attach nc

This commit is contained in:
kalsprite
2026-07-12 12:58:45 -07:00
parent e4fc7a3a9e
commit df044411d9
13 changed files with 461 additions and 44 deletions

View File

@@ -35,13 +35,15 @@ LIMITATIONS:
(SHA-1/256/384/512), ECDSA P-256/P-384, and Ed25519. These paths return
.Unsupported_Algorithm: ECDSA P-521 (effectively dead in web PKI), and
RSA-PSS naming a digest or MGF this package does not recognize.
- Name constraints are NOT decoded; verify_chain fails CLOSED on them.
Any CA (intermediate or trust anchor) asserting a nameConstraints
extension, critical or not, is refused as an issuer, so a chain through
a name-constrained CA is rejected rather than accepted unchecked. RFC
5280 section 6.1.4(g) requires a validator that processes name
constraints to enforce them regardless of criticality; until that is
implemented, refusing is the only safe option.
- Name constraints (RFC 5280 4.2.1.10) are enforced for the dNSName and
iPAddress forms: a CA's permitted/excluded subtrees are checked against
every subordinate certificate's SANs, regardless of the extension's
criticality. A NameConstraints that uses any other base form
(directoryName, rfc822Name, URI, otherName), a minimum/maximum, or that
is malformed cannot be fully evaluated, so the whole chain is rejected
(fail closed) rather than accepted unchecked. NOT enforced: the RFC 5280
rule that the extension be critical, and dNSName syntax validation (a
leading-period constraint is accepted, as OpenSSL does).
- REVOCATION IS NOT CHECKED. verify_chain performs NO CRL or OCSP
revocation checking. Callers that need revocation (e.g. TLS clients)
MUST supply it separately (OCSP stapling, CRLite, ).
@@ -67,9 +69,9 @@ duplicate extension OIDs (Duplicate_Extension, RFC 5280 section 4.2).
extension is still available via `extensions`.
- Only the extensions path validation needs are decoded
(BasicConstraints, KeyUsage, ExtKeyUsage, SubjectAltName,
Subject/Authority Key Identifier). All others (AIA, CRL
distribution points, certificate policies, name constraints, )
are left raw in `extensions`.
Subject/Authority Key Identifier; NameConstraints is decoded at
verification time). All others (AIA, CRL distribution points,
certificate policies, ) are left raw in `extensions`.
- Subject and issuer are exposed only as raw DER (`raw_subject` /
`raw_issuer`); distinguished-name attribute decoding (CN, O, ) is
not performed.

View File

@@ -0,0 +1,365 @@
package x509
import "core:bytes"
import "core:encoding/asn1"
// Name-constraint processing (RFC 5280 section 4.2.1.10 + the section 6.1.4
// path-validation checks), scoped to the dNSName and iPAddress GeneralName
// forms. A NameConstraints extension that uses any other base form
// (directoryName, rfc822Name, uniformResourceIdentifier, otherName, …), a
// non-zero minimum, a maximum, or that fails to decode causes the whole chain
// to be rejected (fail closed): we never accept a constraint we cannot fully
// evaluate.
// GeneralName context tags used in NameConstraints (X.509 GeneralName CHOICE).
@(private)
_GN_OTHER_NAME :: 0 // otherName [0] (constructed)
@(private)
_GN_RFC822 :: 1 // rfc822Name [1]
@(private)
_GN_DNS :: 2 // dNSName [2]
@(private)
_GN_DIR :: 4 // directoryName [4] (constructed)
@(private)
_GN_URI :: 6 // uniformResourceIdentifier [6]
@(private)
_GN_IP :: 7 // iPAddress [7]
// _check_name_constraints enforces every NameConstraints extension in `chain`
// (leaf at index 0, trust anchor last). Each CA's constraints apply to every
// certificate below it; checking each CA independently against each
// subordinate yields the RFC 5280 permitted=intersection / excluded=union
// semantics without accumulator state. Returns true when the chain is
// acceptable, false when a name is forbidden (or a constraint cannot be
// evaluated, in which case we reject rather than guess).
@(private)
_check_name_constraints :: proc(chain: []^Certificate) -> bool {
// RFC 5280 4.2.1.10: NameConstraints MUST appear only in a CA certificate.
// A non-CA end entity that carries it is malformed — reject the chain.
if _, leaf_has := _find_name_constraints(chain[0]); leaf_has && !chain[0].is_ca {
return false
}
// ci walks the CAs (anchor down to the first intermediate above the leaf);
// the leaf (index 0) never constrains.
for ci := len(chain) - 1; ci >= 1; ci -= 1 {
nc, has := _find_name_constraints(chain[ci])
if !has {
continue
}
if !_nc_decidable(nc) {
return false // a form/feature we cannot evaluate: fail closed
}
for sub := ci - 1; sub >= 0; sub -= 1 {
c := chain[sub]
// RFC 5280 section 6.1.4(b): constraints are not applied to a
// self-issued intermediate, only to the final (leaf) certificate.
if sub != 0 && bytes.equal(c.raw_subject, c.raw_issuer) {
continue
}
if !_names_permitted(nc, c) {
return false
}
}
}
return true
}
// _find_name_constraints returns the raw extnValue DER of the cert's
// NameConstraints extension, if present.
@(private)
_find_name_constraints :: proc(cert: ^Certificate) -> (der: []byte, ok: bool) {
for ext in cert.extensions {
if bytes.equal(ext.oid, _OID_EXT_NAME_CONSTRAINTS) {
return ext.value, true
}
}
return nil, false
}
// _names_permitted checks a subordinate certificate's dNSName and iPAddress
// SANs against one CA's NameConstraints: each name form, when the CA lists
// permitted subtrees for it, must match at least one; and no name may match an
// excluded subtree.
@(private)
_names_permitted :: proc(nc: []byte, sub: ^Certificate) -> bool {
for dns in sub.dns_names {
if _nc_section_has(nc, false, _GN_DNS) && !_nc_dns_match(nc, false, dns) {
return false
}
if _nc_dns_match(nc, true, dns) {
return false
}
}
for ip in sub.ip_addresses {
if _nc_section_has(nc, false, _GN_IP) && !_nc_ip_match(nc, false, ip) {
return false
}
if _nc_ip_match(nc, true, ip) {
return false
}
}
return true
}
// _nc_decidable structurally validates a NameConstraints and reports whether we
// can fully evaluate it. It returns false, so the caller fails closed, when the
// extension is malformed (an element other than permittedSubtrees [0] /
// excludedSubtrees [1], neither section present, or an empty GeneralSubtrees,
// which SIZE (1..MAX) forbids), or when a subtree uses a base form other than
// dNSName / iPAddress or carries a minimum/maximum (barred by the RFC 5280
// profile). Only a NameConstraints whose every subtree is a bare
// dNSName/iPAddress is decidable.
@(private)
_nc_decidable :: proc(nc: []byte) -> bool {
cur: asn1.Cursor
asn1.cursor_init(&cur, nc)
seq, e := asn1.read_sequence(&cur)
if e != .None || asn1.done(&cur) != .None {
return false
}
sections := 0
for !asn1.is_empty(&seq) {
tag, content, re := asn1.read_any(&seq)
if re != .None {
return false
}
// The only permitted members are permittedSubtrees [0] / excludedSubtrees
// [1]; a NULL or any other element (CABF 7.1.2.5.2) is malformed.
if tag.class != .Context_Specific || (tag.number != 0 && tag.number != 1) {
return false
}
sections += 1
if !_nc_section_wellformed(content) {
return false
}
}
return sections > 0
}
// _nc_section_wellformed checks one GeneralSubtrees body: at least one subtree
// (SIZE (1..MAX)), every base a bare dNSName/iPAddress, no minimum/maximum.
@(private)
_nc_section_wellformed :: proc(content: []byte) -> bool {
cur: asn1.Cursor
asn1.cursor_init(&cur, content)
count := 0
for !asn1.is_empty(&cur) {
gs, e := asn1.read_sequence(&cur)
if e != .None {
return false
}
count += 1
tag, _, be := asn1.read_any(&gs)
if be != .None || tag.class != .Context_Specific {
return false
}
if tag.number != _GN_DNS && tag.number != _GN_IP {
return false // directoryName / rfc822 / URI / otherName / …
}
// minimum [0] / maximum [1] must both be absent (RFC 5280 profile).
if asn1.done(&gs) != .None {
return false
}
}
return count > 0
}
// _nc_section returns the raw content octets of the permitted [0] (or excluded
// [1]) GeneralSubtrees, i.e. the concatenation of GeneralSubtree elements.
@(private)
_nc_section :: proc(nc: []byte, excluded: bool) -> (content: []byte, present: bool) {
want := u32(excluded ? 1 : 0)
cur: asn1.Cursor
asn1.cursor_init(&cur, nc)
seq, e := asn1.read_sequence(&cur)
if e != .None {
return nil, false
}
for !asn1.is_empty(&seq) {
tag, c, re := asn1.read_any(&seq)
if re != .None {
return nil, false
}
if tag.class == .Context_Specific && tag.number == want {
return c, true
}
}
return nil, false
}
// _nc_section_has reports whether the given section contains at least one
// subtree whose base is `form`.
@(private)
_nc_section_has :: proc(nc: []byte, excluded: bool, form: u32) -> bool {
content, present := _nc_section(nc, excluded)
if !present {
return false
}
cur: asn1.Cursor
asn1.cursor_init(&cur, content)
for !asn1.is_empty(&cur) {
gs, e := asn1.read_sequence(&cur)
if e != .None {
return false
}
tag, _, be := asn1.read_any(&gs)
if be != .None {
return false
}
if tag.class == .Context_Specific && tag.number == form {
return true
}
}
return false
}
// _nc_dns_match reports whether `name` matches any dNSName subtree in the
// given section.
@(private)
_nc_dns_match :: proc(nc: []byte, excluded: bool, name: string) -> bool {
content, present := _nc_section(nc, excluded)
if !present {
return false
}
cur: asn1.Cursor
asn1.cursor_init(&cur, content)
for !asn1.is_empty(&cur) {
gs, e := asn1.read_sequence(&cur)
if e != .None {
return false
}
tag, base, be := asn1.read_any(&gs)
if be != .None {
return false
}
if tag.class == .Context_Specific && tag.number == _GN_DNS {
c := string(base)
// A wildcard SAN "*.B" stands for every "<label>.B". Matching it as
// a literal string lets it slip past an exclusion of some x.B
// (CVE-2025-61727). Handle the leftmost "*" explicitly.
if len(name) >= 2 && name[0] == '*' && name[1] == '.' {
b := name[2:]
if excluded {
// Fail closed: exclude the wildcard if its subtree overlaps
// the constraint at all (constraint covers b, or b covers it).
if _dns_in_constraint(c, b) || _dns_in_constraint(b, _strip_dot(c)) {
return true
}
} else if _dns_in_constraint(_strip_dot(c), b) {
// Permitted only if every child of b is inside the subtree.
return true
}
} else if _dns_in_constraint(c, name) {
return true
}
}
}
return false
}
// _strip_dot drops a single leading '.', normalizing a subtree constraint to a
// bare domain for the wildcard-overlap comparisons above.
@(private)
_strip_dot :: proc "contextless" (s: string) -> string {
return s[1:] if len(s) > 0 && s[0] == '.' else s
}
// _nc_ip_match reports whether `ip` (a 4- or 16-octet address) matches any
// iPAddress subtree in the given section. In NameConstraints an iPAddress
// base is the address followed by a mask of equal length (8 octets for IPv4,
// 32 for IPv6).
@(private)
_nc_ip_match :: proc(nc: []byte, excluded: bool, ip: []byte) -> bool {
content, present := _nc_section(nc, excluded)
if !present {
return false
}
cur: asn1.Cursor
asn1.cursor_init(&cur, content)
for !asn1.is_empty(&cur) {
gs, e := asn1.read_sequence(&cur)
if e != .None {
return false
}
tag, base, be := asn1.read_any(&gs)
if be != .None {
return false
}
if tag.class == .Context_Specific && tag.number == _GN_IP {
if len(base) == 2 * len(ip) {
addr := base[:len(ip)]
mask := base[len(ip):]
if _ip_in_subnet(ip, addr, mask) {
return true
}
}
}
}
return false
}
// _ip_in_subnet reports whether ip lies in addr/mask (all equal length).
@(private)
_ip_in_subnet :: proc "contextless" (ip, addr, mask: []byte) -> bool {
for i in 0 ..< len(ip) {
if (ip[i] & mask[i]) != (addr[i] & mask[i]) {
return false
}
}
return true
}
// _dns_in_constraint implements the RFC 5280 section 4.2.1.10 dNSName rule:
// `name` satisfies `constraint` when it equals the constraint or extends it on
// the left at a label boundary. An empty constraint matches everything; a
// leading-dot constraint (".example.com") matches proper subdomains only.
// Comparison is ASCII case-insensitive.
@(private)
_dns_in_constraint :: proc "contextless" (constraint, name: string) -> bool {
if len(constraint) == 0 {
return true
}
if constraint[0] == '.' {
return _ascii_suffix_fold(name, constraint)
}
if _ascii_eq_fold(name, constraint) {
return true
}
// name must end with "." + constraint, so the constraint aligns to a label.
if len(name) > len(constraint) + 1 && name[len(name) - len(constraint) - 1] == '.' {
return _ascii_suffix_fold(name, constraint)
}
return false
}
@(private)
_ascii_lower :: proc "contextless" (b: byte) -> byte {
return b + 0x20 if b >= 'A' && b <= 'Z' else b
}
@(private)
_ascii_eq_fold :: proc "contextless" (a, b: string) -> bool {
if len(a) != len(b) {
return false
}
for i in 0 ..< len(a) {
if _ascii_lower(a[i]) != _ascii_lower(b[i]) {
return false
}
}
return true
}
@(private)
_ascii_suffix_fold :: proc "contextless" (s, suffix: string) -> bool {
if len(suffix) > len(s) {
return false
}
off := len(s) - len(suffix)
for i in 0 ..< len(suffix) {
if _ascii_lower(s[off + i]) != _ascii_lower(suffix[i]) {
return false
}
}
return true
}

View File

@@ -675,6 +675,11 @@ _parse_known_extension :: proc(
// practice, and AKI is only a path-building hint (issuers are matched by DN + signature), so the other fields carry no
// validation weight here.
case bytes.equal(oid, _OID_EXT_NAME_CONSTRAINTS):
// Left raw in `extensions` and enforced during chain validation (see _check_name_constraints,
// dNSName/iPAddress with fail-closed on other forms). Recognized here so a critical
// nameConstraints does not trip unhandled_critical.
case:
if critical {
cert.unhandled_critical = true

View File

@@ -391,7 +391,12 @@ _build_to_anchor :: proc(
#partial switch verify_signature(cert, root) {
case .None:
append(acc, root)
return true
// Chain is complete: enforce every CA's name constraints over it.
// On violation, keep searching — a different anchor/path may satisfy them.
if _check_name_constraints(acc[:]) {
return true
}
pop(acc)
case .Unsupported_Algorithm:
saw_unsupported^ = true
case:
@@ -475,12 +480,8 @@ _issuer_usable :: proc(issuer: ^Certificate, now: time.Time, below: int) -> bool
if issuer.unhandled_critical {
return false
}
// Name constraints are NOT decoded. RFC 5280 section 6.1.4(g) requires a
// validator that processes them to enforce them regardless of criticality;
// until that is implemented, refuse any issuer that asserts them.
if _has_extension(issuer, _OID_EXT_NAME_CONSTRAINTS) {
return false
}
// Name constraints asserted by this issuer are enforced on the completed
// chain (see _check_name_constraints), so an NC-bearing CA is usable here.
if !issuer.basic_constraints_valid || !issuer.is_ca {
return false
}
@@ -499,17 +500,14 @@ _issuer_usable :: proc(issuer: ^Certificate, now: time.Time, below: int) -> bool
// The anchor is trusted input, so unlike _issuer_usable its CA authorization
// (basicConstraints / keyCertSign / pathLenConstraint) and self-signature are
// NOT re-checked. Still required: valid at `now` (resilience to an expired
// anchor comes from the search trying other anchors/intermediates), no
// uninterpreted critical extension, and no name constraints (which we cannot
// enforce, so refuse rather than ignore).
// anchor comes from the search trying other anchors/intermediates) and no
// uninterpreted critical extension. Any nameConstraints it asserts are
// enforced on the completed chain (see _check_name_constraints).
@(private)
_anchor_usable :: proc(anchor: ^Certificate, now: time.Time) -> bool {
if anchor.unhandled_critical {
return false
}
if _has_extension(anchor, _OID_EXT_NAME_CONSTRAINTS) {
return false
}
if _check_validity(anchor, now) != .None {
return false
}
@@ -531,16 +529,6 @@ _permits_eku :: proc(cert: ^Certificate, ask: EKU_Bit) -> bool {
return ask in cert.ext_key_usage
}
@(private)
_has_extension :: proc(cert: ^Certificate, oid: []byte) -> bool {
for ext in cert.extensions {
if bytes.equal(ext.oid, oid) {
return true
}
}
return false
}
@(private)
_in_chain :: proc(acc: ^[dynamic]^Certificate, cert: ^Certificate) -> bool {
for c in acc {

View File

@@ -67,6 +67,23 @@ NEG_EKU_INTER := #load("testdata/neg_eku_inter.der") // emailProtection
NEG_EKU_LEAF := #load("testdata/neg_eku_leaf.der") // serverAuth leaf
NEG_NC_INTER := #load("testdata/neg_nc_inter.der") // non-critical nameConstraints
NEG_NC_LEAF := #load("testdata/neg_nc_leaf.der") // name within permitted subtree
// Dedicated name-constraint chains (EC P-256), generated with openssl:
// root -> nc_inter (critical NC: permitted DNS:.example.com, IP:10.0.0.0/8)
// -> nc_leaf_ok (SAN host.example.com + 10.1.2.3 — within → accept)
// -> nc_leaf_bad (SAN host.evil.com — outside → reject)
// root -> nc_dir_inter (critical NC on directoryName — a form we do not
// evaluate) -> nc_leaf_dir (must fail closed → reject)
NC_ROOT := #load("testdata/nc_root.der")
NC_INTER := #load("testdata/nc_inter.der")
NC_DIR_INTER := #load("testdata/nc_dir_inter.der")
NC_LEAF_OK := #load("testdata/nc_leaf_ok.der")
NC_LEAF_BAD := #load("testdata/nc_leaf_bad.der")
NC_LEAF_DIR := #load("testdata/nc_leaf_dir.der")
// CVE-2025-61727 shape: nc_excl_inter excludes DNS:bar.example.com; the leaf's
// wildcard SAN *.example.com spans bar.example.com and must be rejected, not
// treated as a literal string that slips past the exclusion.
NC_EXCL_INTER := #load("testdata/nc_excl_inter.der")
NC_LEAF_WILD := #load("testdata/nc_leaf_wild.der")
// EKU alternative path: two intermediates sharing a subject DN and key,
// one emailProtection-only, one serverAuth; the leaf chains through either.
EKU_ALT_BAD := #load("testdata/eku_alt_bad.der") // same key, emailProtection only
@@ -558,18 +575,58 @@ test_verify_chain_eku_alt_path :: proc(t: ^testing.T) {
}
}
// Name constraints are not decoded, so verify_chain fails CLOSED: a
// chain through a name-constrained CA is rejected even when the leaf's
// name is within the permitted subtree (here a NON-critical NC, so it is
// the explicit NC refusal, not the unhandled-critical path).
// Name constraints (RFC 5280 4.2.1.10) are enforced for dNSName and iPAddress:
// a leaf whose SANs fall within a constraining CA's permitted subtrees is
// accepted, one that falls outside is rejected, and a critical NameConstraints
// must NOT be mistaken for an unhandled critical extension.
@(test)
test_verify_chain_name_constraints_fail_closed :: proc(t: ^testing.T) {
root, _ := x509.parse(NEG_ROOT); defer x509.destroy(&root)
inter, _ := x509.parse(NEG_NC_INTER); defer x509.destroy(&inter)
leaf, _ := x509.parse(NEG_NC_LEAF); defer x509.destroy(&leaf)
// The NC is present but not critical, so it must not have tripped the
// unhandled-critical flag — the refusal is the explicit NC check.
testing.expect(t, !inter.unhandled_critical, "NC is non-critical")
test_verify_chain_name_constraints_permitted :: proc(t: ^testing.T) {
root, _ := x509.parse(NC_ROOT); defer x509.destroy(&root)
inter, _ := x509.parse(NC_INTER); defer x509.destroy(&inter)
leaf, _ := x509.parse(NC_LEAF_OK); defer x509.destroy(&leaf)
testing.expect(t, !inter.unhandled_critical, "critical NC is recognized, not unhandled")
opts := x509.Verify_Options{roots = {&root}, intermediates = {&inter}, current_time = time.unix(CHAIN_NOW, 0)}
c, err := x509.verify_chain(&leaf, opts); defer delete(c)
testing.expect_value(t, err, x509.Error.None) // host.example.com + 10.1.2.3 are permitted
testing.expect_value(t, len(c), 3)
}
// A SAN outside every permitted subtree (host.evil.com vs permitted
// .example.com) must be rejected.
@(test)
test_verify_chain_name_constraints_violation :: proc(t: ^testing.T) {
root, _ := x509.parse(NC_ROOT); defer x509.destroy(&root)
inter, _ := x509.parse(NC_INTER); defer x509.destroy(&inter)
leaf, _ := x509.parse(NC_LEAF_BAD); defer x509.destroy(&leaf)
opts := x509.Verify_Options{roots = {&root}, intermediates = {&inter}, current_time = time.unix(CHAIN_NOW, 0)}
c, err := x509.verify_chain(&leaf, opts); delete(c)
testing.expect_value(t, err, x509.Error.Unknown_Authority)
}
// CVE-2025-61727: a wildcard SAN must not slip past a dNSName exclusion. The
// intermediate excludes bar.example.com; the leaf's *.example.com spans it, so
// the chain must be rejected (matching a wildcard as a literal string would
// wrongly accept it).
@(test)
test_verify_chain_name_constraints_wildcard_excluded :: proc(t: ^testing.T) {
root, _ := x509.parse(NC_ROOT); defer x509.destroy(&root)
inter, _ := x509.parse(NC_EXCL_INTER); defer x509.destroy(&inter)
leaf, _ := x509.parse(NC_LEAF_WILD); defer x509.destroy(&leaf)
opts := x509.Verify_Options{roots = {&root}, intermediates = {&inter}, current_time = time.unix(CHAIN_NOW, 0)}
c, err := x509.verify_chain(&leaf, opts); delete(c)
testing.expect_value(t, err, x509.Error.Unknown_Authority)
}
// A constraint on a GeneralName form we do not evaluate (here directoryName)
// must fail closed — reject rather than silently ignore the constraint.
@(test)
test_verify_chain_name_constraints_unevaluable_fail_closed :: proc(t: ^testing.T) {
root, _ := x509.parse(NC_ROOT); defer x509.destroy(&root)
inter, _ := x509.parse(NC_DIR_INTER); defer x509.destroy(&inter)
leaf, _ := x509.parse(NC_LEAF_DIR); defer x509.destroy(&leaf)
opts := x509.Verify_Options{roots = {&root}, intermediates = {&inter}, current_time = time.unix(CHAIN_NOW, 0)}
c, err := x509.verify_chain(&leaf, opts); delete(c)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.