mirror of
https://github.com/odin-lang/Odin.git
synced 2026-07-15 04:10:29 +00:00
attach nc
This commit is contained in:
@@ -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.
|
||||
|
||||
365
core/crypto/x509/name_constraints.odin
Normal file
365
core/crypto/x509/name_constraints.odin
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
BIN
tests/core/crypto/x509/testdata/nc_dir_inter.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/nc_dir_inter.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/nc_excl_inter.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/nc_excl_inter.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/nc_inter.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/nc_inter.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/nc_leaf_bad.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/nc_leaf_bad.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/nc_leaf_dir.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/nc_leaf_dir.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/nc_leaf_ok.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/nc_leaf_ok.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/nc_leaf_wild.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/nc_leaf_wild.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/nc_root.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/nc_root.der
vendored
Normal file
Binary file not shown.
Reference in New Issue
Block a user