Skip to content

Commit 7e9656a

Browse files
committed
cactus-cli: fix entry type decode and pretty-print tbs_cert_entry
entryShow read the MerkleTreeCertEntry type from the first two bytes, but those are the extensions<0..2^16-1> length prefix (always 0x0000 since cactus emits an empty extensions vector). The type lives *after* the extensions vector, so every entry decoded as type 0 and printed as null_entry. Skip the extensions vector before reading the uint16 type. Add cert.ParseTBSCertificateLogEntry (the inverse of MarshalContents) and use it to pretty-print the decoded fields — version, issuer/subject DN, validity, SPKI algorithm and hash, and extensions (including SAN dNSNames) — instead of a raw hex preview.
1 parent 5b8c349 commit 7e9656a

3 files changed

Lines changed: 339 additions & 4 deletions

File tree

cert/entry.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cert
22

33
import (
44
"crypto/sha256"
5+
"encoding/asn1"
56
"errors"
67
"fmt"
78
"hash"
@@ -233,6 +234,109 @@ func (e *TBSCertificateLogEntry) MarshalDER() ([]byte, error) {
233234
return wrapSequence(b.Bytes()), nil
234235
}
235236

237+
// ParseTBSCertificateLogEntry is the inverse of MarshalContents: it
238+
// decodes the contents octets of a TBSCertificateLogEntry (i.e.
239+
// MerkleTreeCertEntry.tbs_cert_entry_data, without the outer SEQUENCE
240+
// header) into the structured fields. The DER-valued fields (IssuerDN,
241+
// SubjectDN, SubjectPublicKeyAlgorithm, Extensions) are returned as their
242+
// raw DER, exactly as MarshalDER would emit them, so round-tripping is
243+
// lossless.
244+
func ParseTBSCertificateLogEntry(contents []byte) (*TBSCertificateLogEntry, error) {
245+
e := &TBSCertificateLogEntry{}
246+
rest := contents
247+
248+
read := func(field string) (asn1.RawValue, error) {
249+
var rv asn1.RawValue
250+
var err error
251+
rest, err = asn1.Unmarshal(rest, &rv)
252+
if err != nil {
253+
return rv, fmt.Errorf("parse %s: %w", field, err)
254+
}
255+
return rv, nil
256+
}
257+
258+
// version [0] EXPLICIT Version DEFAULT v1 — present only when != v1.
259+
rv, err := read("version/issuer")
260+
if err != nil {
261+
return nil, err
262+
}
263+
if rv.Class == asn1.ClassContextSpecific && rv.Tag == 0 {
264+
var v int
265+
if _, err := asn1.Unmarshal(rv.Bytes, &v); err != nil {
266+
return nil, fmt.Errorf("parse version: %w", err)
267+
}
268+
e.Version = v
269+
if rv, err = read("issuer"); err != nil {
270+
return nil, err
271+
}
272+
}
273+
274+
// issuer Name (the RawValue currently in rv).
275+
e.IssuerDN = rv.FullBytes
276+
277+
// validity SEQUENCE { notBefore Time, notAfter Time }.
278+
validity, err := read("validity")
279+
if err != nil {
280+
return nil, err
281+
}
282+
vrest := validity.Bytes
283+
for i, dst := range []*time.Time{&e.NotBefore, &e.NotAfter} {
284+
var t time.Time
285+
vrest, err = asn1.Unmarshal(vrest, &t)
286+
if err != nil {
287+
return nil, fmt.Errorf("parse validity[%d]: %w", i, err)
288+
}
289+
*dst = t
290+
}
291+
292+
// subject Name.
293+
subject, err := read("subject")
294+
if err != nil {
295+
return nil, err
296+
}
297+
e.SubjectDN = subject.FullBytes
298+
299+
// subjectPublicKeyAlgorithm AlgorithmIdentifier.
300+
alg, err := read("subjectPublicKeyAlgorithm")
301+
if err != nil {
302+
return nil, err
303+
}
304+
e.SubjectPublicKeyAlgorithm = alg.FullBytes
305+
306+
// subjectPublicKeyInfoHash OCTET STRING.
307+
spkiHash, err := read("subjectPublicKeyInfoHash")
308+
if err != nil {
309+
return nil, err
310+
}
311+
if spkiHash.Tag != asn1.TagOctetString || spkiHash.Class != asn1.ClassUniversal {
312+
return nil, fmt.Errorf("subjectPublicKeyInfoHash: unexpected tag 0x%02x", spkiHash.FullBytes[0])
313+
}
314+
e.SubjectPublicKeyInfoHash = spkiHash.Bytes
315+
316+
// Optional tail: issuerUniqueID [1], subjectUniqueID [2], extensions [3].
317+
for len(rest) > 0 {
318+
rv, err := read("tail field")
319+
if err != nil {
320+
return nil, err
321+
}
322+
if rv.Class != asn1.ClassContextSpecific {
323+
return nil, fmt.Errorf("unexpected entry field class=%d tag=%d", rv.Class, rv.Tag)
324+
}
325+
switch rv.Tag {
326+
case 1:
327+
e.IssuerUniqueID = rv.Bytes
328+
case 2:
329+
e.SubjectUniqueID = rv.Bytes
330+
case 3:
331+
// extensions [3] EXPLICIT — rv.Bytes is the Extensions SEQUENCE DER.
332+
e.Extensions = rv.Bytes
333+
default:
334+
return nil, fmt.Errorf("unknown entry context-specific tag %d", rv.Tag)
335+
}
336+
}
337+
return e, nil
338+
}
339+
236340
// EntryHash implements the §7.2 single-pass hash for a tbs_cert_entry
237341
// with an empty extensions vector (the only form cactus emits):
238342
//

cert/entry_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,79 @@ func TestMarshalDERIsParseable(t *testing.T) {
116116
}
117117
}
118118

119+
func TestParseTBSCertificateLogEntryRoundTrip(t *testing.T) {
120+
dn, err := BuildCAName("32473.1")
121+
if err != nil {
122+
t.Fatal(err)
123+
}
124+
subjectDN, err := BuildCAName("cactus.test/example")
125+
if err != nil {
126+
t.Fatal(err)
127+
}
128+
algID := []byte{
129+
0x30, 0x13,
130+
0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01,
131+
0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07,
132+
}
133+
// A minimal Extensions SEQUENCE with one extension.
134+
exts := []byte{
135+
0x30, 0x09,
136+
0x30, 0x07,
137+
0x06, 0x03, 0x55, 0x1d, 0x0f, // OID 2.5.29.15 (keyUsage)
138+
0x04, 0x00, // empty OCTET STRING value
139+
}
140+
141+
for _, tc := range []struct {
142+
name string
143+
e *TBSCertificateLogEntry
144+
}{
145+
{"minimal", &TBSCertificateLogEntry{
146+
Version: 2,
147+
IssuerDN: dn,
148+
NotBefore: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
149+
NotAfter: time.Date(2026, 5, 8, 0, 0, 0, 0, time.UTC),
150+
SubjectDN: subjectDN,
151+
SubjectPublicKeyAlgorithm: algID,
152+
SubjectPublicKeyInfoHash: bytes.Repeat([]byte{0xab}, 32),
153+
}},
154+
{"v1-with-extensions", &TBSCertificateLogEntry{
155+
Version: 0, // v1: version field omitted
156+
IssuerDN: dn,
157+
NotBefore: time.Date(2051, 1, 2, 3, 4, 5, 0, time.UTC), // GeneralizedTime path
158+
NotAfter: time.Date(2052, 6, 7, 8, 9, 10, 0, time.UTC),
159+
SubjectDN: subjectDN,
160+
SubjectPublicKeyAlgorithm: algID,
161+
SubjectPublicKeyInfoHash: bytes.Repeat([]byte{0xcd}, 32),
162+
Extensions: exts,
163+
}},
164+
} {
165+
t.Run(tc.name, func(t *testing.T) {
166+
contents, err := tc.e.MarshalContents()
167+
if err != nil {
168+
t.Fatal(err)
169+
}
170+
got, err := ParseTBSCertificateLogEntry(contents)
171+
if err != nil {
172+
t.Fatalf("ParseTBSCertificateLogEntry: %v", err)
173+
}
174+
// Re-marshalling the parsed entry must reproduce the input.
175+
reMarshalled, err := got.MarshalContents()
176+
if err != nil {
177+
t.Fatal(err)
178+
}
179+
if !bytes.Equal(reMarshalled, contents) {
180+
t.Errorf("round-trip mismatch:\n got %x\nwant %x", reMarshalled, contents)
181+
}
182+
if got.Version != tc.e.Version {
183+
t.Errorf("Version = %d, want %d", got.Version, tc.e.Version)
184+
}
185+
if !got.NotBefore.Equal(tc.e.NotBefore) || !got.NotAfter.Equal(tc.e.NotAfter) {
186+
t.Errorf("validity = %s..%s, want %s..%s", got.NotBefore, got.NotAfter, tc.e.NotBefore, tc.e.NotAfter)
187+
}
188+
})
189+
}
190+
}
191+
119192
func TestRoundTripDERLength(t *testing.T) {
120193
cases := []int{0, 1, 0x7f, 0x80, 0xff, 0x100, 0xffff, 0x10000}
121194
for _, n := range cases {

cmd/cactus-cli/main.go

Lines changed: 162 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ package main
99

1010
import (
1111
"crypto/sha256"
12+
"crypto/x509/pkix"
13+
"encoding/asn1"
1214
"encoding/base64"
1315
"encoding/pem"
1416
"errors"
@@ -113,19 +115,175 @@ func entryShow(logURL string, idx uint64) {
113115
if err != nil {
114116
die("fetch entry: %v", err)
115117
}
118+
// MerkleTreeCertEntry (§5.2.1): extensions<0..2^16-1> then uint16 type
119+
// then the type-specific data. The leading uint16 is the extensions
120+
// vector length, NOT the type.
116121
if len(body) < 2 {
117122
die("entry too short")
118123
}
119-
t := uint16(body[0])<<8 | uint16(body[1])
124+
extLen := int(body[0])<<8 | int(body[1])
125+
if len(body) < 2+extLen+2 {
126+
die("entry too short (ext_len=%d, %d bytes)", extLen, len(body))
127+
}
128+
rest := body[2+extLen:]
129+
t := uint16(rest[0])<<8 | uint16(rest[1])
130+
data := rest[2:]
120131
switch t {
121132
case 0:
122133
fmt.Printf("entry %d: null_entry\n", idx)
123134
case 1:
124-
fmt.Printf("entry %d: tbs_cert_entry, %d bytes\n", idx, len(body)-2)
125-
fmt.Printf(" raw (first 64 bytes): %x\n", body[2:min(len(body), 66)])
135+
fmt.Printf("entry %d: tbs_cert_entry, %d bytes\n", idx, len(data))
136+
e, err := cert.ParseTBSCertificateLogEntry(data)
137+
if err != nil {
138+
fmt.Printf(" (decode failed: %v)\n", err)
139+
fmt.Printf(" raw (first 64 bytes): %x\n", data[:min(len(data), 64)])
140+
break
141+
}
142+
printLogEntry(e)
126143
default:
127-
fmt.Printf("entry %d: unknown type %d, %d bytes\n", idx, t, len(body))
144+
fmt.Printf("entry %d: unknown type %d, %d bytes\n", idx, t, len(data))
145+
}
146+
}
147+
148+
// printLogEntry pretty-prints the decoded fields of a TBSCertificateLogEntry.
149+
func printLogEntry(e *cert.TBSCertificateLogEntry) {
150+
fmt.Printf(" version: v%d\n", e.Version+1)
151+
fmt.Printf(" issuer: %s\n", formatDN(e.IssuerDN))
152+
fmt.Printf(" not before: %s\n", e.NotBefore.UTC().Format("2006-01-02T15:04:05Z"))
153+
fmt.Printf(" not after: %s\n", e.NotAfter.UTC().Format("2006-01-02T15:04:05Z"))
154+
fmt.Printf(" subject: %s\n", formatDN(e.SubjectDN))
155+
fmt.Printf(" spki alg: %s\n", formatAlgID(e.SubjectPublicKeyAlgorithm))
156+
fmt.Printf(" spki hash: %x (sha-256)\n", e.SubjectPublicKeyInfoHash)
157+
if e.IssuerUniqueID != nil {
158+
fmt.Printf(" issuerUID: %x\n", e.IssuerUniqueID)
159+
}
160+
if e.SubjectUniqueID != nil {
161+
fmt.Printf(" subjectUID: %x\n", e.SubjectUniqueID)
162+
}
163+
if e.Extensions != nil {
164+
fmt.Printf(" extensions:\n")
165+
for _, line := range formatExtensions(e.Extensions) {
166+
fmt.Printf(" %s\n", line)
167+
}
168+
}
169+
}
170+
171+
// attrNames maps the AttributeType OIDs cactus may emit in a Name to
172+
// short labels. Unknown OIDs fall back to dotted notation.
173+
var attrNames = map[string]string{
174+
cert.OIDRDNATrustAnchorID.String(): "trustAnchorID",
175+
"2.5.4.3": "CN",
176+
"2.5.4.6": "C",
177+
"2.5.4.10": "O",
178+
"2.5.4.11": "OU",
179+
}
180+
181+
// formatDN renders a DER-encoded Name (RDNSequence) as "type=value, …".
182+
func formatDN(der []byte) string {
183+
if len(der) == 0 {
184+
return "(empty)"
185+
}
186+
var rdns pkix.RDNSequence
187+
if _, err := asn1.Unmarshal(der, &rdns); err != nil {
188+
return fmt.Sprintf("<unparseable: %x>", der)
189+
}
190+
if len(rdns) == 0 {
191+
return "(empty)"
192+
}
193+
var parts []string
194+
for _, rdn := range rdns {
195+
for _, atv := range rdn {
196+
name := atv.Type.String()
197+
if n, ok := attrNames[name]; ok {
198+
name = n
199+
}
200+
parts = append(parts, fmt.Sprintf("%s=%v", name, atv.Value))
201+
}
202+
}
203+
return strings.Join(parts, ", ")
204+
}
205+
206+
// formatAlgID renders an AlgorithmIdentifier as its OID (named if known).
207+
func formatAlgID(der []byte) string {
208+
var alg struct {
209+
Algorithm asn1.ObjectIdentifier
210+
Parameters asn1.RawValue `asn1:"optional"`
211+
}
212+
if _, err := asn1.Unmarshal(der, &alg); err != nil {
213+
return fmt.Sprintf("<unparseable: %x>", der)
214+
}
215+
oid := alg.Algorithm.String()
216+
if name, ok := algNames[oid]; ok {
217+
return fmt.Sprintf("%s (%s)", name, oid)
218+
}
219+
return oid
220+
}
221+
222+
// algNames maps SPKI algorithm OIDs to friendly names.
223+
var algNames = map[string]string{
224+
"2.16.840.1.101.3.4.3.17": "ML-DSA-44",
225+
"1.2.840.10045.2.1": "ecPublicKey",
226+
"1.2.840.113549.1.1.1": "rsaEncryption",
227+
}
228+
229+
// extOIDNames maps certificate extension OIDs to short labels.
230+
var extOIDNames = map[string]string{
231+
"2.5.29.15": "keyUsage",
232+
"2.5.29.17": "subjectAltName",
233+
"2.5.29.19": "basicConstraints",
234+
"2.5.29.37": "extKeyUsage",
235+
}
236+
237+
// formatExtensions decodes a DER Extensions SEQUENCE into one display
238+
// line per extension.
239+
func formatExtensions(der []byte) []string {
240+
var exts []pkix.Extension
241+
if _, err := asn1.Unmarshal(der, &exts); err != nil {
242+
return []string{fmt.Sprintf("<unparseable: %x>", der)}
243+
}
244+
var lines []string
245+
for _, ext := range exts {
246+
oid := ext.Id.String()
247+
name := extOIDNames[oid]
248+
label := oid
249+
if name != "" {
250+
label = fmt.Sprintf("%s %s", oid, name)
251+
}
252+
if ext.Critical {
253+
label += " (critical)"
254+
}
255+
if name == "subjectAltName" {
256+
if sans := formatSAN(ext.Value); sans != "" {
257+
label += " " + sans
258+
}
259+
}
260+
lines = append(lines, label)
261+
}
262+
return lines
263+
}
264+
265+
// formatSAN extracts the dNSName entries from a SubjectAltName extension
266+
// value (the GeneralNames SEQUENCE), the common case for cactus leaves.
267+
func formatSAN(der []byte) string {
268+
var seq asn1.RawValue
269+
if _, err := asn1.Unmarshal(der, &seq); err != nil {
270+
return ""
271+
}
272+
var names []string
273+
rest := seq.Bytes
274+
for len(rest) > 0 {
275+
var gn asn1.RawValue
276+
var err error
277+
rest, err = asn1.Unmarshal(rest, &gn)
278+
if err != nil {
279+
break
280+
}
281+
// dNSName is [2] IMPLICIT IA5String.
282+
if gn.Class == asn1.ClassContextSpecific && gn.Tag == 2 {
283+
names = append(names, "DNS:"+string(gn.Bytes))
284+
}
128285
}
286+
return strings.Join(names, ", ")
129287
}
130288

131289
// certVerify performs the §7.2 verification: decode MTCProof, recompute

0 commit comments

Comments
 (0)