Skip to content

Commit ba3a93c

Browse files
committed
feat(certificate): add RFC 9399 logotype parsing to inspect command
This parses the logotype extension (OID 1.3.6.1.5.5.7.1.12) and prints the URI to the console output. Includes unit tests for all logotype categories.
1 parent 5746dd2 commit ba3a93c

2 files changed

Lines changed: 297 additions & 0 deletions

File tree

command/certificate/inspect.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package certificate
22

33
import (
44
"crypto/x509"
5+
"crypto/x509/pkix"
6+
"encoding/asn1"
57
"encoding/json"
68
"encoding/pem"
79
"fmt"
@@ -263,6 +265,7 @@ func inspectCertificates(ctx *cli.Context, crts []*x509.Certificate, w io.Writer
263265
}
264266
}
265267
fmt.Fprint(w, text)
268+
printLogotypes(crt, w)
266269
}
267270
return nil
268271
case "json":
@@ -344,3 +347,148 @@ func inspectCertificateRequest(ctx *cli.Context, csr *x509.CertificateRequest, w
344347
return errs.InvalidFlagValue(ctx, "format", format, "text, json")
345348
}
346349
}
350+
351+
type HashAlgAndValue struct {
352+
HashAlgorithm pkix.AlgorithmIdentifier
353+
HashValue []byte
354+
}
355+
356+
type LogotypeDetails struct {
357+
MediaType string `asn1:"ia5"`
358+
LogotypeHash []HashAlgAndValue
359+
LogotypeURI []string
360+
}
361+
362+
type LogotypeImageInfo struct {
363+
Type int `asn1:"optional,tag:0"`
364+
FileSize int
365+
XSize int
366+
YSize int
367+
Resolution asn1.RawValue `asn1:"optional"`
368+
Language string `asn1:"optional,ia5,tag:4"`
369+
}
370+
371+
type LogotypeImage struct {
372+
ImageDetails LogotypeDetails
373+
ImageInfo LogotypeImageInfo `asn1:"optional"`
374+
}
375+
376+
type LogotypeAudioInfo struct {
377+
FileSize int
378+
PlayTime int
379+
Channels int
380+
SampleRate int `asn1:"optional,tag:3"`
381+
Language string `asn1:"optional,ia5,tag:4"`
382+
}
383+
384+
type LogotypeAudio struct {
385+
AudioDetails LogotypeDetails
386+
AudioInfo LogotypeAudioInfo `asn1:"optional"`
387+
}
388+
389+
type LogotypeData struct {
390+
Image []LogotypeImage `asn1:"optional"`
391+
Audio []LogotypeAudio `asn1:"optional,tag:1"`
392+
}
393+
394+
type LogotypeReference struct {
395+
RefStructHash []HashAlgAndValue
396+
RefStructURI []string
397+
}
398+
399+
type OtherLogotypeInfo struct {
400+
LogotypeType asn1.ObjectIdentifier
401+
Info asn1.RawValue
402+
}
403+
404+
type LogotypeExtn struct {
405+
CommunityLogos []asn1.RawValue `asn1:"explicit,optional,tag:0"`
406+
IssuerLogo asn1.RawValue `asn1:"explicit,optional,tag:1"`
407+
SubjectLogo asn1.RawValue `asn1:"explicit,optional,tag:2"`
408+
OtherLogos []asn1.RawValue `asn1:"explicit,optional,tag:3"`
409+
}
410+
411+
func unmarshalImplicitSequence(val asn1.RawValue, out interface{}) error {
412+
der := val.FullBytes
413+
if len(der) == 0 {
414+
return io.EOF
415+
}
416+
derCopy := make([]byte, len(der))
417+
copy(derCopy, der)
418+
derCopy[0] = 0x30
419+
_, err := asn1.Unmarshal(derCopy, out)
420+
return err
421+
}
422+
423+
func parseLogotypeURIs(value []byte) ([]string, error) {
424+
var ext LogotypeExtn
425+
if _, err := asn1.Unmarshal(value, &ext); err != nil {
426+
return nil, err
427+
}
428+
429+
var uris []string
430+
431+
extractFromInfo := func(raw asn1.RawValue) {
432+
switch raw.Tag {
433+
case 0: // direct (LogotypeData)
434+
var data LogotypeData
435+
if err := unmarshalImplicitSequence(raw, &data); err == nil {
436+
for _, img := range data.Image {
437+
uris = append(uris, img.ImageDetails.LogotypeURI...)
438+
}
439+
for _, aud := range data.Audio {
440+
uris = append(uris, aud.AudioDetails.LogotypeURI...)
441+
}
442+
}
443+
case 1: // indirect (LogotypeReference)
444+
var ref LogotypeReference
445+
if err := unmarshalImplicitSequence(raw, &ref); err == nil {
446+
uris = append(uris, ref.RefStructURI...)
447+
}
448+
}
449+
}
450+
451+
// 1. communityLogos
452+
for _, logoInfo := range ext.CommunityLogos {
453+
extractFromInfo(logoInfo)
454+
}
455+
456+
// 2. issuerLogo
457+
if len(ext.IssuerLogo.Bytes) > 0 {
458+
var choice asn1.RawValue
459+
if _, err := asn1.Unmarshal(ext.IssuerLogo.Bytes, &choice); err == nil {
460+
extractFromInfo(choice)
461+
}
462+
}
463+
464+
// 3. subjectLogo
465+
if len(ext.SubjectLogo.Bytes) > 0 {
466+
var choice asn1.RawValue
467+
if _, err := asn1.Unmarshal(ext.SubjectLogo.Bytes, &choice); err == nil {
468+
extractFromInfo(choice)
469+
}
470+
}
471+
472+
// 4. otherLogos
473+
for _, other := range ext.OtherLogos {
474+
var otherInfo OtherLogotypeInfo
475+
if _, err := asn1.Unmarshal(other.FullBytes, &otherInfo); err == nil {
476+
extractFromInfo(otherInfo.Info)
477+
}
478+
}
479+
480+
return uris, nil
481+
}
482+
483+
func printLogotypes(crt *x509.Certificate, w io.Writer) {
484+
for _, ext := range crt.Extensions {
485+
if ext.Id.Equal(asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 12}) {
486+
uris, err := parseLogotypeURIs(ext.Value)
487+
if err == nil && len(uris) > 0 {
488+
for _, uri := range uris {
489+
fmt.Fprintf(w, "Logotype URI: %s\n", uri)
490+
}
491+
}
492+
}
493+
}
494+
}

command/certificate/inspect_test.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@ package certificate
22

33
import (
44
"bytes"
5+
"crypto/x509"
6+
"crypto/x509/pkix"
7+
"encoding/asn1"
58
"encoding/json"
69
"flag"
10+
"strings"
711
"testing"
812

913
"github.com/smallstep/assert"
@@ -138,3 +142,148 @@ func TestInspectCertificateRequest(t *testing.T) {
138142
}
139143

140144
}
145+
146+
func TestInspectCertificates_Logotypes(t *testing.T) {
147+
mustMarshal := func(val interface{}) []byte {
148+
b, err := asn1.Marshal(val)
149+
if err != nil {
150+
t.Fatal(err)
151+
}
152+
return b
153+
}
154+
155+
wrapExplicit := func(tag int, payload []byte) []byte {
156+
var lenBytes []byte
157+
length := len(payload)
158+
if length < 128 {
159+
lenBytes = []byte{byte(length)}
160+
} else if length < 256 {
161+
lenBytes = []byte{0x81, byte(length)}
162+
} else {
163+
lenBytes = []byte{0x82, byte(length >> 8), byte(length & 0xff)}
164+
}
165+
result := []byte{byte(0xa0 | tag)}
166+
result = append(result, lenBytes...)
167+
result = append(result, payload...)
168+
return result
169+
}
170+
171+
wrapSequence := func(payload []byte) []byte {
172+
var lenBytes []byte
173+
length := len(payload)
174+
if length < 128 {
175+
lenBytes = []byte{byte(length)}
176+
} else if length < 256 {
177+
lenBytes = []byte{0x81, byte(length)}
178+
} else {
179+
lenBytes = []byte{0x82, byte(length >> 8), byte(length & 0xff)}
180+
}
181+
result := []byte{0x30}
182+
result = append(result, lenBytes...)
183+
result = append(result, payload...)
184+
return result
185+
}
186+
187+
// Direct CHOICE value [0] LogotypeData
188+
directDataBytes := mustMarshal(LogotypeData{
189+
Image: []LogotypeImage{
190+
{
191+
ImageDetails: LogotypeDetails{
192+
MediaType: "image/png",
193+
LogotypeURI: []string{"https://example.com/subject-direct.png"},
194+
},
195+
},
196+
},
197+
})
198+
directDataBytes[0] = 0xa0 // choice tag [0] implicit
199+
200+
// Indirect CHOICE value [1] LogotypeReference
201+
indirectRefBytes := mustMarshal(LogotypeReference{
202+
RefStructURI: []string{"https://example.com/issuer-indirect.png"},
203+
})
204+
indirectRefBytes[0] = 0xa1 // choice tag [1] implicit
205+
206+
// Community LOGOS list element (direct Choice [0] LogotypeData)
207+
communityDirectBytes := mustMarshal(LogotypeData{
208+
Image: []LogotypeImage{
209+
{
210+
ImageDetails: LogotypeDetails{
211+
MediaType: "image/svg+xml",
212+
LogotypeURI: []string{"https://example.com/community-direct.svg"},
213+
},
214+
},
215+
},
216+
})
217+
communityDirectBytes[0] = 0xa0
218+
219+
// Community LOGOS list element (indirect Choice [1] LogotypeReference)
220+
communityIndirectBytes := mustMarshal(LogotypeReference{
221+
RefStructURI: []string{"https://example.com/community-indirect.png"},
222+
})
223+
communityIndirectBytes[0] = 0xa1
224+
225+
// OtherLogotypeInfo element
226+
otherInfoBytes := mustMarshal(OtherLogotypeInfo{
227+
LogotypeType: asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 12, 99},
228+
Info: asn1.RawValue{
229+
FullBytes: communityDirectBytes,
230+
},
231+
})
232+
233+
// Wrap in communityLogos explicit tag [0]
234+
communitySeqBytes := append(communityDirectBytes, communityIndirectBytes...)
235+
communityLogosDER := wrapExplicit(0, wrapSequence(communitySeqBytes))
236+
237+
// Wrap in issuerLogo explicit tag [1]
238+
issuerLogoDER := wrapExplicit(1, indirectRefBytes)
239+
240+
// Wrap in subjectLogo explicit tag [2]
241+
subjectLogoDER := wrapExplicit(2, directDataBytes)
242+
243+
// Wrap in otherLogos explicit tag [3]
244+
otherLogosDER := wrapExplicit(3, wrapSequence(otherInfoBytes))
245+
246+
var extnBytes []byte
247+
extnBytes = append(extnBytes, communityLogosDER...)
248+
extnBytes = append(extnBytes, issuerLogoDER...)
249+
extnBytes = append(extnBytes, subjectLogoDER...)
250+
extnBytes = append(extnBytes, otherLogosDER...)
251+
252+
extDER := wrapSequence(extnBytes)
253+
254+
certs, err := pemutil.ParseCertificateBundle(pemData)
255+
if err != nil {
256+
t.Fatal(err)
257+
}
258+
crt := certs[0]
259+
crt.Extensions = append(crt.Extensions, pkix.Extension{
260+
Id: asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 12},
261+
Value: extDER,
262+
})
263+
264+
app := &cli.App{}
265+
set := flag.NewFlagSet("contrive", 0)
266+
_ = set.String("format", "text", "")
267+
ctx := cli.NewContext(app, set, nil)
268+
269+
var buf bytes.Buffer
270+
err = inspectCertificates(ctx, []*x509.Certificate{crt}, &buf)
271+
assert.NoError(t, err)
272+
273+
output := buf.String()
274+
t.Logf("Output:\n%s", output)
275+
276+
if !strings.Contains(output, "Logotype URI: https://example.com/community-direct.svg") {
277+
t.Error("missing community-direct URI")
278+
}
279+
if !strings.Contains(output, "Logotype URI: https://example.com/community-indirect.png") {
280+
t.Error("missing community-indirect URI")
281+
}
282+
if !strings.Contains(output, "Logotype URI: https://example.com/issuer-indirect.png") {
283+
t.Error("missing issuer-indirect URI")
284+
}
285+
if !strings.Contains(output, "Logotype URI: https://example.com/subject-direct.png") {
286+
t.Error("missing subject-direct URI")
287+
}
288+
}
289+

0 commit comments

Comments
 (0)