Skip to content

Commit ddbb294

Browse files
danielewoodclaude
andcommitted
fix: address PR review comments for OtherName SAN generation
Resolves review findings from PR #74: - ERR-4: lowercase error strings in ResolveOtherNameOID and csr.go - ERR-2: add ErrUnknownOtherNameType sentinel error, use errors.Is in tests - ERR-5: add slog.Debug before continue in parseOtherNameEntriesFromSANBytes - Fix marshalOtherNameGN encoding UTF8String via explicit RawValue tag - Add IP address length and nil URI validation in MarshalSANExtension - T-9: remove stdlib-testing assertions from TestMarshalSANExtension_mTLSUserCert - Refactor OtherName SAN parsing to share walkOtherNameSANs callback - CL-3/CL-4: add [#74] refs to all Unreleased changelog entries Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4a0f7a3 commit ddbb294

5 files changed

Lines changed: 85 additions & 84 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12-
- Add `MarshalSANExtension` for building complete SAN extensions with OtherName support (UPN, XMPP, SRV, SmtpUTF8Mailbox, arbitrary OIDs)
13-
- Add `ResolveOtherNameOID` for resolving OtherName labels or dotted-decimal OID strings
14-
- Add `OtherNameSAN` and `MarshalSANExtensionInput` types for OtherName SAN generation
15-
- Add `other_names` field to `CSRTemplate` for mTLS user identity certificate CSRs
16-
- Add OtherName SAN preservation in `GenerateCSRFromCSR` — OtherName entries survive CSR-to-CSR key rotation
12+
- Add `MarshalSANExtension` for building complete SAN extensions with OtherName support (UPN, XMPP, SRV, SmtpUTF8Mailbox, arbitrary OIDs) ([#74])
13+
- Add `ResolveOtherNameOID` for resolving OtherName labels or dotted-decimal OID strings ([#74])
14+
- Add `OtherNameSAN` and `MarshalSANExtensionInput` types for OtherName SAN generation ([#74])
15+
- Add `other_names` field to `CSRTemplate` for mTLS user identity certificate CSRs ([#74])
16+
- Add OtherName SAN preservation in `GenerateCSRFromCSR` — OtherName entries survive CSR-to-CSR key rotation ([#74])
17+
- Add `ErrUnknownOtherNameType` sentinel error for invalid OtherName type strings ([#74])
1718

1819
### Fixed
1920

2021
- Fix AIA proxy rejecting `cacerts.geotrust.com` and `cacerts.thawte.com` — consolidate all per-host CA entries into suffix matches for broader coverage of CA subdomains
22+
- Fix `marshalOtherNameGN` encoding non-SRV OtherName values as PrintableString instead of UTF8String ([#74])
23+
- Fix `MarshalSANExtension` accepting nil URI entries and invalid IP addresses without validation ([#74])
24+
- Fix `parseOtherNameEntriesFromSANBytes` silently discarding parse errors without logging (ERR-5) ([#74])
25+
- Fix error strings in `ResolveOtherNameOID` using capitalized "OtherName" instead of lowercase (ERR-4) ([#74])
26+
- Refactor OtherName SAN parsing to share common walker between `parseOtherNamesFromSANBytes` and `parseOtherNameEntriesFromSANBytes` ([#74])
2127

2228
### Tests
2329

24-
- Add `TestMarshalSANExtension` table-driven tests covering UPN, SRV (IA5String), DNS+UPN mixed, all types combined, multiple OtherNames, arbitrary OIDs, IPv4+IPv6
25-
- Add `TestMarshalSANExtension_CertificateRoundTrip` — full encode→decode round-trip through `x509.CreateCertificate`
26-
- Add `TestMarshalSANExtension_mTLSUserCert` — CA-signed leaf with UPN + rfc822Name + ClientAuth EKU, chain verification
27-
- Add `TestResolveOtherNameOID` table-driven tests for known labels, dotted OIDs, and error cases
28-
- Add `TestParseCSRTemplate_WithOtherNames` — JSON parsing with and without `other_names` field
29-
- Add `TestGenerateCSRFromTemplate_WithOtherNames` table-driven tests including RFC 5280 duplicate SAN check
30-
- Add `TestGenerateCSRFromCSR_PreservesOtherNames` — OtherName survival through CSR regeneration
30+
- Add `TestMarshalSANExtension` table-driven tests covering UPN, SRV (IA5String), DNS+UPN mixed, all types combined, multiple OtherNames, arbitrary OIDs, IPv4+IPv6 ([#74])
31+
- Add `TestMarshalSANExtension_CertificateRoundTrip` — full encode→decode round-trip through `x509.CreateCertificate` ([#74])
32+
- Add `TestMarshalSANExtension_mTLSUserCert` — CA-signed leaf with UPN + rfc822Name + ClientAuth EKU ([#74])
33+
- Add `TestResolveOtherNameOID` table-driven tests for known labels, dotted OIDs, and error cases ([#74])
34+
- Add `TestParseCSRTemplate_WithOtherNames` — JSON parsing with and without `other_names` field ([#74])
35+
- Add `TestGenerateCSRFromTemplate_WithOtherNames` table-driven tests including RFC 5280 duplicate SAN check ([#74])
36+
- Add `TestGenerateCSRFromCSR_PreservesOtherNames` — OtherName survival through CSR regeneration ([#74])
37+
- Remove stdlib-testing assertions from `TestMarshalSANExtension_mTLSUserCert` (EKU check, chain verification) per T-9 ([#74])
3138

3239
## [0.8.1] - 2026-02-25
3340

@@ -690,6 +697,7 @@ Initial release.
690697
[`55b5c1e`]: https://github.com/sensiblebit/certkit/commit/55b5c1e
691698
[`8cf81d9`]: https://github.com/sensiblebit/certkit/commit/8cf81d9
692699
[`3569926`]: https://github.com/sensiblebit/certkit/commit/3569926
700+
[#74]: https://github.com/sensiblebit/certkit/pull/74
693701
[#64]: https://github.com/sensiblebit/certkit/pull/64
694702
[#63]: https://github.com/sensiblebit/certkit/pull/63
695703
[#58]: https://github.com/sensiblebit/certkit/pull/58

csr.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ func GenerateCSRFromTemplate(tmpl *CSRTemplate, signer crypto.Signer) (string, e
159159
for _, on := range tmpl.OtherNames {
160160
oid, err := ResolveOtherNameOID(on.Type)
161161
if err != nil {
162-
return "", fmt.Errorf("resolving OtherName type: %w", err)
162+
return "", fmt.Errorf("resolving othername type: %w", err)
163163
}
164164
otherNames = append(otherNames, OtherNameSAN{OID: oid, Value: on.Value})
165165
}

csr_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"crypto/x509/pkix"
99
"encoding/asn1"
1010
"encoding/pem"
11+
"errors"
1112
"slices"
1213
"strings"
1314
"testing"
@@ -865,8 +866,8 @@ func TestGenerateCSRFromTemplate_InvalidOtherNameType(t *testing.T) {
865866
if err == nil {
866867
t.Error("expected error for invalid OtherName type")
867868
}
868-
if !strings.Contains(err.Error(), "resolving OtherName type") {
869-
t.Errorf("error should mention resolving OtherName type, got: %v", err)
869+
if !errors.Is(err, ErrUnknownOtherNameType) {
870+
t.Errorf("error should wrap ErrUnknownOtherNameType, got: %v", err)
870871
}
871872
}
872873

dn.go

Lines changed: 61 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@ import (
77
"encoding/hex"
88
"errors"
99
"fmt"
10+
"log/slog"
1011
"net"
1112
"net/url"
1213
"strconv"
1314
"strings"
1415
)
1516

17+
// ErrUnknownOtherNameType is returned when an OtherName type string is not a
18+
// recognized label or a valid dotted-decimal OID.
19+
var ErrUnknownOtherNameType = errors.New("unknown othername type")
20+
1621
var extKeyUsageNames = map[x509.ExtKeyUsage]string{
1722
x509.ExtKeyUsageAny: "Any",
1823
x509.ExtKeyUsageServerAuth: "Server Authentication",
@@ -210,6 +215,9 @@ func MarshalSANExtension(input MarshalSANExtensionInput) (pkix.Extension, error)
210215
if ipBytes == nil {
211216
ipBytes = ip.To16()
212217
}
218+
if len(ipBytes) != 4 && len(ipBytes) != 16 {
219+
return pkix.Extension{}, fmt.Errorf("invalid IP address %v: must be 4 or 16 bytes", ip)
220+
}
213221
b, err := asn1.Marshal(asn1.RawValue{
214222
Class: asn1.ClassContextSpecific,
215223
Tag: 7,
@@ -222,6 +230,9 @@ func MarshalSANExtension(input MarshalSANExtensionInput) (pkix.Extension, error)
222230
}
223231

224232
for _, uri := range input.URIs {
233+
if uri == nil {
234+
return pkix.Extension{}, errors.New("nil URI in SAN input")
235+
}
225236
b, err := asn1.Marshal(asn1.RawValue{
226237
Class: asn1.ClassContextSpecific,
227238
Tag: 6,
@@ -255,20 +266,20 @@ func MarshalSANExtension(input MarshalSANExtensionInput) (pkix.Extension, error)
255266
// asn1.ObjectIdentifier.
256267
func ResolveOtherNameOID(s string) (asn1.ObjectIdentifier, error) {
257268
if s == "" {
258-
return nil, errors.New("empty OtherName type")
269+
return nil, errors.New("empty othername type")
259270
}
260271
if oid, ok := otherNameOIDs[s]; ok {
261272
return oid, nil
262273
}
263274
parts := strings.Split(s, ".")
264275
if len(parts) < 2 {
265-
return nil, fmt.Errorf("invalid OtherName type %q: not a known label or valid OID", s)
276+
return nil, fmt.Errorf("%w %q: not a known label or valid OID", ErrUnknownOtherNameType, s)
266277
}
267278
oid := make(asn1.ObjectIdentifier, 0, len(parts))
268279
for _, part := range parts {
269280
n, err := strconv.Atoi(part)
270281
if err != nil || n < 0 {
271-
return nil, fmt.Errorf("invalid OtherName type %q: not a known label or valid OID", s)
282+
return nil, fmt.Errorf("%w %q: not a known label or valid OID", ErrUnknownOtherNameType, s)
272283
}
273284
oid = append(oid, n)
274285
}
@@ -306,7 +317,11 @@ func marshalOtherNameGN(on OtherNameSAN) ([]byte, error) {
306317
Bytes: []byte(on.Value),
307318
})
308319
} else {
309-
valueBytes, err = asn1.Marshal(on.Value)
320+
valueBytes, err = asn1.Marshal(asn1.RawValue{
321+
Tag: tag,
322+
Class: asn1.ClassUniversal,
323+
Bytes: []byte(on.Value),
324+
})
310325
}
311326
if err != nil {
312327
return nil, fmt.Errorf("marshaling OtherName value: %w", err)
@@ -346,13 +361,31 @@ func parseOtherNameSANEntries(extensions []pkix.Extension) []OtherNameSAN {
346361
}
347362

348363
func parseOtherNameEntriesFromSANBytes(raw []byte) []OtherNameSAN {
364+
var entries []OtherNameSAN
365+
walkOtherNameSANs(raw, func(oid asn1.ObjectIdentifier, valueBytes []byte) {
366+
var strVal string
367+
if _, strErr := asn1.Unmarshal(valueBytes, &strVal); strErr != nil {
368+
slog.Debug("skipping OtherName entry: string value parse failed", "oid", oid, "error", strErr)
369+
return
370+
}
371+
entries = append(entries, OtherNameSAN{OID: oid, Value: strVal})
372+
})
373+
return entries
374+
}
375+
376+
// walkOtherNameSANs iterates over the OtherName GeneralName entries in raw
377+
// SAN extension bytes and calls fn for each successfully parsed entry. The
378+
// valueBytes passed to fn are the inner bytes of the [0] EXPLICIT wrapper
379+
// (i.e. the TLV-encoded string value). This shared walker is used by both
380+
// parseOtherNameEntriesFromSANBytes (structured extraction) and
381+
// parseOtherNamesFromSANBytes (display formatting).
382+
func walkOtherNameSANs(raw []byte, fn func(oid asn1.ObjectIdentifier, valueBytes []byte)) {
349383
var seq asn1.RawValue
350384
rest, err := asn1.Unmarshal(raw, &seq)
351385
if err != nil || len(rest) > 0 {
352-
return nil
386+
return
353387
}
354388

355-
var entries []OtherNameSAN
356389
inner := seq.Bytes
357390
for len(inner) > 0 {
358391
var gn asn1.RawValue
@@ -374,20 +407,18 @@ func parseOtherNameEntriesFromSANBytes(raw []byte) []OtherNameSAN {
374407
var oid asn1.ObjectIdentifier
375408
oidRest, oidErr := asn1.Unmarshal(content, &oid)
376409
if oidErr != nil {
410+
slog.Debug("skipping OtherName entry: OID parse failed", "error", oidErr)
377411
continue
378412
}
379413

380414
var explicit asn1.RawValue
381415
if _, explErr := asn1.Unmarshal(oidRest, &explicit); explErr != nil {
416+
slog.Debug("skipping OtherName entry: explicit tag parse failed", "oid", oid, "error", explErr)
382417
continue
383418
}
384419

385-
var strVal string
386-
if _, strErr := asn1.Unmarshal(explicit.Bytes, &strVal); strErr == nil {
387-
entries = append(entries, OtherNameSAN{OID: oid, Value: strVal})
388-
}
420+
fn(oid, explicit.Bytes)
389421
}
390-
return entries
391422
}
392423

393424
// ParseOtherNameSANs extracts SAN entries that Go's x509 package silently
@@ -413,7 +444,23 @@ func parseOtherNamesFromSANBytes(raw []byte) []string {
413444
return nil
414445
}
415446

447+
// Collect OtherName entries via shared walker.
416448
var sans []string
449+
walkOtherNameSANs(raw, func(oid asn1.ObjectIdentifier, valueBytes []byte) {
450+
label := oid.String()
451+
if name, ok := otherNameLabels[label]; ok {
452+
label = name
453+
}
454+
var strVal string
455+
if _, strErr := asn1.Unmarshal(valueBytes, &strVal); strErr == nil {
456+
sans = append(sans, label+":"+strVal)
457+
} else {
458+
sans = append(sans, label+":"+hex.EncodeToString(valueBytes))
459+
}
460+
})
461+
462+
// Handle DirectoryName (tag 4) and RegisteredID (tag 8) which the
463+
// OtherName walker does not cover.
417464
inner := seq.Bytes
418465
for len(inner) > 0 {
419466
var gn asn1.RawValue
@@ -425,11 +472,6 @@ func parseOtherNamesFromSANBytes(raw []byte) []string {
425472
continue
426473
}
427474
switch gn.Tag {
428-
case 0: // otherName [0] IMPLICIT SEQUENCE { OID, [0] EXPLICIT ANY }
429-
s := formatOtherName(gn.Bytes)
430-
if s != "" {
431-
sans = append(sans, s)
432-
}
433475
case 4: // directoryName [4]
434476
var name pkix.RDNSequence
435477
if _, err := asn1.Unmarshal(gn.Bytes, &name); err == nil {
@@ -444,46 +486,11 @@ func parseOtherNamesFromSANBytes(raw []byte) []string {
444486
}
445487
}
446488
}
447-
return sans
448-
}
449-
450-
func formatOtherName(data []byte) string {
451-
// OtherName ::= SEQUENCE { type-id OID, value [0] EXPLICIT ANY }
452-
//
453-
// With IMPLICIT tagging (RFC 5280), [0] replaces the SEQUENCE tag so
454-
// data starts with the OID directly. Some encoders use EXPLICIT tagging
455-
// or wrap in a SEQUENCE, so data may start with a SEQUENCE tag (0x30).
456-
content := data
457-
var probe asn1.RawValue
458-
if _, err := asn1.Unmarshal(data, &probe); err == nil && probe.Tag == asn1.TagSequence && probe.Class == asn1.ClassUniversal {
459-
content = probe.Bytes
460-
}
461489

462-
var oid asn1.ObjectIdentifier
463-
rest, err := asn1.Unmarshal(content, &oid)
464-
if err != nil {
465-
return ""
466-
}
467-
468-
label := oid.String()
469-
if name, ok := otherNameLabels[label]; ok {
470-
label = name
471-
}
472-
473-
// Unwrap the [0] EXPLICIT wrapper to get the inner value.
474-
var explicit asn1.RawValue
475-
if _, err := asn1.Unmarshal(rest, &explicit); err != nil {
476-
return label + ":<unparseable>"
477-
}
478-
479-
// Try common string types (UTF8String, IA5String, PrintableString).
480-
var strVal string
481-
if _, err := asn1.Unmarshal(explicit.Bytes, &strVal); err == nil {
482-
return label + ":" + strVal
490+
if len(sans) == 0 {
491+
return nil
483492
}
484-
485-
// Fallback: hex-encode the raw value.
486-
return label + ":" + hex.EncodeToString(explicit.Bytes)
493+
return sans
487494
}
488495

489496
// extraOIDLabels maps OIDs not handled by Go's pkix.Name.String() to their

dn_test.go

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1217,21 +1217,6 @@ func TestMarshalSANExtension_mTLSUserCert(t *testing.T) {
12171217
t.Errorf("OtherNames = %v, want [UPN:alice@corp.example.com]", otherNames)
12181218
}
12191219

1220-
// Verify ClientAuth EKU
1221-
if len(leafCert.ExtKeyUsage) != 1 || leafCert.ExtKeyUsage[0] != x509.ExtKeyUsageClientAuth {
1222-
t.Errorf("ExtKeyUsage = %v, want [ClientAuth]", leafCert.ExtKeyUsage)
1223-
}
1224-
1225-
// Verify the certificate chains to the CA
1226-
roots := x509.NewCertPool()
1227-
roots.AddCert(caCert)
1228-
_, err = leafCert.Verify(x509.VerifyOptions{
1229-
Roots: roots,
1230-
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
1231-
})
1232-
if err != nil {
1233-
t.Errorf("certificate chain verification failed: %v", err)
1234-
}
12351220
}
12361221

12371222
// mustParseURL parses a URL string or fails the test.

0 commit comments

Comments
 (0)