Merge pull request #6838 from kalsprite/x.509

core:encoding/asn1 (strict DER) + core:crypto/x509 (X.509 v3)
This commit is contained in:
Jeroen van Rijn
2026-07-14 10:08:52 +02:00
committed by GitHub
77 changed files with 7080 additions and 166 deletions

View File

@@ -143,6 +143,8 @@ jobs:
run: ./odin test tests/core/crypto/wycheproof -vet -vet-tabs -strict-style -vet-style -vet-cast -warnings-as-errors -disallow-do -o:speed
- name: Noise Protocol Framework tests
run: ./odin test tests/core/crypto/noise -vet -vet-tabs -strict-style -vet-style -vet-cast -warnings-as-errors -disallow-do -o:speed
- name: X.509 limbo tests
run: ./odin test tests/core/crypto/x509_limbo -vet -vet-tabs -strict-style -vet-style -vet-cast -warnings-as-errors -disallow-do -o:speed
- name: Vendor library tests
run: ./odin test tests/vendor -all-packages -vet -vet-tabs -strict-style -vet-style -warnings-as-errors -disallow-do -define:ODIN_TEST_FANCY=false -define:ODIN_TEST_FAIL_ON_BAD_MEMORY=true -sanitize:address
if: matrix.os != 'macos-15-intel' && matrix.os != 'macos-latest'
@@ -250,6 +252,11 @@ jobs:
run: |
call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvars64.bat"
odin test tests/core/crypto/noise -vet -vet-tabs -strict-style -vet-style -vet-cast -warnings-as-errors -disallow-do -o:speed -microarch:native
- name: X.509 limbo tests
shell: cmd
run: |
call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvars64.bat"
odin test tests/core/crypto/x509_limbo -vet -vet-tabs -strict-style -vet-style -vet-cast -warnings-as-errors -disallow-do -o:speed -microarch:native
- name: Vendor library tests
shell: cmd
run: |

View File

@@ -1,17 +1,13 @@
package ecdsa
import "core:encoding/asn1"
import secec "core:crypto/_weierstrass"
// ASN.1 format ECDSA signatures are`SEQUENCE { r INTEGER, s INTEGER }`
// this implements enough to generate/parse signatures. Eventually when
// we have a full ASN.1 DER library, these routines will be removed.
// ASN.1 ECDSA signatures are `SEQUENCE { r INTEGER, s INTEGER }`. These thin
// wrappers over core:encoding/asn1 generate/parse that structure the DER
// minimal-encoding rules.
@(private="file")
TAG_SEQUENCE :: 0x30
@(private="file")
TAG_INTEGER :: 0x02
@(private,require_results)
@(private, require_results)
generate_asn1_sig :: proc(r, s: ^$T, allocator := context.allocator) -> []byte {
when T == secec.Scalar_p256r1 {
SC_SZ :: secec.SC_SIZE_P256R1
@@ -21,163 +17,35 @@ generate_asn1_sig :: proc(r, s: ^$T, allocator := context.allocator) -> []byte {
#panic("crypto/ecdsa: invalid curve")
}
INT_TLP :: 3 // tag, tength, (optional) leading zero-byte
encode_uint :: proc(b: []byte) -> []byte {
b := b
r_buf, s_buf: [SC_SZ]byte = ---, ---
secec.sc_bytes(r_buf[:], r)
secec.sc_bytes(s_buf[:], s)
// DER requires minimal encoding.
off := INT_TLP
for v in b[off:] {
if v != 0 {
break
}
off += 1
}
// If the sign big is set, add a leading zero.
if b[off] & 0x80 == 0x80 {
off -= 1
b[off] = 0
}
// Encode the length (up to 127 octets, adequate for ECDSA).
l := len(b[off:])
off -= 1
b[off] = byte(l)
// Encode the tag
off -= 1
b[off] = TAG_INTEGER
return b[off:]
sig, err := asn1.marshal(
asn1.sequence({asn1.integer_unsigned(r_buf[:]), asn1.integer_unsigned(s_buf[:])}),
allocator,
)
if err != .None {
return nil
}
r_buf, s_buf: [INT_TLP+SC_SZ]byte = ---, ---
secec.sc_bytes(r_buf[INT_TLP:], r)
secec.sc_bytes(s_buf[INT_TLP:], s)
r_bytes, s_bytes := encode_uint(r_buf[:]), encode_uint(s_buf[:])
seq_len := len(r_bytes) + len(s_bytes)
// WARNING: If secp521r1 support is added, this needs to support
// long-form length encoding.
ensure(seq_len <= 127, "BUG: crypto/ecdsa: signature length too large")
b := make([]byte, seq_len + 2, allocator)
b[0] = TAG_SEQUENCE
b[1] = byte(seq_len)
copy(b[2:], r_bytes)
copy(b[2+len(r_bytes):], s_bytes)
return b
return sig
}
@(private,require_results)
@(private, require_results)
parse_asn1_sig :: proc(sig: []byte) -> (r, s: []byte, ok: bool) {
read_seq :: proc(b: []byte) -> (v: []byte, rest: []byte, ok: bool) {
b_len := len(b)
if b_len < 3 {
return nil, nil, false
}
if b[0] != TAG_SEQUENCE {
return nil, nil, false
}
seq_len, off: int
if b[1] & 0x80 == 0x80 {
if b[1] != 0x81 || b_len < 4 { // 2-length octets is sufficient for ecdsa.
return nil, nil, false
}
if b[2] & 0x80 == 0x80 || b[3] & 0x80 == 80 {
return nil, nil, false
}
seq_len = int(b[2]) * 127 + int(b[3])
off = 4
} else {
seq_len = int(b[1])
off = 2
}
if b_len - off < seq_len {
return nil, nil, false
}
return b[off:off+seq_len], b[off+seq_len:], true
}
read_int :: proc(b: []byte) -> (v: []byte, rest: []byte, ok: bool) {
b_len := len(b)
if b_len < 3 {
return nil, nil, false
}
if b[0] != TAG_INTEGER {
return nil, nil, false
}
v_len := int(b[1])
if v_len > 0x80 || b_len - 2 < v_len { // 127-bytes max.
return nil, nil, false
}
return b[2:2+v_len], b[2+v_len:], true
}
// SEQUENCE
seq_bytes, rest: []byte
seq_bytes, rest, ok = read_seq(sig)
if !ok {
return nil, nil, false
}
if len(rest) != 0 {
cur: asn1.Cursor
asn1.cursor_init(&cur, sig)
seq, e0 := asn1.read_sequence(&cur)
if e0 != .None || asn1.done(&cur) != .None {
return nil, nil, false
}
// INTEGER (r)
r, rest, ok = read_int(seq_bytes)
if !ok {
// r and s are unsigned; read_unsigned_integer_bytes validates the INTEGER
// and strips the DER sign octet, returning the magnitude as a view of sig.
rb, e1 := asn1.read_unsigned_integer_bytes(&seq)
sb, e2 := asn1.read_unsigned_integer_bytes(&seq)
if e1 != .None || e2 != .None || asn1.done(&seq) != .None {
return nil, nil, false
}
// INTEGER (s)
s, rest, ok = read_int(rest)
if !ok {
return nil, nil, false
}
if len(rest) != 0 {
return nil, nil, false
}
// DER requires a leading 0 if and only if (⟺) the sign bit of the leading byte
// is set to distinguish between positive and negative integers,
// and the minimal length representation. `r` and `s` are always
// going to be unsigned, so we validate malformed DER and strip
// the leading 0 as needed.
fixup_der_uint :: proc(b: []byte) -> ([]byte, bool) {
switch len(b) {
case 0:
// 0 length is invalid
return nil, false
case 1:
// Missing leading zero
if b[0] & 0x80 == 0x80 {
return nil, false
}
case:
if b[0] == 0 {
// Sign bit not set
if b[1] & 0x80 != 0x80 {
return nil, false
}
return b[1:], true
} else if b[0] & 0x80 == 0x80 {
// Missing leading zero
return nil, false
}
}
return b, true
}
if r, ok = fixup_der_uint(r); !ok {
return nil, nil, false
}
if s, ok = fixup_der_uint(s); !ok {
return nil, nil, false
}
return r, s, true
return rb, sb, true
}

99
core/crypto/x509/doc.odin Normal file
View File

@@ -0,0 +1,99 @@
/*
X.509 v3 certificate parsing, signature verification, and chain (path)
validation.
The parser is built on the strict DER reader in core:encoding/asn1 and
is zero-copy where possible: the returned Certificate's byte-slice
fields are views into the input DER, which must outlive it. The few
allocated fields (the extension/SAN tables) are released with
`destroy`.
Input is DER. To parse a PEM certificate, decode it first with
core:encoding/pem (label "CERTIFICATE") and pass the resulting bytes.
A successful `parse` means the bytes were well-formed, NOT that the
certificate is valid or trusted. The Certificate carries everything
the verifier needs: `raw_tbs` (the exact byte range a signature covers),
`raw_spki` (the range hashed for tls-server-end-point channel binding,
RFC 5929, and SPKI pinning), and `raw_issuer`/`raw_subject` (for the RFC
5280 binary-comparison rule).
Hostname verification (`verify_hostname`) implements the RFC 6125
subset modern clients use: subject alternative names only (no
CommonName fallback), with at most one wildcard as the entire
left-most label.
Trust is established by `verify_chain`, which builds a path from a leaf
to a supplied trust anchor through supplied intermediates and checks,
for each certificate, validity, signature, name chaining, and the CA /
keyCertSign / pathLenConstraint rules; `verify_signature` exposes the
single-edge signature check on its own.
LIMITATIONS:
- Signature verification covers RSA PKCS#1 v1.5 and RSA-PSS
(SHA-256/384/512), ECDSA P-256/P-384, and Ed25519. These paths return
.Unsupported_Algorithm: SHA-1 (deprecated and rejected, RFC 9155), ECDSA
P-521 (effectively dead in web PKI), and RSA-PSS naming a digest or MGF
this package does not recognize.
- 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, ).
- Certificate policies / policy constraints are not evaluated, and
there is no Public Suffix List: a (CABF-forbidden) wildcard such as
"*.com" would match "host.com". As a backstop, verify_chain still
fails closed on any uninterpreted CRITICAL extension
(.Unhandled_Critical_Extension).
- EKU is checked only when opts.required_eku is set, and then by RFC
5280 semantics: a certificate with no EKU extension is unrestricted
(it is not required to assert the purpose); a certificate that DOES
assert EKU must include the purpose, enforced across the leaf and
every intermediate (EKU nesting). Leaf KeyUsage is not checked
against the intended protocol use.
Parsing is deliberately lenient wherever strictness is a validation
concern rather than a structural one. Exception: Parser rejects
duplicate extension OIDs (Duplicate_Extension, RFC 5280 section 4.2).
- Only dNSName and iPAddress subject alternative names are decoded
(into `dns_names` / `ip_addresses`). Other GeneralName forms (URI,
rfc822Name, directoryName, otherName) are skipped; the raw SAN
extension is still available via `extensions`.
- Only the extensions path validation needs are decoded
(BasicConstraints, KeyUsage, ExtKeyUsage, SubjectAltName,
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 kept as raw DER (`raw_subject` / `raw_issuer`),
which is what name chaining compares (the RFC 5280 binary rule). The
attributes (CN, O, ) are decoded on demand by `parse_dn`, not at parse
time; `dn_get` / `dn_string` read them out and `serial_string` formats
the serial.
- Unsupported public-key curves yield Public_Key_Algorithm.Unknown.
- Non-conformant-but-extractable values are preserved: negative or
over-long serials, and validity dates far in the future. Validity
is stored as core:time.Time, which tops out near year 2262; dates
beyond that (the RFC 5280 "99991231235959Z" no-expiration sentinel)
saturate to that bound at parse time rather than failing, so they
read as "effectively never expires".
- Per-extension criticality rules (e.g. that subjectKeyIdentifier be
non-critical) are left to the caller via `Extension.critical`, and
a critical extension this package does not understand sets
`unhandled_critical` rather than failing the parse.
See:
- [[ https://www.rfc-editor.org/rfc/rfc5280 ]]
- [[ https://www.rfc-editor.org/rfc/rfc6125 ]]
- [[ https://www.rfc-editor.org/rfc/rfc5929 ]]
*/
package x509

160
core/crypto/x509/ext.odin Normal file
View File

@@ -0,0 +1,160 @@
package x509
import "core:encoding/asn1"
// Extension encoders. Each returns a complete DER Extension
// Extension ::= SEQUENCE { extnID OID, critical BOOLEAN DEFAULT FALSE, extnValue OCTET STRING }
// with extnValue carrying the encoded value (the inverse of the matching
// _parse_known_extension branch). The returned slice is allocated;
// a certificate builder splices these into the extensions [3] list via
// asn1.raw. critical=false is omitted per DEFAULT FALSE.
// marshal_ext_basic_constraints encodes id-ce-basicConstraints:
// BasicConstraints ::= SEQUENCE { cA BOOLEAN DEFAULT FALSE, pathLenConstraint INTEGER OPTIONAL }
// cA is emitted only when `is_ca`; pathLenConstraint only when is_ca and
// `max_path_len` >= 0 (a negative value means "absent").
@(require_results)
marshal_ext_basic_constraints :: proc(is_ca: bool, max_path_len: int, critical: bool, allocator := context.allocator) -> (der: []byte, err: Error) {
vals: [2]asn1.Value
path_buf: [8]byte
n := 0
if is_ca {
vals[n] = asn1.boolean(true)
n += 1
if max_path_len >= 0 {
vals[n] = asn1.integer_unsigned(_uint_be(max_path_len, path_buf[:]))
n += 1
}
}
return _marshal_extension(_OID_EXT_BASIC_CONSTRAINTS, critical, asn1.sequence(vals[:n]), allocator)
}
// marshal_ext_key_usage encodes id-ce-keyUsage as a KeyUsage BIT STRING,
// minimally (trailing zero bits dropped, per DER named bit strings).
@(require_results)
marshal_ext_key_usage :: proc(usage: Key_Usage, critical: bool, allocator := context.allocator) -> (der: []byte, err: Error) {
hi := -1
for bit in Key_Usage_Bit {
if bit in usage {
hi = int(bit) // bits enumerate ascending, so the last present is the highest
}
}
buf: [3]byte // unused-bits octet + up to 2 payload octets (9 named bits)
content: []byte
if hi < 0 {
content = buf[:1] // no bits set: empty bit string, 0 unused
} else {
nbytes := hi / 8 + 1
buf[0] = byte(nbytes * 8 - (hi + 1)) // unused-bits count
for bit in Key_Usage_Bit {
i := int(bit)
if i <= hi && bit in usage {
buf[1 + i / 8] |= 0x80 >> uint(i % 8)
}
}
content = buf[:1 + nbytes]
}
return _marshal_extension(_OID_EXT_KEY_USAGE, critical, asn1.primitive(asn1.universal(.Bit_String), content), allocator)
}
// marshal_ext_ext_key_usage encodes id-ce-extKeyUsage:
// ExtKeyUsageSyntax ::= SEQUENCE SIZE (1..MAX) OF KeyPurposeId
// in EKU_Bit order.
@(require_results)
marshal_ext_ext_key_usage :: proc(eku: Ext_Key_Usage, critical: bool, allocator := context.allocator) -> (der: []byte, err: Error) {
purposes: [7]asn1.Value
n := 0
for bit in EKU_Bit {
if bit in eku {
purposes[n] = asn1.object_identifier(_eku_oid(bit))
n += 1
}
}
return _marshal_extension(_OID_EXT_EXT_KEY_USAGE, critical, asn1.sequence(purposes[:n]), allocator)
}
// marshal_ext_san encodes id-ce-subjectAltName:
// GeneralNames ::= SEQUENCE OF GeneralName
// emitting dNSName [2] IA5String entries (in order) followed by iPAddress [7]
// OCTET STRING entries. IP values are the raw 4- or 16-octet address.
@(require_results)
marshal_ext_san :: proc(dns_names: []string, ip_addresses: [][]byte, critical: bool, allocator := context.allocator) -> (der: []byte, err: Error) {
n := len(dns_names) + len(ip_addresses)
names, merr := make([]asn1.Value, n, allocator) // dynamic count: scaffolding, freed below
if merr != nil {
return nil, .Allocation_Failed
}
defer delete(names, allocator)
i := 0
for d in dns_names {
names[i] = asn1.context_primitive(2, transmute([]byte)d) // [2] IMPLICIT IA5String
i += 1
}
for ip in ip_addresses {
names[i] = asn1.context_primitive(7, ip) // [7] IMPLICIT OCTET STRING
i += 1
}
return _marshal_extension(_OID_EXT_SAN, critical, asn1.sequence(names[:]), allocator)
}
// _marshal_extension wraps an extension value tree as a complete Extension.
// `value`'s borrowed backing lives in the caller's frame, which is active for
// the duration of this synchronous call, so the encode below sees it intact.
@(private, require_results)
_marshal_extension :: proc(oid: []byte, critical: bool, value: asn1.Value, allocator := context.allocator) -> (der: []byte, err: Error) {
out: []byte
merr: asn1.Error
if critical {
out, merr = asn1.marshal(
asn1.sequence({asn1.object_identifier(oid), asn1.boolean(true), asn1.octet_string_wrap({value})}),
allocator,
)
} else {
out, merr = asn1.marshal(asn1.sequence({asn1.object_identifier(oid), asn1.octet_string_wrap({value})}), allocator)
}
if merr != .None {
return nil, .Allocation_Failed
}
return out, .None
}
// _uint_be writes the minimal big-endian magnitude of a non-negative int into
// buf and returns the slice (empty for zero, which integer_unsigned encodes as 0).
@(private)
_uint_be :: proc(v: int, buf: []byte) -> []byte {
if v <= 0 {
return buf[:0]
}
n := 0
x := v
for x > 0 {
n += 1
x >>= 8
}
for i in 0 ..< n {
buf[n - 1 - i] = byte(v >> uint(8 * i))
}
return buf[:n]
}
@(private)
_eku_oid :: proc(bit: EKU_Bit) -> []byte {
switch bit {
case .Server_Auth:
return _OID_EKU_SERVER_AUTH
case .Client_Auth:
return _OID_EKU_CLIENT_AUTH
case .Code_Signing:
return _OID_EKU_CODE_SIGNING
case .Email_Protection:
return _OID_EKU_EMAIL_PROTECTION
case .Time_Stamping:
return _OID_EKU_TIME_STAMPING
case .OCSP_Signing:
return _OID_EKU_OCSP_SIGNING
case .Any:
return _OID_EKU_ANY
}
return nil
}

View File

@@ -0,0 +1,438 @@
package x509
import "core:encoding/asn1"
import "core:time"
// Distinguished-name attribute types the DN builder knows by name. `Other`
// carries any attribute outside this set via DN_Attribute.oid (used by
// parse_dn for attributes it does not recognize by name).
DN_Attribute_Type :: enum {
Common_Name, // CN, 2.5.4.3
Country, // C, 2.5.4.6
Locality, // L, 2.5.4.7
State_Or_Province, // ST, 2.5.4.8
Organization, // O, 2.5.4.10
Organizational_Unit, // OU, 2.5.4.11
Serial_Number, // 2.5.4.5
Other, // any other attribute; its type OID is in `oid`
}
// DN_Attribute is one relative distinguished name: a type and its value. When
// `type` is `Other`, `oid` holds the raw attribute-type OID content octets.
// Values are emitted as UTF8String, except Country and Serial_Number which
// are PrintableString (X.520), the policy RFC 5280 section 4.1.2.4 advises.
DN_Attribute :: struct {
type: DN_Attribute_Type,
value: string,
oid: []byte, // meaningful only when type == Other
}
@(rodata, private)
_OID_AT_CN := []byte{0x55, 0x04, 0x03}
@(rodata, private)
_OID_AT_C := []byte{0x55, 0x04, 0x06}
@(rodata, private)
_OID_AT_L := []byte{0x55, 0x04, 0x07}
@(rodata, private)
_OID_AT_ST := []byte{0x55, 0x04, 0x08}
@(rodata, private)
_OID_AT_O := []byte{0x55, 0x04, 0x0A}
@(rodata, private)
_OID_AT_OU := []byte{0x55, 0x04, 0x0B}
@(rodata, private)
_OID_AT_SERIAL := []byte{0x55, 0x04, 0x05}
// marshal_dn encodes `attrs` as a DER Name (RDNSequence): one single-valued
// RelativeDistinguishedName per attribute, in the given order. The returned
// slice is the caller's to free; the attribute value bytes are copied into
// it, so `attrs` need not outlive the call.
@(require_results)
marshal_dn :: proc(attrs: []DN_Attribute, allocator := context.allocator) -> (der: []byte, err: Error) {
n := len(attrs)
// Value-tree scaffolding, freed once the bytes are produced. Pre-sized so
// the sub-slices handed to set()/sequence() never move before the encode.
rdns, e1 := make([]asn1.Value, n, allocator) // one SET per attribute
if e1 != nil {
return nil, .Allocation_Failed
}
defer delete(rdns, allocator)
atvs, e2 := make([]asn1.Value, n, allocator) // the AttributeTypeAndValue SEQUENCE
if e2 != nil {
return nil, .Allocation_Failed
}
defer delete(atvs, allocator)
pairs, e3 := make([]asn1.Value, 2 * n, allocator) // {type OID, value} per attribute
if e3 != nil {
return nil, .Allocation_Failed
}
defer delete(pairs, allocator)
for a, i in attrs {
pairs[2 * i] = asn1.object_identifier(_dn_oid(a))
pairs[2 * i + 1] = asn1.primitive(_dn_string_tag(a.type), transmute([]byte)a.value)
atvs[i] = asn1.sequence(pairs[2 * i:2 * i + 2])
rdns[i] = asn1.set(atvs[i:i + 1])
}
out, merr := asn1.marshal(asn1.sequence(rdns), allocator)
if merr != .None {
return nil, .Allocation_Failed
}
return out, .None
}
@(private)
_dn_oid :: proc(a: DN_Attribute) -> []byte {
switch a.type {
case .Common_Name:
return _OID_AT_CN
case .Country:
return _OID_AT_C
case .Locality:
return _OID_AT_L
case .State_Or_Province:
return _OID_AT_ST
case .Organization:
return _OID_AT_O
case .Organizational_Unit:
return _OID_AT_OU
case .Serial_Number:
return _OID_AT_SERIAL
case .Other:
return a.oid
}
return nil
}
@(private)
_dn_string_tag :: proc(type: DN_Attribute_Type) -> asn1.Tag {
#partial switch type {
case .Country, .Serial_Number:
return asn1.universal(.Printable_String)
}
return asn1.universal(.UTF8_String)
}
@(rodata, private)
_DER_INT_ZERO := []byte{0x00}
// marshal_csr_info encodes the CertificationRequestInfo, the to-be-signed
// portion of a PKCS#10 CSR (RFC 2986): version v1, the subject DN, the subject
// public key, and the attributes set. Sign the returned bytes and pass them
// with the signature to marshal_csr. The slice is the caller's to free;
// `subject`, `key`, and `extensions` need not outlive the call.
//
// `extensions` is a list of pre-encoded Extension DER (from the marshal_ext_*
// helpers); when non-empty it is requested via a single PKCS#9
// extensionRequest attribute (the standard way a CSR asks the CA to place
// extensions, SANs, key usage in the issued certificate). Pass nil for the
// empty attributes set.
@(require_results)
marshal_csr_info :: proc(subject: []DN_Attribute, key: Public_Key, extensions: [][]byte = nil, allocator := context.allocator) -> (cri_der: []byte, err: Error) {
dn := marshal_dn(subject, allocator) or_return
defer delete(dn, allocator)
spki := marshal_spki(key, allocator) or_return
defer delete(spki, allocator)
attrs := _marshal_csr_attributes(extensions, allocator) or_return
defer delete(attrs, allocator)
// CertificationRequestInfo ::= SEQUENCE { version, subject, subjectPKInfo, [0] attributes }
out, merr := asn1.marshal(
asn1.sequence(
{
asn1.integer_raw(_DER_INT_ZERO), // version v1 (0)
asn1.raw(dn), // subject Name
asn1.raw(spki), // subjectPublicKeyInfo
asn1.raw(attrs), // attributes [0]
},
),
allocator,
)
if merr != .None {
return nil, .Allocation_Failed
}
return out, .None
}
// _marshal_csr_attributes encodes the CertificationRequestInfo attributes
// field, [0] IMPLICIT SET OF Attribute. With no extensions it is the empty
// set (A0 00); otherwise it carries a single PKCS#9 extensionRequest attribute
// Attribute ::= SEQUENCE { type extensionRequest, values SET { Extensions } }
// whose value is the Extensions SEQUENCE OF Extension built from `extensions`.
@(private, require_results)
_marshal_csr_attributes :: proc(extensions: [][]byte, allocator := context.allocator) -> (der: []byte, err: Error) {
out: []byte
merr: asn1.Error
if len(extensions) == 0 {
out, merr = asn1.marshal(asn1.context_explicit(0, {}), allocator)
} else {
// Pre-sized scaffolding for the Extensions SEQUENCE OF, kept alive
// (never resized) through the marshal below.
ext_raws, e := make([]asn1.Value, len(extensions), allocator)
if e != nil {
return nil, .Allocation_Failed
}
defer delete(ext_raws, allocator)
for ext, i in extensions {
ext_raws[i] = asn1.raw(ext)
}
out, merr = asn1.marshal(
asn1.context_explicit(
0,
{asn1.sequence({asn1.object_identifier(_OID_EXT_REQUEST), asn1.set({asn1.sequence(ext_raws[:])})})},
),
allocator,
)
}
if merr != .None {
return nil, .Allocation_Failed
}
return out, .None
}
// marshal_csr wraps a (separately signed) CertificationRequestInfo into a
// complete PKCS#10 CertificationRequest. `signature` is the raw signature
// value over `cri_der`, a DER ECDSA-Sig-Value for ECDSA, the 64-byte value
// for Ed25519, and `signature_algorithm` selects the matching
// AlgorithmIdentifier. The slice is the caller's to free.
@(require_results)
marshal_csr :: proc(cri_der: []byte, signature_algorithm: Signature_Algorithm, signature: []byte, allocator := context.allocator) -> (csr_der: []byte, err: Error) {
// CertificationRequest ::= SEQUENCE { CRI, signatureAlgorithm, signature BIT STRING }
return _marshal_signed(cri_der, signature_algorithm, signature, allocator)
}
// _marshal_signed wraps an already-encoded body (a CertificationRequestInfo
// or a TBSCertificate) with its signature algorithm and signature BIT STRING:
// SEQUENCE { body, AlgorithmIdentifier, BIT STRING signature }
// the common shape of PKCS#10 CSRs and X.509 certificates.
@(private, require_results)
_marshal_signed :: proc(body_der: []byte, signature_algorithm: Signature_Algorithm, signature: []byte, allocator := context.allocator) -> (der: []byte, err: Error) {
oid, null_params, ok := _sig_alg_identifier(signature_algorithm)
if !ok {
return nil, .Unsupported_Algorithm
}
out: []byte
merr: asn1.Error
if null_params {
out, merr = asn1.marshal(
asn1.sequence(
{
asn1.raw(body_der),
asn1.sequence({asn1.object_identifier(oid), asn1.null()}),
asn1.bit_string_octets(signature),
},
),
allocator,
)
} else {
out, merr = asn1.marshal(
asn1.sequence(
{asn1.raw(body_der), asn1.sequence({asn1.object_identifier(oid)}), asn1.bit_string_octets(signature)},
),
allocator,
)
}
if merr != .None {
return nil, .Allocation_Failed
}
return out, .None
}
// _marshal_alg_id encodes a standalone AlgorithmIdentifier (NULL params for
// RSA PKCS#1, absent for ECDSA/EdDSA) for embedding in a TBSCertificate.
@(private, require_results)
_marshal_alg_id :: proc(signature_algorithm: Signature_Algorithm, allocator := context.allocator) -> (der: []byte, err: Error) {
oid, null_params, ok := _sig_alg_identifier(signature_algorithm)
if !ok {
return nil, .Unsupported_Algorithm
}
out: []byte
merr: asn1.Error
if null_params {
out, merr = asn1.marshal(asn1.sequence({asn1.object_identifier(oid), asn1.null()}), allocator)
} else {
out, merr = asn1.marshal(asn1.sequence({asn1.object_identifier(oid)}), allocator)
}
if merr != .None {
return nil, .Allocation_Failed
}
return out, .None
}
// Maps a signature algorithm to its AlgorithmIdentifier OID and whether the
// parameters field is an explicit NULL (RSA PKCS#1) or absent (ECDSA, EdDSA).
@(private)
_sig_alg_identifier :: proc(alg: Signature_Algorithm) -> (oid: []byte, null_params: bool, ok: bool) {
#partial switch alg {
case .RSA_SHA256:
return _OID_SIG_RSA_SHA256, true, true
case .RSA_SHA384:
return _OID_SIG_RSA_SHA384, true, true
case .RSA_SHA512:
return _OID_SIG_RSA_SHA512, true, true
case .ECDSA_SHA256:
return _OID_SIG_ECDSA_SHA256, false, true
case .ECDSA_SHA384:
return _OID_SIG_ECDSA_SHA384, false, true
case .ECDSA_SHA512:
return _OID_SIG_ECDSA_SHA512, false, true
case .Ed25519:
return _OID_ED25519, false, true
}
return nil, false, false
}
@(rodata, private)
_DER_INT_V3 := []byte{0x02} // Version v3 (value 2)
// _marshal_extensions_field encodes the TBSCertificate extensions field:
// [3] EXPLICIT Extensions, Extensions ::= SEQUENCE OF Extension
// from a list of pre-encoded Extension DER, or nil when there are none (the
// field is OPTIONAL, so an empty list omits it entirely).
@(private, require_results)
_marshal_extensions_field :: proc(extensions: [][]byte, allocator := context.allocator) -> (der: []byte, err: Error) {
if len(extensions) == 0 {
return nil, .None
}
raws, merr := make([]asn1.Value, len(extensions), allocator)
if merr != nil {
return nil, .Allocation_Failed
}
defer delete(raws, allocator)
for e, i in extensions {
raws[i] = asn1.raw(e)
}
out, ferr := asn1.marshal(asn1.context_explicit(3, {asn1.sequence(raws[:])}), allocator)
if ferr != .None {
return nil, .Allocation_Failed
}
return out, .None
}
// TBS_Certificate gathers the fields of a TBSCertificate to encode. `issuer`
// and `subject` are RDNSequences; `serial` is the serialNumber's unsigned
// magnitude; `extensions` is a list of pre-encoded Extension DER (from the
// marshal_ext_* helpers), embedded in order.
TBS_Certificate :: struct {
serial: []byte,
signature_algorithm: Signature_Algorithm,
issuer: []DN_Attribute,
not_before: time.Time,
not_after: time.Time,
subject: []DN_Attribute,
public_key: Public_Key,
extensions: [][]byte,
}
// marshal_tbs_certificate encodes a v3 TBSCertificate (RFC 5280 section 4.1),
// the to-be-signed portion of a certificate. Sign the returned bytes and pass
// them with the signature to marshal_certificate. The slice is the caller's to
// free; the inputs need not outlive the call.
@(require_results)
marshal_tbs_certificate :: proc(tbs: TBS_Certificate, allocator := context.allocator) -> (der: []byte, err: Error) {
issuer_dn := marshal_dn(tbs.issuer, allocator) or_return
defer delete(issuer_dn, allocator)
subject_dn := marshal_dn(tbs.subject, allocator) or_return
defer delete(subject_dn, allocator)
spki := marshal_spki(tbs.public_key, allocator) or_return
defer delete(spki, allocator)
sig_alg := _marshal_alg_id(tbs.signature_algorithm, allocator) or_return
defer delete(sig_alg, allocator)
ext_field := _marshal_extensions_field(tbs.extensions, allocator) or_return
defer delete(ext_field, allocator)
// raw() splices the independently-marshalled pieces in place; raw(nil) for
// an absent extensions field contributes nothing.
out, merr := asn1.marshal(
asn1.sequence(
{
asn1.context_explicit(0, {asn1.integer_raw(_DER_INT_V3)}), // version [0] EXPLICIT v3
asn1.integer_unsigned(tbs.serial), // serialNumber
asn1.raw(sig_alg), // signature AlgorithmIdentifier
asn1.raw(issuer_dn), // issuer
asn1.sequence({asn1.time(tbs.not_before), asn1.time(tbs.not_after)}), // validity
asn1.raw(subject_dn), // subject
asn1.raw(spki), // subjectPublicKeyInfo
asn1.raw(ext_field), // extensions [3] (absent when empty)
},
),
allocator,
)
if merr != .None {
return nil, .Allocation_Failed
}
return out, .None
}
// marshal_certificate wraps a (separately signed) TBSCertificate into a
// complete X.509 Certificate. `signature_algorithm` must match the one inside
// the TBS (RFC 5280 section 4.1.1.2). The slice is the caller's to free.
@(require_results)
marshal_certificate :: proc(tbs_der: []byte, signature_algorithm: Signature_Algorithm, signature: []byte, allocator := context.allocator) -> (der: []byte, err: Error) {
return _marshal_signed(tbs_der, signature_algorithm, signature, allocator)
}
// Public_Key holds the subject public-key material to encode into a
// SubjectPublicKeyInfo, mirroring the fields parse() extracts onto a
// Certificate: rsa_n/rsa_e (unsigned magnitudes) for RSA; ec_point for
// ECDSA (the uncompressed point 0x04||X||Y) and Ed25519 (the 32-byte key).
Public_Key :: struct {
algorithm: Public_Key_Algorithm,
rsa_n: []byte,
rsa_e: []byte,
ec_point: []byte,
}
// marshal_spki encodes `key` as a DER SubjectPublicKeyInfo, the inverse of
// the SPKI decoding in parse(); the returned slice is the caller's to free.
// Unknown / unsupported key algorithms yield .Unsupported_Algorithm.
@(require_results)
marshal_spki :: proc(key: Public_Key, allocator := context.allocator) -> (der: []byte, err: Error) {
out: []byte
merr: asn1.Error
switch key.algorithm {
case .RSA:
// SEQUENCE { SEQUENCE { OID rsaEncryption, NULL }, BIT STRING { RSAPublicKey } }
out, merr = asn1.marshal(
asn1.sequence(
{
asn1.sequence({asn1.object_identifier(_OID_KEY_RSA), asn1.null()}),
asn1.bit_string_wrap({asn1.sequence({asn1.integer_unsigned(key.rsa_n), asn1.integer_unsigned(key.rsa_e)})}),
},
),
allocator,
)
case .ECDSA_P256, .ECDSA_P384, .ECDSA_P521:
curve_oid: []byte
#partial switch key.algorithm {
case .ECDSA_P256:
curve_oid = _OID_CURVE_P256
case .ECDSA_P384:
curve_oid = _OID_CURVE_P384
case .ECDSA_P521:
curve_oid = _OID_CURVE_P521
}
// SEQUENCE { SEQUENCE { OID ecPublicKey, OID namedCurve }, BIT STRING point }
out, merr = asn1.marshal(
asn1.sequence(
{
asn1.sequence({asn1.object_identifier(_OID_KEY_EC), asn1.object_identifier(curve_oid)}),
asn1.bit_string_octets(key.ec_point),
},
),
allocator,
)
case .Ed25519:
// SEQUENCE { SEQUENCE { OID Ed25519 }, BIT STRING key } (no params, RFC 8410)
out, merr = asn1.marshal(
asn1.sequence({asn1.sequence({asn1.object_identifier(_OID_ED25519)}), asn1.bit_string_octets(key.ec_point)}),
allocator,
)
case .Unknown:
return nil, .Unsupported_Algorithm
}
if merr != .None {
return nil, .Allocation_Failed
}
return out, .None
}

198
core/crypto/x509/name.odin Normal file
View File

@@ -0,0 +1,198 @@
package x509
import "core:bytes"
import "core:encoding/asn1"
import "core:strings"
// Consumer-facing helpers for reading the primitives `parse` leaves as raw DER:
// distinguished-name (Name) decoding, plus a serial-number display formatter.
// These are conveniences over the raw fields, path validation does not need
// them, and `raw_subject` / `raw_issuer` remain available for the RFC 5280
// binary-comparison rule (which is what issuer/subject matching uses).
// parse_dn decodes a DER Name (an RDNSequence, e.g. cert.raw_subject or
// cert.raw_issuer) into its attributes, the inverse of marshal_dn. Attributes
// outside the recognized set (see DN_Attribute_Type) come back as `Other` with
// their type OID in `oid`. The returned slice is the caller's to free; every
// `value` (and `oid`) is a VIEW into `der`, which must outlive the result.
//
// Values are taken as the raw content octets of the attribute value: correct
// for the UTF8String / PrintableString / IA5String forms certificates use in
// practice, but Teletex/BMP/Universal strings are NOT transcoded (returned as
// their raw bytes).
@(require_results)
parse_dn :: proc(der: []byte, allocator := context.allocator) -> (attrs: []DN_Attribute, err: Error) {
cur: asn1.Cursor
asn1.cursor_init(&cur, der)
seq, e := asn1.read_sequence(&cur)
if e != .None || asn1.done(&cur) != .None {
return nil, .Malformed
}
// Count AttributeTypeAndValue entries first (an RDN is a SET OF, usually one
// element), so the result is exact-sized.
count := 0
{
tmp := seq
for !asn1.is_empty(&tmp) {
rdn, re := asn1.read_set(&tmp)
if re != .None {
return nil, .Malformed
}
for !asn1.is_empty(&rdn) {
if _, ae := asn1.read_sequence(&rdn); ae != .None {
return nil, .Malformed
}
count += 1
}
}
}
if count == 0 {
return nil, .None // an empty Name is valid
}
out, merr := make([]DN_Attribute, count, allocator)
if merr != nil {
return nil, .Allocation_Failed
}
i := 0
for !asn1.is_empty(&seq) {
rdn, re := asn1.read_set(&seq)
if re != .None {
delete(out, allocator)
return nil, .Malformed
}
for !asn1.is_empty(&rdn) {
atv, ae := asn1.read_sequence(&rdn)
oid, oe := asn1.read_oid(&atv)
_, val, ve := asn1.read_any(&atv)
if ae != .None || oe != .None || ve != .None || asn1.done(&atv) != .None {
delete(out, allocator)
return nil, .Malformed
}
t, known := _dn_type_from_oid(oid)
out[i] = DN_Attribute {
type = t,
value = string(val),
}
if !known {
out[i].oid = oid
}
i += 1
}
}
return out[:i], .None
}
// dn_get returns the value of the first attribute of `type` (e.g. .Common_Name),
// and whether one was present.
dn_get :: proc(attrs: []DN_Attribute, type: DN_Attribute_Type) -> (value: string, ok: bool) {
for a in attrs {
if a.type == type {
return a.value, true
}
}
return "", false
}
// dn_string renders `attrs` as an RFC 4514 string ("CN=leaf,O=Acme,C=US"):
// RDNs in reverse order, short names for the recognized attributes and the
// dotted OID for `Other`, with RFC 4514 section 2.4 special characters escaped.
// The returned string is the caller's to free.
@(require_results)
dn_string :: proc(attrs: []DN_Attribute, allocator := context.allocator) -> string {
b := strings.builder_make(allocator)
for i := len(attrs) - 1; i >= 0; i -= 1 {
a := attrs[i]
if i != len(attrs) - 1 {
strings.write_byte(&b, ',')
}
switch a.type {
case .Common_Name:
strings.write_string(&b, "CN")
case .Country:
strings.write_string(&b, "C")
case .Locality:
strings.write_string(&b, "L")
case .State_Or_Province:
strings.write_string(&b, "ST")
case .Organization:
strings.write_string(&b, "O")
case .Organizational_Unit:
strings.write_string(&b, "OU")
case .Serial_Number:
strings.write_string(&b, "serialNumber")
case .Other:
if s, e := asn1.oid_to_string(a.oid, context.temp_allocator); e == .None {
strings.write_string(&b, s)
} else {
strings.write_byte(&b, '?')
}
}
strings.write_byte(&b, '=')
_dn_escape(&b, a.value)
}
return strings.to_string(b)
}
// serial_string formats the certificate serial as upper-case colon-separated
// hex ("07:44:76:…"). The serial is an opaque identifier (up to 20 octets)
// Allocated String
@(require_results)
serial_string :: proc(cert: ^Certificate, allocator := context.allocator) -> string {
HEX := "0123456789ABCDEF"
b := strings.builder_make(allocator)
for octet, i in cert.serial {
if i != 0 {
strings.write_byte(&b, ':')
}
strings.write_byte(&b, HEX[octet >> 4])
strings.write_byte(&b, HEX[octet & 0x0F])
}
return strings.to_string(b)
}
// _dn_type_from_oid maps an attribute-type OID to a DN_Attribute_Type, the
// inverse of _dn_oid
@(private)
_dn_type_from_oid :: proc(oid: []byte) -> (type: DN_Attribute_Type, is_known_oid: bool) {
switch {
case bytes.equal(oid, _OID_AT_CN):
return .Common_Name, true
case bytes.equal(oid, _OID_AT_C):
return .Country, true
case bytes.equal(oid, _OID_AT_L):
return .Locality, true
case bytes.equal(oid, _OID_AT_ST):
return .State_Or_Province, true
case bytes.equal(oid, _OID_AT_O):
return .Organization, true
case bytes.equal(oid, _OID_AT_OU):
return .Organizational_Unit, true
case bytes.equal(oid, _OID_AT_SERIAL):
return .Serial_Number, true
}
return .Other, false
}
// _dn_escape writes `s` into `b` with the RFC 4514 section 2.4 escapes: a
// leading '#' or space and a trailing space are escaped, as are the characters
// " + , ; < > \ and the NUL byte.
@(private)
_dn_escape :: proc(b: ^strings.Builder, s: string) {
for i in 0 ..< len(s) {
c := s[i]
lead := i == 0 && (c == ' ' || c == '#')
trail := i == len(s) - 1 && c == ' '
switch c {
case '"', '+', ',', ';', '<', '>', '\\', 0x00:
strings.write_byte(b, '\\')
strings.write_byte(b, c)
case:
if lead || trail {
strings.write_byte(b, '\\')
}
strings.write_byte(b, c)
}
}
}

View File

@@ -0,0 +1,363 @@
package x509
import "core:bytes"
import "core:encoding/asn1"
import "core:strings"
// 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, strings.trim_prefix(c, ".")) {
return true
}
} else if _dns_in_constraint(strings.trim_prefix(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
}
// _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
}
// _ascii_eq_fold / _ascii_suffix_fold are the package's DNS-name comparison
// primitives, shared with verify_hostname. dNSNames are IA5String (ASCII) and
// IDNs are carried as punycode A-labels. Do not replace with strings.equal_fold.
@(private)
_ascii_lower :: proc "contextless" (b: byte) -> byte {
if b >= 'A' && b <= 'Z' { return b+0x20 }
else { return 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
}

751
core/crypto/x509/parse.odin Normal file
View File

@@ -0,0 +1,751 @@
package x509
import "core:bytes"
import "core:crypto/hash"
import "core:encoding/asn1"
/*
Ref: RFC 5280, Section 4.1, wire format:
This grammar is decoded via a cursor over the DER. The contents flatten into
the certificate (see x509.odin).
Certificate / TBSCertificate -> Certificate (flattened; raw, raw_tbs)
signatureAlgorithm + value -> signature_algorithm, signature_oid, signature
Version -> version (int: 1 / 2 / 3)
CertificateSerialNumber -> serial ([]byte, raw INTEGER content)
Validity / Time -> not_before, not_after (time.Time)
Name (issuer / subject) -> raw_issuer, raw_subject ([]byte DER; not decoded)
SubjectPublicKeyInfo -> raw_spki, public_key_algorithm, rsa_n/rsa_e/ec_point
Extension -> Extension struct, in `extensions`
issuer/subjectUniqueID -> skipped (obsolete)
Certificate ::= SEQUENCE {
tbsCertificate TBSCertificate,
signatureAlgorithm AlgorithmIdentifier,
signatureValue BIT STRING
}
TBSCertificate ::= SEQUENCE {
version [0] EXPLICIT Version DEFAULT v1,
serialNumber CertificateSerialNumber,
signature AlgorithmIdentifier,
issuer Name,
validity Validity,
subject Name,
subjectPublicKeyInfo SubjectPublicKeyInfo,
issuerUniqueID [1] IMPLICIT UniqueIdentifier OPTIONAL,
subjectUniqueID [2] IMPLICIT UniqueIdentifier OPTIONAL,
extensions [3] EXPLICIT Extensions OPTIONAL
}
Version ::= INTEGER { v1(0), v2(1), v3(2) }
CertificateSerialNumber ::= INTEGER
Validity ::= SEQUENCE {
notBefore Time,
notAfter Time }
Time ::= CHOICE {
utcTime UTCTime,
generalTime GeneralizedTime }
UniqueIdentifier ::= BIT STRING
SubjectPublicKeyInfo ::= SEQUENCE {
algorithm AlgorithmIdentifier,
subjectPublicKey BIT STRING }
Extensions ::= SEQUENCE SIZE (1..MAX) OF Extension
Extension ::= SEQUENCE {
extnID OBJECT IDENTIFIER,
critical BOOLEAN DEFAULT FALSE,
extnValue OCTET STRING
-- contains the DER encoding of an ASN.1 value
-- corresponding to the extension type identified
-- by extnID
}
AlgorithmIdentifier ::= SEQUENCE {
algorithm OBJECT IDENTIFIER,
parameters ANY DEFINED BY algorithm OPTIONAL
}
Name ::= CHOICE { -- only one possibility for now --
rdnSequence RDNSequence
}
RDNSequence ::= SEQUENCE OF RelativeDistinguishedName
RelativeDistinguishedName ::= SET SIZE (1..MAX) OF AttributeTypeAndValue
AttributeTypeAndValue ::= SEQUENCE {
type AttributeType,
value AttributeValue
}
AttributeType ::= OBJECT IDENTIFIER
AttributeValue ::= ANY -- DEFINED BY AttributeType
DirectoryString ::= CHOICE {
teletexString TeletexString (SIZE (1..MAX)),
printableString PrintableString (SIZE (1..MAX)),
universalString UniversalString (SIZE (1..MAX)),
utf8String UTF8String (SIZE (1..MAX)),
bmpString BMPString (SIZE (1..MAX))
}
*/
// parse decodes one DER certificate. The returned Certificate holds views into `der`, which must outlive it;
// the allocated tables are released with destroy(). Trailing bytes after the certificate are an error.
@(require_results)
parse :: proc(der: []byte, allocator := context.allocator) -> (cert: Certificate, err: Error) {
r: asn1.Cursor
asn1.cursor_init(&r, der)
outer, oerr := asn1.read_sequence(&r)
if oerr != .None || asn1.done(&r) != .None {
return {}, .Malformed
}
cert.raw = der[:r.pos]
// tbsCertificate: capture the full element (header included);
tbs_start := outer.pos
tbs, terr := asn1.read_sequence(&outer)
if terr != .None {
return {}, .Malformed
}
cert.raw_tbs = outer.data[tbs_start:outer.pos]
// signatureAlgorithm + signatureValue.
sig_oid, sig_params, serr := _read_algorithm_identifier(&outer)
if serr != .None {
return {}, .Malformed
}
cert.signature_oid = sig_oid
cert.signature_algorithm = _signature_algorithm(sig_oid)
if cert.signature_algorithm == .RSA_PSS {
if perr := _parse_pss_params(&cert, sig_params); perr != .None {
return {}, perr
}
}
sig_bits, sberr := asn1.read_bit_string_octets(&outer)
if sberr != .None || asn1.done(&outer) != .None {
return {}, .Malformed
}
cert.signature = sig_bits
// ---- TBSCertificate ----
// [0] EXPLICIT version (DEFAULT v1).
cert.version = 1
vr, has_version, verr := asn1.read_explicit(&tbs, 0)
if verr != .None {
return {}, .Malformed
}
if has_version {
v, vierr := asn1.read_i64(&vr)
if vierr != .None || asn1.done(&vr) != .None {
return {}, .Malformed
}
if v < 0 || v > 2 {
return {}, .Unsupported_Version
}
cert.version = int(v) + 1
}
// The serial is read as a raw INTEGER (not unsigned): RFC 5280 requires it to be positive, but non-conformant CAs issue negative
// serials and rejecting them is a validation policy, not a parsing one. The two's-complement content is preserved verbatim.
serial, snerr := asn1.read_integer_bytes(&tbs)
if snerr != .None {
return {}, .Malformed
}
cert.serial = serial
// signature must match the outer signatureAlgorithm per RFC 5280 section 4.1.1.2,
// the OID always, and for RSA-PSS the parameters too (they carry the digest).
tbs_sig_oid, tbs_sig_params, tserr := _read_algorithm_identifier(&tbs)
if tserr != .None {
return {}, .Malformed
}
if !bytes.equal(tbs_sig_oid, sig_oid) {
return {}, .Malformed
}
if cert.signature_algorithm == .RSA_PSS && !bytes.equal(tbs_sig_params, sig_params) {
return {}, .Malformed
}
// issuer
issuer_start := tbs.pos
if _, ierr := asn1.read_sequence(&tbs); ierr != .None {
return {}, .Malformed
}
cert.raw_issuer = tbs.data[issuer_start:tbs.pos]
// validity
validity, vderr := asn1.read_sequence(&tbs)
if vderr != .None {
return {}, .Invalid_Validity
}
nb, nberr := asn1.read_time(&validity)
na, naerr := asn1.read_time(&validity)
if nberr != .None || naerr != .None || asn1.done(&validity) != .None {
return {}, .Invalid_Validity
}
cert.not_before = nb
cert.not_after = na
// subject
subject_start := tbs.pos
if _, suberr := asn1.read_sequence(&tbs); suberr != .None {
return {}, .Malformed
}
cert.raw_subject = tbs.data[subject_start:tbs.pos]
// subjectPublicKeyInfo, full element preserved for hashing
spki_start := tbs.pos
spki, sperr := asn1.read_sequence(&tbs)
if sperr != .None {
return {}, .Malformed
}
cert.raw_spki = tbs.data[spki_start:tbs.pos]
if kerr := _parse_spki(&cert, &spki); kerr != .None {
return {}, kerr
}
// issuerUniqueID / subjectUniqueID - obsolete; skip if present
for number in u32(1) ..= u32(2) {
if asn1.is_empty(&tbs) {
break
}
tag, perr := asn1.peek_tag(&tbs)
if perr != .None {
return {}, .Malformed
}
if tag.class == .Context_Specific && tag.number == number {
if asn1.skip(&tbs) != .None {
return {}, .Malformed
}
}
}
// [3] EXPLICIT extensions.
cert.max_path_len = -1
er, has_exts, eerr := asn1.read_explicit(&tbs, 3)
if eerr != .None || asn1.done(&tbs) != .None {
return {}, .Malformed
}
if has_exts {
if cert.version != 3 {
return {}, .Malformed
}
if xerr := _parse_extensions(&cert, &er, allocator); xerr != .None {
destroy(&cert, allocator)
return {}, xerr
}
}
return cert, .None
}
// Internals.
// _read_algorithm_identifier reads SEQUENCE { OID, params ANY OPTIONAL }, returning the OID content and the raw parameter element
// (nil when absent).
@(private)
_read_algorithm_identifier :: proc(
r: ^asn1.Cursor,
) -> (
oid: []byte,
params: []byte,
err: asn1.Error,
) {
alg, aerr := asn1.read_sequence(r)
if aerr != .None {
return nil, nil, aerr
}
oid, err = asn1.read_oid(&alg)
if err != .None {
return nil, nil, err
}
if !asn1.is_empty(&alg) {
params_start := alg.pos
if serr := asn1.skip(&alg); serr != .None {
return nil, nil, serr
}
params = alg.data[params_start:alg.pos]
}
if derr := asn1.done(&alg); derr != .None {
return nil, nil, derr
}
return oid, params, .None
}
@(private)
_signature_algorithm :: proc(oid: []byte) -> Signature_Algorithm {
switch {
case bytes.equal(oid, _OID_SIG_RSA_SHA256):
return .RSA_SHA256
case bytes.equal(oid, _OID_SIG_ECDSA_SHA256):
return .ECDSA_SHA256
case bytes.equal(oid, _OID_SIG_RSA_SHA384):
return .RSA_SHA384
case bytes.equal(oid, _OID_SIG_RSA_SHA512):
return .RSA_SHA512
case bytes.equal(oid, _OID_SIG_ECDSA_SHA384):
return .ECDSA_SHA384
case bytes.equal(oid, _OID_SIG_ECDSA_SHA512):
return .ECDSA_SHA512
case bytes.equal(oid, _OID_ED25519):
return .Ed25519
case bytes.equal(oid, _OID_SIG_RSA_PSS):
return .RSA_PSS
case bytes.equal(oid, _OID_SIG_RSA_SHA1):
return .RSA_SHA1
}
return .Unknown
}
// _hash_from_oid maps a bare hash-algorithm OID (as it appears in an
// RSASSA-PSS AlgorithmIdentifier) to a hash.Algorithm, reporting ok=false for
// digests this package does not verify (leaving the field .Invalid so the
// verifier fails closed rather than the parser rejecting the certificate).
@(private)
_hash_from_oid :: proc(oid: []byte) -> (hash.Algorithm, bool) {
switch {
case bytes.equal(oid, _OID_HASH_SHA256):
return .SHA256, true
case bytes.equal(oid, _OID_HASH_SHA384):
return .SHA384, true
case bytes.equal(oid, _OID_HASH_SHA512):
return .SHA512, true
case bytes.equal(oid, _OID_HASH_SHA1):
return .Insecure_SHA1, true
}
return .Invalid, false
}
// _parse_pss_params decodes RSASSA-PSS-params (RFC 4055 section 3.1) from the
// signatureAlgorithm parameters into cert.pss_*:
// SEQUENCE { hashAlgorithm [0], maskGenAlgorithm [1], saltLength [2] INTEGER,
// trailerField [3] INTEGER }, all EXPLICIT and all with defaults.
// Omitted fields take their RFC 4055 defaults (SHA-1 / MGF1-SHA-1 / salt 20).
// A structurally broken params element is .Malformed; an unrecognized digest is
// NOT an error here, the hash is left .Invalid for verify_signature to reject.
@(private)
_parse_pss_params :: proc(cert: ^Certificate, params: []byte) -> Error {
// RFC 4055 defaults (applied when a field is absent).
cert.pss_hash = .Insecure_SHA1
cert.pss_mgf_hash = .Insecure_SHA1
cert.pss_salt_len = 20
if len(params) == 0 {
return .None // absent parameters: all defaults
}
cur: asn1.Cursor
asn1.cursor_init(&cur, params)
seq, e := asn1.read_sequence(&cur)
if e != .None || asn1.done(&cur) != .None {
return .Malformed
}
// hashAlgorithm [0] EXPLICIT AlgorithmIdentifier
if inner, present, ie := asn1.read_explicit(&seq, 0); ie != .None {
return .Malformed
} else if present {
oid, _, oe := _read_algorithm_identifier(&inner)
if oe != .None || asn1.done(&inner) != .None {
return .Malformed
}
cert.pss_hash, _ = _hash_from_oid(oid) // .Invalid when unrecognized
}
// maskGenAlgorithm [1] EXPLICIT AlgorithmIdentifier { id-mgf1, hashAlgorithm }
if inner, present, ie := asn1.read_explicit(&seq, 1); ie != .None {
return .Malformed
} else if present {
mgf_oid, mgf_params, me := _read_algorithm_identifier(&inner)
if me != .None || asn1.done(&inner) != .None {
return .Malformed
}
if !bytes.equal(mgf_oid, _OID_MGF1) {
cert.pss_mgf_hash = .Invalid // an MGF other than MGF1: unverifiable here
} else {
mp: asn1.Cursor
asn1.cursor_init(&mp, mgf_params)
hoid, _, he := _read_algorithm_identifier(&mp)
if he != .None || asn1.done(&mp) != .None {
return .Malformed
}
cert.pss_mgf_hash, _ = _hash_from_oid(hoid)
}
}
// saltLength [2] EXPLICIT INTEGER
if inner, present, ie := asn1.read_explicit(&seq, 2); ie != .None {
return .Malformed
} else if present {
sl, se := asn1.read_i64(&inner)
if se != .None || asn1.done(&inner) != .None || sl < 0 {
return .Malformed
}
cert.pss_salt_len = int(sl)
}
// trailerField [3] EXPLICIT INTEGER, only trailerFieldBC (1) is defined.
if inner, present, ie := asn1.read_explicit(&seq, 3); ie != .None {
return .Malformed
} else if present {
tf, te := asn1.read_i64(&inner)
if te != .None || asn1.done(&inner) != .None || tf != 1 {
return .Malformed
}
}
if asn1.done(&seq) != .None {
return .Malformed
}
return .None
}
@(private)
_parse_spki :: proc(cert: ^Certificate, spki: ^asn1.Cursor) -> Error {
key_oid, key_params, aerr := _read_algorithm_identifier(spki)
if aerr != .None {
return .Malformed
}
key_bits, kberr := asn1.read_bit_string_octets(spki)
if kberr != .None || asn1.done(spki) != .None {
return .Malformed
}
switch {
case bytes.equal(key_oid, _OID_KEY_RSA):
cert.public_key_algorithm = .RSA
// RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }
kr: asn1.Cursor
asn1.cursor_init(&kr, key_bits)
rsa, rerr := asn1.read_sequence(&kr)
if rerr != .None || asn1.done(&kr) != .None {
return .Malformed
}
n, nerr := asn1.read_unsigned_integer_bytes(&rsa)
e, eerr := asn1.read_unsigned_integer_bytes(&rsa)
if nerr != .None || eerr != .None || asn1.done(&rsa) != .None {
return .Malformed
}
cert.rsa_n = n
cert.rsa_e = e
case bytes.equal(key_oid, _OID_KEY_EC):
// Parameters carry the named curve: OID wrapped in the params element we captured raw
pr: asn1.Cursor
asn1.cursor_init(&pr, key_params)
curve_oid, cerr := asn1.read_oid(&pr)
if cerr != .None || asn1.done(&pr) != .None {
return .Malformed
}
switch {
case bytes.equal(curve_oid, _OID_CURVE_P256):
cert.public_key_algorithm = .ECDSA_P256
case bytes.equal(curve_oid, _OID_CURVE_P384):
cert.public_key_algorithm = .ECDSA_P384
case bytes.equal(curve_oid, _OID_CURVE_P521):
cert.public_key_algorithm = .ECDSA_P521
case:
cert.public_key_algorithm = .Unknown
}
cert.ec_point = key_bits
case bytes.equal(key_oid, _OID_ED25519):
cert.public_key_algorithm = .Ed25519
if len(key_bits) != 32 {
return .Malformed
}
cert.ec_point = key_bits
case:
cert.public_key_algorithm = .Unknown
}
return .None
}
@(private)
_parse_extensions :: proc(
cert: ^Certificate,
er: ^asn1.Cursor,
allocator := context.allocator,
) -> Error {
exts, xerr := asn1.read_sequence(er)
if xerr != .None || asn1.done(er) != .None {
return .Malformed
}
// Find allocation size
count := 0
{
tmp := exts
for !asn1.is_empty(&tmp) {
if asn1.skip(&tmp) != .None {
return .Malformed
}
count += 1
}
}
exts_table, merr := make([]Extension, count, allocator)
if merr != nil {
return .Allocation_Failed
}
cert.extensions = exts_table
for i in 0 ..< count {
// Extension ::= SEQUENCE { extnID OID, critical BOOLEAN DEFAULT FALSE, extnValue OCTET STRING }
ext, eerr := asn1.read_sequence(&exts)
if eerr != .None {
return .Malformed
}
oid, oerr := asn1.read_oid(&ext)
if oerr != .None {
return .Malformed
}
critical := false
tag, perr := asn1.peek_tag(&ext)
if perr != .None {
return .Malformed
}
if tag == asn1.universal(.Boolean) {
c, berr := asn1.read_boolean(&ext)
if berr != .None {
return .Malformed
}
critical = c
}
value, verr := asn1.read_octet_string(&ext)
if verr != .None || asn1.done(&ext) != .None {
return .Malformed
}
// RFC 5280 section 4.2: "A certificate MUST NOT include more than one instance of a particular extension."
for j in 0 ..< i {
if bytes.equal(cert.extensions[j].oid, oid) {
return .Duplicate_Extension
}
}
cert.extensions[i] = Extension {
oid = oid,
critical = critical,
value = value,
}
if herr := _parse_known_extension(cert, oid, critical, value, allocator); herr != .None {
return herr
}
}
return .None
}
@(private)
_parse_known_extension :: proc(
cert: ^Certificate,
oid: []byte,
critical: bool,
value: []byte,
allocator := context.allocator,
) -> Error {
vr: asn1.Cursor
asn1.cursor_init(&vr, value)
switch {
case bytes.equal(oid, _OID_EXT_BASIC_CONSTRAINTS):
// BasicConstraints ::= SEQUENCE { cA BOOLEAN DEFAULT FALSE, pathLenConstraint INTEGER OPTIONAL }
bc, err := asn1.read_sequence(&vr)
if err != .None || asn1.done(&vr) != .None {
return .Invalid_Extension
}
if !asn1.is_empty(&bc) {
tag, perr := asn1.peek_tag(&bc)
if perr != .None {
return .Invalid_Extension
}
if tag == asn1.universal(.Boolean) {
ca, berr := asn1.read_boolean(&bc)
if berr != .None {
return .Invalid_Extension
}
cert.is_ca = ca
}
}
if !asn1.is_empty(&bc) {
depth, derr := asn1.read_i64(&bc)
if derr != .None || depth < 0 {
return .Invalid_Extension
}
cert.max_path_len = int(depth)
}
if asn1.done(&bc) != .None {
return .Invalid_Extension
}
cert.basic_constraints_valid = true
case bytes.equal(oid, _OID_EXT_KEY_USAGE):
bits, unused, err := asn1.read_bit_string(&vr)
if err != .None || asn1.done(&vr) != .None {
return .Invalid_Extension
}
total := len(bits) * 8 - unused
usage: Key_Usage
for bit in Key_Usage_Bit {
i := int(bit)
if i >= total {
continue
}
if bits[i / 8] & (0x80 >> uint(i % 8)) != 0 {
usage += {bit}
}
}
cert.key_usage = usage
cert.has_key_usage = true
case bytes.equal(oid, _OID_EXT_EXT_KEY_USAGE):
seq, err := asn1.read_sequence(&vr)
if err != .None || asn1.done(&vr) != .None {
return .Invalid_Extension
}
for !asn1.is_empty(&seq) {
purpose, perr := asn1.read_oid(&seq)
if perr != .None {
return .Invalid_Extension
}
switch {
case bytes.equal(purpose, _OID_EKU_SERVER_AUTH):
cert.ext_key_usage += {.Server_Auth}
case bytes.equal(purpose, _OID_EKU_CLIENT_AUTH):
cert.ext_key_usage += {.Client_Auth}
case bytes.equal(purpose, _OID_EKU_CODE_SIGNING):
cert.ext_key_usage += {.Code_Signing}
case bytes.equal(purpose, _OID_EKU_EMAIL_PROTECTION):
cert.ext_key_usage += {.Email_Protection}
case bytes.equal(purpose, _OID_EKU_TIME_STAMPING):
cert.ext_key_usage += {.Time_Stamping}
case bytes.equal(purpose, _OID_EKU_OCSP_SIGNING):
cert.ext_key_usage += {.OCSP_Signing}
case bytes.equal(purpose, _OID_EKU_ANY):
cert.ext_key_usage += {.Any}
case:
cert.eku_has_unknown = true
}
}
cert.has_ext_key_usage = true
case bytes.equal(oid, _OID_EXT_SAN):
return _parse_san(cert, &vr, allocator)
case bytes.equal(oid, _OID_EXT_SUBJECT_KEY_ID):
ski, err := asn1.read_octet_string(&vr)
if err != .None || asn1.done(&vr) != .None {
return .Invalid_Extension
}
cert.subject_key_id = ski
case bytes.equal(oid, _OID_EXT_AUTHORITY_KEY_ID):
// AuthorityKeyIdentifier ::= SEQUENCE { keyIdentifier [0] IMPLICIT OCTET STRING OPTIONAL, ... }
aki, err := asn1.read_sequence(&vr)
if err != .None || asn1.done(&vr) != .None {
return .Invalid_Extension
}
if !asn1.is_empty(&aki) {
tag, perr := asn1.peek_tag(&aki)
if perr != .None {
return .Invalid_Extension
}
if tag == asn1.context_specific(0, false) {
kid, kerr := asn1.expect(&aki, tag)
if kerr != .None {
return .Invalid_Extension
}
cert.authority_key_id = kid
}
}
// authorityCertIssuer [1] and authorityCertSerialNumber [2] are intentionally not decoded: keyIdentifier is the form used in
// 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
}
}
return .None
}
// GeneralNames ::= SEQUENCE OF GeneralName; we extract dNSName ([2] IA5String) and iPAddress ([7] OCTET STRING).
@(private)
_parse_san :: proc(cert: ^Certificate, vr: ^asn1.Cursor, allocator := context.allocator) -> Error {
names, err := asn1.read_sequence(vr)
if err != .None || asn1.done(vr) != .None {
return .Invalid_Extension
}
dns_count, ip_count := 0, 0
{
tmp := names
for !asn1.is_empty(&tmp) {
tag, content, gerr := asn1.read_any(&tmp)
if gerr != .None || tag.class != .Context_Specific {
return .Invalid_Extension
}
switch tag.number {
case 2:
dns_count += 1
case 7:
if len(content) != 4 && len(content) != 16 {
return .Invalid_Extension
}
ip_count += 1
}
}
}
// On allocation failure the caller (parse) unwinds every table via destroy.
if dns_count > 0 {
dns_table, derr := make([]string, dns_count, allocator)
if derr != nil {
return .Allocation_Failed
}
cert.dns_names = dns_table
}
if ip_count > 0 {
ip_table, ierr := make([][]byte, ip_count, allocator)
if ierr != nil {
return .Allocation_Failed
}
cert.ip_addresses = ip_table
}
di, ii := 0, 0
for !asn1.is_empty(&names) {
tag, content, gerr := asn1.read_any(&names)
if gerr != .None {
return .Invalid_Extension
}
switch tag.number {
case 2:
cert.dns_names[di] = string(content)
di += 1
case 7:
cert.ip_addresses[ii] = content
ii += 1
}
}
return .None
}

143
core/crypto/x509/pkcs8.odin Normal file
View File

@@ -0,0 +1,143 @@
package x509
import "core:crypto"
import "core:encoding/asn1"
// Private_Key holds the RAW key material to serialize as a PKCS#8
// PrivateKeyInfo (RFC 5208 / 5958). It is the bytes the crypto packages expose,
// which marshal_pkcs8 only assembles into DER:
// - ECDSA: `private` is the secret scalar (ecdsa.private_key_bytes), `public`
// (optional) the uncompressed point (ecdsa.public_key_bytes).
// - Ed25519: `private` is the 32-byte seed (ed25519.private_key_bytes).
// - RSA: the `rsa_*` CRT components, as big-endian magnitudes, from the
// rsa.private_key_{n,e,d,p,q,dp,dq,iq} accessors.
Private_Key :: struct {
algorithm: Public_Key_Algorithm,
private: []byte,
public: []byte,
// RSA CRT components (big-endian magnitudes), used when algorithm == RSA.
rsa_n: []byte, // modulus
rsa_e: []byte, // public exponent
rsa_d: []byte, // private exponent
rsa_p: []byte, // prime1
rsa_q: []byte, // prime2
rsa_dp: []byte, // d mod (p-1)
rsa_dq: []byte, // d mod (q-1)
rsa_iq: []byte, // q^-1 mod p (CRT coefficient)
}
// private_key_clear securely wipes the secret material `key` references.
// The byte buffers are the caller's: this clears their secret contents
// in place (the public modulus / exponent / point are left untouched; only the
// slice headers are dropped). The DER that marshal_pkcs8 returns also holds the
// key, so wipe and free it separately.
private_key_clear :: proc "contextless" (key: ^Private_Key) {
crypto.zero_explicit(raw_data(key.private), len(key.private))
crypto.zero_explicit(raw_data(key.rsa_d), len(key.rsa_d))
crypto.zero_explicit(raw_data(key.rsa_p), len(key.rsa_p))
crypto.zero_explicit(raw_data(key.rsa_q), len(key.rsa_q))
crypto.zero_explicit(raw_data(key.rsa_dp), len(key.rsa_dp))
crypto.zero_explicit(raw_data(key.rsa_dq), len(key.rsa_dq))
crypto.zero_explicit(raw_data(key.rsa_iq), len(key.rsa_iq))
key^ = {}
}
@(rodata, private)
_DER_INT_ONE := []byte{0x01}
// Serializes `key` as a DER PKCS#8 PrivateKeyInfo. The crypto has already
// happened; this only nests the raw bytes. An unknown algorithm yields
// .Unsupported_Algorithm. The returned slice is the caller's to free.
@(require_results)
marshal_pkcs8 :: proc(key: Private_Key, allocator := context.allocator) -> (der: []byte, err: Error) {
out: []byte
merr: asn1.Error
switch key.algorithm {
case .ECDSA_P256, .ECDSA_P384, .ECDSA_P521:
curve_oid: []byte
#partial switch key.algorithm {
case .ECDSA_P256:
curve_oid = _OID_CURVE_P256
case .ECDSA_P384:
curve_oid = _OID_CURVE_P384
case .ECDSA_P521:
curve_oid = _OID_CURVE_P521
}
// ECPrivateKey ::= SEQUENCE { version(1), privateKey OCTET STRING, [1] publicKey BIT STRING OPTIONAL }
// (parameters [0] is omitted: the curve is in privateKeyAlgorithm.)
ec_fields: [3]asn1.Value
pub_wrap: [1]asn1.Value // stable backing for the [1] EXPLICIT child
n := 0
ec_fields[n] = asn1.integer_raw(_DER_INT_ONE)
n += 1
ec_fields[n] = asn1.octet_string(key.private)
n += 1
if len(key.public) > 0 {
pub_wrap[0] = asn1.bit_string_octets(key.public)
ec_fields[n] = asn1.context_explicit(1, pub_wrap[:])
n += 1
}
// PrivateKeyInfo ::= SEQUENCE { version(0), AlgId{ecPublicKey, curve},
// privateKey OCTET STRING { ECPrivateKey } }
out, merr = asn1.marshal(
asn1.sequence(
{
asn1.integer_raw(_DER_INT_ZERO),
asn1.sequence({asn1.object_identifier(_OID_KEY_EC), asn1.object_identifier(curve_oid)}),
asn1.octet_string_wrap({asn1.sequence(ec_fields[:n])}),
},
),
allocator,
)
case .Ed25519:
// PrivateKeyInfo ::= SEQUENCE { version(0), AlgId{Ed25519},
// privateKey OCTET STRING { CurvePrivateKey ::= OCTET STRING seed } }
out, merr = asn1.marshal(
asn1.sequence(
{
asn1.integer_raw(_DER_INT_ZERO),
asn1.sequence({asn1.object_identifier(_OID_ED25519)}),
asn1.octet_string_wrap({asn1.octet_string(key.private)}),
},
),
allocator,
)
case .RSA:
// RSAPrivateKey ::= SEQUENCE { version(0, two-prime), n, e, d, p, q,
// exponent1(dp), exponent2(dq), coefficient(iq) } (RFC 8017 A.1.2)
// PrivateKeyInfo ::= SEQUENCE { version(0), AlgId{rsaEncryption, NULL},
// privateKey OCTET STRING { RSAPrivateKey } }
out, merr = asn1.marshal(
asn1.sequence(
{
asn1.integer_raw(_DER_INT_ZERO),
asn1.sequence({asn1.object_identifier(_OID_KEY_RSA), asn1.null()}),
asn1.octet_string_wrap(
{
asn1.sequence(
{
asn1.integer_raw(_DER_INT_ZERO),
asn1.integer_unsigned(key.rsa_n),
asn1.integer_unsigned(key.rsa_e),
asn1.integer_unsigned(key.rsa_d),
asn1.integer_unsigned(key.rsa_p),
asn1.integer_unsigned(key.rsa_q),
asn1.integer_unsigned(key.rsa_dp),
asn1.integer_unsigned(key.rsa_dq),
asn1.integer_unsigned(key.rsa_iq),
},
),
},
),
},
),
allocator,
)
case .Unknown:
return nil, .Unsupported_Algorithm
}
if merr != .None {
return nil, .Allocation_Failed
}
return out, .None
}

View File

@@ -0,0 +1,536 @@
package x509
import "core:bytes"
import "core:crypto/ecdsa"
import "core:crypto/ed25519"
import "core:crypto/hash"
import "core:crypto/rsa"
import "core:net"
import "core:slice"
import "core:strings"
import "core:time"
// valid_at returns true if and only if (⟺) `now` falls within the
// certificate's validity window (inclusive on both ends, per RFC 5280
// section 4.1.2.5). Obtain `now` from e.g. time.now().
@(require_results)
valid_at :: proc "contextless" (cert: ^Certificate, now: time.Time) -> bool {
return time.diff(cert.not_before, now) >= 0 && time.diff(now, cert.not_after) >= 0
}
// verify_hostname checks `host` against the certificate's subject
// alternative names per RFC 6125:
//
// - IP-literal hosts match iPAddress SANs by byte equality only.
// - DNS hosts match dNSName SANs case-insensitively; one wildcard is
// permitted as the ENTIRE left-most label of the SAN
// ("*.example.com" matches "a.example.com" but never
// "a.b.example.com", "example.com", or partial labels like
// "f*.example.com").
// - The legacy CommonName fallback is not implemented (deprecated
// since RFC 6125).
//
// Returns .None on match, .Hostname_Mismatch when SANs of the right
// kind exist but none match, and .No_SAN when the certificate carries
// no SAN of the queried kind.
@(require_results)
verify_hostname :: proc(cert: ^Certificate, host: string) -> Error {
hostname := strings.trim_suffix(host, ".")
// IP literal?
if addr := net.parse_address(hostname); addr != nil {
if len(cert.ip_addresses) == 0 {
return .No_SAN
}
addr_bytes: [16]byte
addr_len: int
switch a in addr {
case net.IP4_Address:
tmp := a
copy(addr_bytes[:], tmp[:])
addr_len = 4
case net.IP6_Address:
tmp := transmute([16]byte)a
copy(addr_bytes[:], tmp[:])
addr_len = 16
}
for san in cert.ip_addresses {
if bytes.equal(san, addr_bytes[:addr_len]) {
return .None
}
}
return .Hostname_Mismatch
}
if len(cert.dns_names) == 0 {
return .No_SAN
}
for san in cert.dns_names {
if _match_hostname(san, hostname) {
return .None
}
}
return .Hostname_Mismatch
}
// _match_hostname implements the RFC 6125 section 6.4.3 subset described on verify_hostname.
@(private)
_match_hostname :: proc(pattern, host: string) -> bool {
p := strings.trim_suffix(pattern, ".")
if len(p) == 0 || len(host) == 0 {
return false
}
// DNS comparisons are ASCII case-insensitive (RFC 4343): dNSNames are
// IA5String and IDNs travel as punycode A-labels, so `_ascii_eq_fold`is used.
if !strings.has_prefix(p, "*.") {
return _ascii_eq_fold(p, host)
}
// Wildcard: "*." + base. The host's first label is consumed by the wildcard; the
// remainder must equal the base, and the wildcard must not swallow more than one label.
base := p[2:]
dot := strings.index_byte(host, '.')
if dot < 1 {
// No label boundary (or empty first label); a bare host can't match a wildcard.
return false
}
rest := host[dot + 1:]
if len(rest) == 0 {
return false
}
// The base itself may not contain another wildcard.
if strings.index_byte(base, '*') >= 0 {
return false
}
return _ascii_eq_fold(base, rest)
}
// ============================================================
// Signature verification and chain (path) validation.
//
// verify_signature checks that `cert`'s signature was produced by the
// private key matching `issuer`'s public key, over cert.raw_tbs (the
// signed TBSCertificate). It checks ONLY the cryptographic signature,
// not validity periods, names, basic constraints, or that `issuer` is
// actually authorized to issue `cert`; verify_chain does all of that.
//
// Returns .None on a good signature, .Signature_Invalid on a bad one, and
// .Unsupported_Algorithm when the signature algorithm (SHA-1, which is rejected
// per RFC 9155; ECDSA P-521; or RSA-PSS with an unrecognized digest) or the
// issuer key type is not implemented here.
@(require_results)
verify_signature :: proc(cert: ^Certificate, issuer: ^Certificate) -> Error {
#partial switch cert.signature_algorithm {
case .RSA_SHA1:
// SHA-1 signatures are deprecated and rejected (RFC 9155); the
// algorithm is recognized at parse time for identification only.
return .Unsupported_Algorithm
case .RSA_SHA256, .RSA_SHA384, .RSA_SHA512:
if issuer.public_key_algorithm != .RSA {
// An RSA signature paired with a non-RSA issuer key can never verify.
return .Unsupported_Algorithm
}
pub: rsa.Public_Key
if !rsa.public_key_set_bytes(&pub, issuer.rsa_n, issuer.rsa_e) {
return .Signature_Invalid
}
h := _hash_for_rsa(cert.signature_algorithm)
if !rsa.verify_pkcs1(&pub, h, cert.raw_tbs, cert.signature) {
return .Signature_Invalid
}
return .None
case .RSA_PSS:
if issuer.public_key_algorithm != .RSA {
return .Unsupported_Algorithm
}
// The parser decodes the RSASSA-PSS-params into cert.pss_*, leaving a
// digest it cannot verify as .Invalid; SHA-1 is likewise rejected
// (RFC 9155), for the message digest or the MGF.
if cert.pss_hash == .Invalid || cert.pss_mgf_hash == .Invalid {
return .Unsupported_Algorithm
}
if cert.pss_hash == .Insecure_SHA1 || cert.pss_mgf_hash == .Insecure_SHA1 {
return .Unsupported_Algorithm
}
pub: rsa.Public_Key
if !rsa.public_key_set_bytes(&pub, issuer.rsa_n, issuer.rsa_e) {
return .Signature_Invalid
}
if !rsa.verify_pss(&pub, cert.pss_hash, cert.pss_salt_len, cert.raw_tbs, cert.signature, mgf1_algo = cert.pss_mgf_hash) {
return .Signature_Invalid
}
return .None
case .ECDSA_SHA256, .ECDSA_SHA384, .ECDSA_SHA512:
curve: ecdsa.Curve
#partial switch issuer.public_key_algorithm {
case .ECDSA_P256:
curve = .SECP256R1
case .ECDSA_P384:
curve = .SECP384R1
case:
// P-521 (unsupported by the verifier) or a non-EC issuer key paired with an ECDSA signature.
return .Unsupported_Algorithm
}
h := _hash_for_ecdsa(cert.signature_algorithm)
pub: ecdsa.Public_Key
if !ecdsa.public_key_set_bytes(&pub, curve, issuer.ec_point) {
return .Signature_Invalid
}
if !ecdsa.verify_asn1(&pub, h, cert.raw_tbs, cert.signature) {
return .Signature_Invalid
}
return .None
case .Ed25519:
if issuer.public_key_algorithm != .Ed25519 {
return .Unsupported_Algorithm
}
pub: ed25519.Public_Key
if !ed25519.public_key_set_bytes(&pub, issuer.ec_point) {
return .Signature_Invalid
}
if !ed25519.verify(&pub, cert.raw_tbs, cert.signature) {
return .Signature_Invalid
}
return .None
}
return .Unsupported_Algorithm
}
@(private)
_hash_for_ecdsa :: proc "contextless" (s: Signature_Algorithm) -> hash.Algorithm {
#partial switch s {
case .ECDSA_SHA384:
return .SHA384
case .ECDSA_SHA512:
return .SHA512
}
return .SHA256
}
// _hash_for_rsa maps an RSA PKCS#1 v1.5 signature algorithm to its message
// digest. RSA_SHA1 is rejected before reaching here (see verify_signature).
@(private)
_hash_for_rsa :: proc "contextless" (s: Signature_Algorithm) -> hash.Algorithm {
#partial switch s {
case .RSA_SHA384:
return .SHA384
case .RSA_SHA512:
return .SHA512
}
return .SHA256
}
// Verify_Options parameterizes verify_chain.
Verify_Options :: struct {
// Trust anchors. A chain is accepted iff it terminates at one of
// these (matched by name + signature, as ordinary issuers). Usually
// self-signed roots, but any certificate trusted a priori works.
roots: []^Certificate,
// Untrusted intermediates available to bridge the leaf to a root.
// Order does not matter; verify_chain searches them.
intermediates: []^Certificate,
// Reference time for every certificate's validity window.
current_time: time.Time,
// If non-empty, the leaf must pass verify_hostname for this name.
dns_name: string,
// If set, the leaf's ExtKeyUsage must permit this purpose (a leaf
// with no EKU extension is unrestricted and always passes). TLS
// clients pass .Server_Auth.
required_eku: Maybe(EKU_Bit),
}
// _MAX_CHAIN_DEPTH bounds path search depth, to stop cycles among
// mutually-issuing intermediates.
@(private)
_MAX_CHAIN_DEPTH :: 10
// _MAX_SIG_CHECKS caps the total number of signature verifications a
// single verify_chain may perform across its entire path search.
// Guard for path-building denial of service, RFC 4158 section 2.4.2.
@(private)
_MAX_SIG_CHECKS :: 100
// verify_chain builds and validates a certificate path from `leaf` to
// one of opts.roots, using opts.intermediates to bridge the gap. On
// success it returns the verified chain leaf-first (chain[0] == leaf,
// chain[len-1] is the trust anchor); the slice is allocated & freed.
//
// Each non-anchor certificate in the path is checked for:
// - validity at opts.current_time;
// - Signature verifies against the next certificate's key;
// - Name chaining (issuer DN == subject DN);
// For every intermediate issuer:
// - It is a CA (basicConstraints);
// - Is within pathLenConstraint (counting only non-self-issued
// intermediates, per RFC 5280 section 6.1.4);
// - if it declares KeyUsage: permits keyCertSign.
// A certificate carrying a critical extension this package does not
// interpret fails the path closed (see .Unhandled_Critical_Extension).
// When opts.dns_name is set the leaf must pass verify_hostname; when
// opts.required_eku is set the leaf AND every intermediate must permit
// that purpose (e.g. an email-only sub-CA cannot issue a TLS server leaf).
//
// The trust anchor is treated as trusted input: it must be valid at
// opts.current_time and is name-chained + signature-checked as the issuer
// below it, but its CA authorization (basicConstraints / keyCertSign /
// pathLenConstraint) and its own self-signature are NOT re-checked.
// An expired anchor is still rejected; resilience to that comes
// from the search trying every other available anchor and intermediate.
//
@(require_results)
verify_chain :: proc(
leaf: ^Certificate,
opts: Verify_Options,
allocator := context.allocator,
) -> (
chain: []^Certificate,
err: Error,
) {
// Leaf-definitive checks
if leaf.unhandled_critical {
return nil, .Unhandled_Critical_Extension
}
if verr := _check_validity(leaf, opts.current_time); verr != .None {
return nil, verr
}
// A leaf signed with an algorithm the verifier cannot check (RSA-PSS with
// an unrecognized digest, ECDSA P-521) surfaces via `saw_unsupported` from
// the per-edge verify_signature call, so an unbuildable path still returns
// .Unsupported_Algorithm.
if opts.dns_name != "" {
if herr := verify_hostname(leaf, opts.dns_name); herr != .None {
return nil, herr
}
}
// Reserve the whole path up front: with capacity in hand, the appends below should never
// reallocate, so single point of OOM risk
acc, aerr := make([dynamic]^Certificate, 0, _MAX_CHAIN_DEPTH + 1, allocator)
if aerr != nil {
return nil, .Allocation_Failed
}
append(&acc, leaf)
saw_unsupported := false
saw_eku_reject := false
opts_local := opts
budget := _MAX_SIG_CHECKS
if _build_to_anchor(leaf, &opts_local, &acc, 0, &saw_unsupported, &saw_eku_reject, &budget) {
// Success: caller owns the returned chain.
// EKU nesting (leaf + every intermediate permits opts.required_eku)
// was enforced as a usability gate during the search itself, so any
// path returned here already satisfies it.
return acc[:], .None
}
delete(acc)
// An unimplemented signature algorithm is a hard capability gap and
// outranks the EKU policy mismatch; both outrank the generic failure.
switch {
case saw_unsupported:
return nil, .Unsupported_Algorithm
case saw_eku_reject:
return nil, .Incompatible_Usage
case:
return nil, .Unknown_Authority
}
}
// Performs a depth-first search for a path from `cert` up to a trust anchor.
// `acc` holds the chain so far, leaf-first and including `cert`; on success
// the matched issuers (ending at the anchor) are appended. `depth` is the
// recursion depth and len(acc) - 1 is the number of intermediates already
// below the next issuer (used for pathLenConstraint). `budget` is the shared
// remaining signature-verification allowance (see _MAX_SIG_CHECKS); when it is
// exhausted the search stops. `saw_unsupported` / `saw_eku_reject` are
// set when a branch was abandoned only because the signature algorithm
// was unimplemented, or because a cert failed the required-EKU check, so
// verify_chain can report the more specific error when no path is found.
@(private)
_build_to_anchor :: proc(
cert: ^Certificate,
opts: ^Verify_Options,
acc: ^[dynamic]^Certificate,
depth: int,
saw_unsupported: ^bool,
saw_eku_reject: ^bool,
budget: ^int,
) -> (found: bool) {
if depth >= _MAX_CHAIN_DEPTH {
return false
}
// EKU nesting: when the caller requires a purpose, the leaf and every
// intermediate must permit it. `cert` here is always the leaf (depth 0)
// or an intermediate being extended through, so anchors stay exempt.
// Enforcing EKU as a usability gate lets the search backtrack and try an
// alternative issuer that does permit the purpose (two same-subject/same-key
// intermediates can differ in EKU); a post-build filter would instead commit
// to whichever path was found first and reject the whole verification.
if eku_ask, ok := opts.required_eku.?; ok {
if !_permits_eku(cert, eku_ask) {
saw_eku_reject^ = true
return false
}
}
// Non-self-issued intermediates already beneath the next issuer, for the pathLenConstraint check (RFC 5280 section 6.1.4)
below := _non_self_issued_below(acc)
// Prefer terminating at a trust anchor. The anchor must have issued `cert`
// (name chaining + signature) and be valid at `now`, but as TRUSTED INPUT
// its CA authorization (basicConstraints / keyCertSign / pathLenConstraint)
// and its own self-signature are NOT re-checked. See _anchor_usable.
for root in opts.roots {
if !_is_issuer_of(root, cert) || !_anchor_usable(root, opts.current_time) {
continue
}
if budget^ <= 0 {
return false
}
budget^ -= 1
#partial switch verify_signature(cert, root) {
case .None:
append(acc, root)
// 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:
// bad signature: not this anchor
}
}
// Otherwise extend through an untrusted intermediate and recurse.
for inter in opts.intermediates {
if inter == cert || slice.contains(acc[:], inter) {
continue
}
if !_is_issuer_of(inter, cert) || !_issuer_usable(inter, opts.current_time, below) {
continue
}
if budget^ <= 0 {
return false
}
budget^ -= 1
#partial switch verify_signature(cert, inter) {
case .None:
append(acc, inter)
if _build_to_anchor(inter, opts, acc, depth + 1, saw_unsupported, saw_eku_reject, budget) {
return true
}
pop(acc) // backtrack
case .Unsupported_Algorithm:
saw_unsupported^ = true
case:
// bad signature: not this issuer
}
}
return false
}
@(private)
_check_validity :: proc "contextless" (cert: ^Certificate, now: time.Time) -> Error {
if time.diff(now, cert.not_before) > 0 {
return .Not_Yet_Valid
}
if time.diff(cert.not_after, now) > 0 {
return .Expired
}
return .None
}
// Reports whether `issuer` could have issued `cert`: the issuer's
// subject DN must equal cert's issuer DN (RFC 5280 section 6.1
// name chaining, by binary DER comparison). The authority/subject key
// identifiers are NOT used as a filter, RFC 5280 section 4.2.1.1
// (and RFC 4158) make them a non-authoritative path-building hint.
@(private)
_is_issuer_of :: proc(issuer, cert: ^Certificate) -> bool {
return bytes.equal(issuer.raw_subject, cert.raw_issuer)
}
// Counts the non-self-issued intermediates already in the path below
// the next issuer, i.e. everything in `acc` except the leaf (index 0).
// A self-issued certificate (subject DN == issuer DN, used for CA key
// rollover) does not count against pathLenConstraint (RFC 5280 sections
// 4.2.1.9 and 6.1.4(l)).
@(private)
_non_self_issued_below :: proc(acc: ^[dynamic]^Certificate) -> int {
n := 0
// Skip the leaf at index 0; it is the end-entity, not an intermediate.
for i in 1 ..< len(acc) {
c := acc[i]
if !bytes.equal(c.raw_subject, c.raw_issuer) {
n += 1
}
}
return n
}
// Applies the RFC 5280 section 6.1.4 checks to an INTERMEDIATE issuer:
// a CA with (if asserted) keyCertSign, valid at `now`, within its
// pathLenConstraint given `below` non-self-issued intermediates beneath
// it, and with no uninterpreted critical extension.
@(private)
_issuer_usable :: proc(issuer: ^Certificate, now: time.Time, below: int) -> bool {
if issuer.unhandled_critical {
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
}
if issuer.has_key_usage && .Key_Cert_Sign not_in issuer.key_usage {
return false
}
if issuer.max_path_len >= 0 && below > issuer.max_path_len {
return false
}
if _check_validity(issuer, now) != .None {
return false
}
return true
}
// 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) 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 _check_validity(anchor, now) != .None {
return false
}
return true
}
// Reports whether `cert` allows the given Extended Key Usage purpose:
// no EKU extension means unrestricted, anyExtendedKeyUsage
// permits everything, otherwise the purpose must be listed. Applied to
// the leaf and every intermediate for EKU nesting (see verify_chain).
@(private)
_permits_eku :: proc(cert: ^Certificate, ask: EKU_Bit) -> bool {
if !cert.has_ext_key_usage {
return true // no EKU extension: unrestricted
}
if .Any in cert.ext_key_usage {
return true
}
return ask in cert.ext_key_usage
}

247
core/crypto/x509/x509.odin Normal file
View File

@@ -0,0 +1,247 @@
package x509
import "core:crypto/hash"
import "core:time"
Error :: enum {
None,
Malformed, // DER-level violation, structural mismatch, or trailing garbage.
Unsupported_Version, // Certificate version beyond v3.
Invalid_Validity, // notBefore/notAfter missing or unparseable.
Invalid_Extension, // A recognized extension's content didn't match its schema.
Duplicate_Extension, // The same extension OID appeared more than once (RFC 5280 section 4.2 forbids this).
Hostname_Mismatch, // Hostname verification: no SAN matched.
No_SAN, // Hostname verification: the certificate has no usable SANs of the queried kind.
Allocation_Failed, // Allocating the extension/SAN tables failed.
// --- verification (verify_signature / verify_chain) ---
Signature_Invalid, // A signature did not verify against the issuer's public key.
Unsupported_Algorithm, // The signature or public-key algorithm is recognized but not implemented here.
Not_Yet_Valid, // A certificate's notBefore is in the future relative to the supplied time.
Expired, // A certificate's notAfter is in the past relative to the supplied time.
Unknown_Authority, // (no issuer found, failed name chaining, validity, CA constraints, or signature verification).
Unhandled_Critical_Extension, // Failed to handle a critical extension, automatic rejection
Incompatible_Usage, // Lacks EKU, or EKU not authorized
}
// Signature_Algorithm covers the PKIX signature algorithms a client
// encounters in practice. RSASSA-PSS parameters are decoded into the
// Certificate's pss_* fields.
Signature_Algorithm :: enum {
Unknown,
RSA_SHA1, // obsolete; parsed for identification only
RSA_SHA256,
RSA_SHA384,
RSA_SHA512,
RSA_PSS,
ECDSA_SHA256,
ECDSA_SHA384,
ECDSA_SHA512,
Ed25519,
}
// Public_Key_Algorithm identifies the certificate's subject public key
// type. Unknown covers key algorithms (or EC curves) this package does
// not decode; the SubjectPublicKeyInfo bytes remain available in
// raw_spki.
Public_Key_Algorithm :: enum {
Unknown,
RSA,
ECDSA_P256,
ECDSA_P384,
ECDSA_P521,
Ed25519,
}
// Key_Usage bits per RFC 5280 section 4.2.1.3
Key_Usage_Bit :: enum u16 {
Digital_Signature = 0,
Content_Commitment = 1,
Key_Encipherment = 2,
Data_Encipherment = 3,
Key_Agreement = 4,
Key_Cert_Sign = 5,
CRL_Sign = 6,
Encipher_Only = 7,
Decipher_Only = 8,
}
// Key_Usage is the decoded KeyUsage extension bit set.
Key_Usage :: bit_set[Key_Usage_Bit;u16]
// Extended key usage purposes (RFC 5280 section 4.2.1.12) recognized by name;
// unrecognized purposes set `eku_has_unknown`.
EKU_Bit :: enum u8 {
Server_Auth,
Client_Auth,
Code_Signing,
Email_Protection,
Time_Stamping,
OCSP_Signing,
Any,
}
// Ext_Key_Usage is the decoded set of recognized ExtKeyUsage purposes;
// unrecognized purposes set Certificate.eku_has_unknown.
Ext_Key_Usage :: bit_set[EKU_Bit;u8]
// Extension is one raw entry from the TBS extensions list. `oid` is
// the OID content octets; `value` the extnValue OCTET STRING content.
Extension :: struct {
oid: []byte,
critical: bool,
value: []byte,
}
// Certificate is a parsed X.509 v3 certificate.
// Byte-slice fields are views into the input DER (which must outlive the Certificate)
// The dns_names / ip_addresses / extensions slices are *allocated* (their elements still view the DER) and released by destroy.
Certificate :: struct {
// Raw views into the input DER.
raw: []byte, // the whole Certificate element
raw_tbs: []byte, // TBSCertificate, header included, the signed bytes
raw_issuer: []byte, // issuer Name element (RFC 5280 binary comparison)
raw_subject: []byte, // subject Name element
raw_spki: []byte, // SubjectPublicKeyInfo element; hash for tls-server-end-point / SPKI pinning
version: int, // 1, 2, or 3
// Certificate serial number as the raw DER INTEGER content (minimal two's-complement).
// It is an opaque identifier, compare and display by these bytes. A positive serial whose top
// bit is set carries a leading 0x00 sign octet; a serial of 0 is the single octet {0x00}. RFC 5280 requires
// serials to be positive and <= 20 octets, but non-conformant (negative, zero, or over-long) serials are preserved
serial: []byte,
signature_algorithm: Signature_Algorithm,
signature_oid: []byte, // OID content octets
signature: []byte, // signatureValue payload (whole octets)
// Validity bounds. time.Time is i64 nanoseconds and tops out near year 2262; X.509 dates beyond that (notably the RFC 5280
// "99991231235959Z" no-expiration sentinel) saturate to that bound at parse time, so they compare as "effectively never expires"
// rather than overflowing. See asn1's _time_from_unix.
not_before: time.Time,
not_after: time.Time,
public_key_algorithm: Public_Key_Algorithm,
// RSA: modulus and exponent magnitudes.
rsa_n: []byte,
rsa_e: []byte,
// ECDSA: the uncompressed point (0x04 || X || Y); Ed25519: the 32-byte key.
ec_point: []byte,
// RSASSA-PSS parameters, decoded from the signatureAlgorithm's
// RSASSA-PSS-params (meaningful only when signature_algorithm ==
// .RSA_PSS; RFC 4055 defaults applied for omitted fields). pss_hash and
// pss_mgf_hash are hash.Algorithm.Invalid when the certificate names a
// digest this package cannot verify, which verify_signature reports as
// .Unsupported_Algorithm rather than a failure.
pss_hash: hash.Algorithm,
pss_mgf_hash: hash.Algorithm,
pss_salt_len: int,
// BasicConstraints (basic_constraints_valid reports presence).
basic_constraints_valid: bool,
is_ca: bool,
max_path_len: int, // -1 when absent
has_key_usage: bool,
key_usage: Key_Usage,
has_ext_key_usage: bool,
ext_key_usage: Ext_Key_Usage,
eku_has_unknown: bool,
subject_key_id: []byte,
authority_key_id: []byte,
dns_names: []string, // ALLOCATED
ip_addresses: [][]byte, // ALLOCATED
// Every extension, in order, including ones this package does not interpret.
extensions: []Extension, // ALLOCATED
// True if a critical extension other than the ones interpreted
// here was present. RFC 5280 requires a relying party to reject
// such a certificate at validation time; parsing still succeeds so
// the caller can inspect.
//
// The specific unhandled OIDs are recoverable by walking `extensions`
// for entries with `critical = true` whose OID is none of the handled
// ones (_OID_EXT_*).
unhandled_critical: bool,
}
destroy :: proc(cert: ^Certificate, allocator := context.allocator) {
delete(cert.dns_names, allocator)
delete(cert.ip_addresses, allocator)
delete(cert.extensions, allocator)
cert^ = {}
}
// PKIX object identifiers as DER content octets, for direct comparison against asn1.read_oid results.
@(rodata, private)
_OID_SIG_RSA_SHA1 := []byte{0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x05} // sha1WithRSAEncryption (1.2.840.113549.1.1.5)
@(rodata, private)
_OID_SIG_RSA_SHA256 := []byte{0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0B} // sha256WithRSAEncryption (1.2.840.113549.1.1.11)
@(rodata, private)
_OID_SIG_RSA_SHA384 := []byte{0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0C} // sha384WithRSAEncryption (1.2.840.113549.1.1.12)
@(rodata, private)
_OID_SIG_RSA_SHA512 := []byte{0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0D} // sha512WithRSAEncryption (1.2.840.113549.1.1.13)
@(rodata, private)
_OID_SIG_RSA_PSS := []byte{0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0A} // id-RSASSA-PSS (1.2.840.113549.1.1.10)
@(rodata, private)
_OID_SIG_ECDSA_SHA256 := []byte{0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04, 0x03, 0x02} // ecdsa-with-SHA256 (1.2.840.10045.4.3.2)
@(rodata, private)
_OID_SIG_ECDSA_SHA384 := []byte{0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04, 0x03, 0x03} // ecdsa-with-SHA384 (1.2.840.10045.4.3.3)
@(rodata, private)
_OID_SIG_ECDSA_SHA512 := []byte{0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04, 0x03, 0x04} // ecdsa-with-SHA512 (1.2.840.10045.4.3.4)
@(rodata, private)
_OID_ED25519 := []byte{0x2B, 0x65, 0x70} // id-Ed25519 (1.3.101.112), RFC 8410
@(rodata, private)
_OID_EXT_REQUEST := []byte{0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x09, 0x0E} // id-extensionRequest (1.2.840.113549.1.9.14), PKCS#9
// Bare hash OIDs (content octets), for the RSASSA-PSS AlgorithmIdentifiers.
@(rodata, private)
_OID_HASH_SHA1 := []byte{0x2B, 0x0E, 0x03, 0x02, 0x1A} // id-sha1 (1.3.14.3.2.26)
@(rodata, private)
_OID_HASH_SHA256 := []byte{0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01} // id-sha256 (2.16.840.1.101.3.4.2.1)
@(rodata, private)
_OID_HASH_SHA384 := []byte{0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x02} // id-sha384 (2.16.840.1.101.3.4.2.2)
@(rodata, private)
_OID_HASH_SHA512 := []byte{0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x03} // id-sha512 (2.16.840.1.101.3.4.2.3)
@(rodata, private)
_OID_MGF1 := []byte{0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x08} // id-mgf1 (1.2.840.113549.1.1.8), RFC 4055
@(rodata, private)
_OID_KEY_RSA := []byte{0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01} // rsaEncryption (1.2.840.113549.1.1.1)
@(rodata, private)
_OID_KEY_EC := []byte{0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01} // id-ecPublicKey (1.2.840.10045.2.1)
@(rodata, private)
_OID_CURVE_P256 := []byte{0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07} // secp256r1 (1.2.840.10045.3.1.7)
@(rodata, private)
_OID_CURVE_P384 := []byte{0x2B, 0x81, 0x04, 0x00, 0x22} // secp384r1 (1.3.132.0.34)
@(rodata, private)
_OID_CURVE_P521 := []byte{0x2B, 0x81, 0x04, 0x00, 0x23} // secp521r1 (1.3.132.0.35)
@(rodata, private)
_OID_EXT_SUBJECT_KEY_ID := []byte{0x55, 0x1D, 0x0E} // id-ce-subjectKeyIdentifier (2.5.29.14)
@(rodata, private)
_OID_EXT_KEY_USAGE := []byte{0x55, 0x1D, 0x0F} // id-ce-keyUsage (2.5.29.15)
@(rodata, private)
_OID_EXT_SAN := []byte{0x55, 0x1D, 0x11} // id-ce-subjectAltName (2.5.29.17)
@(rodata, private)
_OID_EXT_BASIC_CONSTRAINTS := []byte{0x55, 0x1D, 0x13} // id-ce-basicConstraints (2.5.29.19)
@(rodata, private)
_OID_EXT_NAME_CONSTRAINTS := []byte{0x55, 0x1D, 0x1E} // id-ce-nameConstraints (2.5.29.30)
@(rodata, private)
_OID_EXT_AUTHORITY_KEY_ID := []byte{0x55, 0x1D, 0x23} // id-ce-authorityKeyIdentifier (2.5.29.35)
@(rodata, private)
_OID_EXT_EXT_KEY_USAGE := []byte{0x55, 0x1D, 0x25} // id-ce-extKeyUsage (2.5.29.37)
@(rodata, private)
_OID_EKU_ANY := []byte{0x55, 0x1D, 0x25, 0x00} // anyExtendedKeyUsage (2.5.29.37.0)
@(rodata, private)
_OID_EKU_SERVER_AUTH := []byte{0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x01} // id-kp-serverAuth (1.3.6.1.5.5.7.3.1)
@(rodata, private)
_OID_EKU_CLIENT_AUTH := []byte{0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x02} // id-kp-clientAuth (1.3.6.1.5.5.7.3.2)
@(rodata, private)
_OID_EKU_CODE_SIGNING := []byte{0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x03} // id-kp-codeSigning (1.3.6.1.5.5.7.3.3)
@(rodata, private)
_OID_EKU_EMAIL_PROTECTION := []byte{0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x04} // id-kp-emailProtection (1.3.6.1.5.5.7.3.4)
@(rodata, private)
_OID_EKU_TIME_STAMPING := []byte{0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x08} // id-kp-timeStamping (1.3.6.1.5.5.7.3.8)
@(rodata, private)
_OID_EKU_OCSP_SIGNING := []byte{0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x09} // id-kp-OCSPSigning (1.3.6.1.5.5.7.3.9)

View File

@@ -0,0 +1,72 @@
package asn1
// Tag class from the identifier octet (X.690 section 8.1.2.2).
Class :: enum u8 {
Universal = 0,
Application = 1,
Context_Specific = 2,
Private = 3,
}
// Tag_Number enumerates the UNIVERSAL tag numbers relevant to DER as
// used by PKIX. Context-specific tags carry their number directly in
// Tag.number.
Tag_Number :: enum u32 {
Boolean = 1,
Integer = 2,
Bit_String = 3,
Octet_String = 4,
Null = 5,
Object_Identifier = 6,
Enumerated = 10,
UTF8_String = 12,
Sequence = 16,
Set = 17,
Numeric_String = 18,
Printable_String = 19,
Teletex_String = 20,
IA5_String = 22,
UTC_Time = 23,
Generalized_Time = 24,
Visible_String = 26,
BMP_String = 30,
}
// Tag is a decoded identifier octet (plus high-tag-number form).
Tag :: struct {
class: Class,
constructed: bool,
number: u32,
}
Error :: enum {
None,
Truncated, // The element (or its header) extends past the end of the input.
Invalid_Tag, // Malformed identifier octets (non-minimal high-tag-number form, or a tag number that overflows u32).
Invalid_Length, // Indefinite length, non-minimal length encoding, or a length beyond what this implementation supports.
Unexpected_Tag, // An expect_*/read_* procedure found a different tag than required.
Invalid_Boolean, // BOOLEAN content was not exactly one octet of 0x00 or 0xFF.
Invalid_Integer, // INTEGER content was empty or not minimally encoded.
Integer_Overflow, // INTEGER does not fit the requested machine type.
Negative_Integer, // INTEGER was negative where an unsigned value is required.
Invalid_Bit_String, // BIT STRING content violated X.690 sections 8.6/11.2 (bad unused-bit count, non-zero padding bits, or unused bits where none are permitted).
Invalid_Null, // NULL with non-empty content.
Invalid_Object_Identifier, // OBJECT IDENTIFIER content was empty or not minimally encoded.
Invalid_Time, // UTCTime/GeneralizedTime outside the RFC 5280 DER profile (YYMMDDHHMMSSZ / YYYYMMDDHHMMSSZ, Zulu only, seconds present, no fractional seconds), or an impossible date.
Leftover_Bytes, // done() was called with input remaining.
// An OBJECT IDENTIFIER arc exceeds u64. Arc magnitude is unbounded per X.660 (e.g. 2.25 UUID-derived OIDs carry a 128-bit arc), so
// this is a representation limit of oid_components/oid_to_string, not a malformed input; compare such OIDs by their raw bytes.
Arc_Overflow,
Allocation_Failed, // OOM
Buffer_Too_Small, // encode: the destination slice is shorter than encoded_len.
}
// universal builds a UNIVERSAL-class tag.
universal :: proc "contextless" (number: Tag_Number, constructed := false) -> Tag {
return Tag{class = .Universal, constructed = constructed, number = u32(number)}
}
// context_specific builds a CONTEXT-SPECIFIC-class tag ("[n]").
context_specific :: proc "contextless" (number: u32, constructed := true) -> Tag {
return Tag{class = .Context_Specific, constructed = constructed, number = number}
}

View File

@@ -0,0 +1,619 @@
package asn1
import "core:strings"
import dt "core:time"
// Cursor is a position into DER input, advanced by the read_* procs.
Cursor :: struct {
data: []byte,
pos: int,
}
// Points a Cursor at `data` and rewinds it to the start.
cursor_init :: proc "contextless" (r: ^Cursor, data: []byte) {
r.data = data
r.pos = 0
}
// Returns the number of unconsumed bytes.
remaining :: proc "contextless" (r: ^Cursor) -> int {
return len(r.data) - r.pos
}
// Reports whether the Cursor has been fully consumed.
is_empty :: proc "contextless" (r: ^Cursor) -> bool {
return r.pos >= len(r.data)
}
// Returns Leftover_Bytes if input remains. DER structures are exact: every SEQUENCE walk should end with done() on its sub-cursor.
done :: proc "contextless" (r: ^Cursor) -> Error {
if r.pos < len(r.data) {
return .Leftover_Bytes
}
return .None
}
// Reads one complete TLV element of any tag, returning the tag and a view of the content octets.
read_any :: proc "contextless" (r: ^Cursor) -> (tag: Tag, content: []byte, err: Error) {
tag, err = _read_tag(r)
if err != .None {
return
}
length: int
length, err = _read_length(r)
if err != .None {
return
}
if length > remaining(r) {
err = .Truncated
return
}
content = r.data[r.pos:r.pos + length]
r.pos += length
return
}
// Decodes the next element's tag without consuming anything.
peek_tag :: proc "contextless" (r: ^Cursor) -> (tag: Tag, err: Error) {
tmp := r^
return _read_tag(&tmp)
}
// Consumes one complete element of any tag.
skip :: proc "contextless" (r: ^Cursor) -> Error {
_, _, err := read_any(r)
return err
}
// Reads one element and requires its tag to match exactly.
expect :: proc "contextless" (r: ^Cursor, tag: Tag) -> (content: []byte, err: Error) {
got: Tag
got, content, err = read_any(r)
if err != .None {
return
}
if got != tag {
err = .Unexpected_Tag
}
return
}
// Enters a SEQUENCE, returning a sub-cursor over its content.
read_sequence :: proc "contextless" (r: ^Cursor) -> (seq: Cursor, err: Error) {
content, eerr := expect(r, universal(.Sequence, true))
if eerr != .None {
return {}, eerr
}
return Cursor{data = content}, .None
}
// Enters a SET, returning a sub-cursor over its content.
// NOTE: DER requires SET OF contents to be sorted; this cursor does
// not verify ordering, consumers that care (none in PKIX cert parsing) must check.
read_set :: proc "contextless" (r: ^Cursor) -> (set: Cursor, err: Error) {
content, eerr := expect(r, universal(.Set, true))
if eerr != .None {
return {}, eerr
}
return Cursor{data = content}, .None
}
// Handles `[number] EXPLICIT ... OPTIONAL`: if the next element is the given
// constructed context-specific tag, it is consumed and a sub-cursor over its
// content returned with present=true. Otherwise nothing is consumed.
read_explicit :: proc "contextless" (r: ^Cursor, number: u32) -> (inner: Cursor, present: bool, err: Error) {
if is_empty(r) {
return {}, false, .None
}
tag, perr := peek_tag(r)
if perr != .None {
return {}, false, perr
}
if tag != context_specific(number, true) {
return {}, false, .None
}
content, eerr := expect(r, tag)
if eerr != .None {
return {}, false, eerr
}
return Cursor{data = content}, true, .None
}
// Reads a BOOLEAN. DER: exactly one octet, 0x00 or 0xFF.
read_boolean :: proc "contextless" (r: ^Cursor) -> (value: bool, err: Error) {
content, eerr := expect(r, universal(.Boolean))
if eerr != .None {
return false, eerr
}
if len(content) != 1 {
return false, .Invalid_Boolean
}
switch content[0] {
case 0x00:
return false, .None
case 0xFF:
return true, .None
}
return false, .Invalid_Boolean
}
// Reads an INTEGER and returns the validated, minimally-encoded two's-complement content octets.
read_integer_bytes :: proc "contextless" (r: ^Cursor) -> (content: []byte, err: Error) {
content, err = expect(r, universal(.Integer))
if err != .None {
return
}
err = _check_integer(content)
return
}
// Reads an INTEGER that must fit in an i64.
read_i64 :: proc "contextless" (r: ^Cursor) -> (value: i64, err: Error) {
content, ierr := read_integer_bytes(r)
if ierr != .None {
return 0, ierr
}
if len(content) > 8 {
return 0, .Integer_Overflow
}
if content[0] & 0x80 != 0 {
value = -1 // sign-extend
}
for b in content {
value = value << 8 | i64(b)
}
return value, .None
}
// Reads a non-negative INTEGER and returns its magnitude octets with any leading 0x00 sign
// octet stripped.
read_unsigned_integer_bytes :: proc "contextless" (r: ^Cursor) -> (magnitude: []byte, err: Error) {
content, ierr := read_integer_bytes(r)
if ierr != .None {
return nil, ierr
}
if content[0] & 0x80 != 0 {
return nil, .Negative_Integer
}
if len(content) > 1 && content[0] == 0x00 {
content = content[1:]
}
return content, .None
}
// Reads a BIT STRING, returning the payload octets and the count of unused trailing bits
// in the final octet. DER: primitive form only, unused count 0..7 (0 if the payload is
// empty), and the unused bits themselves must be zero.
read_bit_string :: proc "contextless" (r: ^Cursor) -> (bits: []byte, unused: int, err: Error) {
content, eerr := expect(r, universal(.Bit_String))
if eerr != .None {
return nil, 0, eerr
}
if len(content) < 1 {
return nil, 0, .Invalid_Bit_String
}
unused = int(content[0])
bits = content[1:]
if unused > 7 {
return nil, 0, .Invalid_Bit_String
}
if len(bits) == 0 && unused != 0 {
return nil, 0, .Invalid_Bit_String
}
if unused > 0 {
mask := byte(1 << uint(unused)) - 1
if bits[len(bits) - 1] & mask != 0 {
return nil, 0, .Invalid_Bit_String
}
}
return bits, unused, .None
}
// Reads a BIT STRING that must be a whole number of octets (unused == 0).
read_bit_string_octets :: proc "contextless" (r: ^Cursor) -> (octets: []byte, err: Error) {
bits, unused, berr := read_bit_string(r)
if berr != .None {
return nil, berr
}
if unused != 0 {
return nil, .Invalid_Bit_String
}
return bits, .None
}
// Reads an OCTET STRING (primitive form only).
read_octet_string :: proc "contextless" (r: ^Cursor) -> (octets: []byte, err: Error) {
return expect(r, universal(.Octet_String))
}
// Reads a NULL (content must be empty).
read_null :: proc "contextless" (r: ^Cursor) -> Error {
content, err := expect(r, universal(.Null))
if err != .None {
return err
}
if len(content) != 0 {
return .Invalid_Null
}
return .None
}
// Reads an OBJECT IDENTIFIER and returns a view of its content octets,
// validated for minimal base-128 encoding. The validation is structural
// only: arc magnitude is unbounded per X.660, so PKIX consumers should
// compare these bytes directly against known-OID constants.
// oid_components/oid_to_string decode arcs when needed, reporting
// Arc_Overflow for arcs beyond u64.
read_oid :: proc "contextless" (r: ^Cursor) -> (raw: []byte, err: Error) {
raw, err = expect(r, universal(.Object_Identifier))
if err != .None {
return
}
if len(raw) == 0 {
return nil, .Invalid_Object_Identifier
}
// Validate: each subidentifier is base-128 with minimal encoding
// (no 0x80 lead octet) and terminates (last octet has bit 8 clear).
expect_start := true
for b in raw {
if expect_start && b == 0x80 {
return nil, .Invalid_Object_Identifier
}
expect_start = b & 0x80 == 0
}
if !expect_start {
return nil, .Invalid_Object_Identifier
}
return raw, .None
}
// Times are returned as core:time.Time. time.Time is i64 nanoseconds
// and so tops out near year 2262, while UTCTime/GeneralizedTime reach
// year 9999; dates beyond what time.Time can hold (notably RFC 5280's
// "99991231235959Z" no-well-defined-expiration sentinel) saturate to
// time.Time's bound rather than erroring, so a far-future cert still
// parses and reads as "effectively never expires". See _time_from_unix.
// read_utc_time reads a UTCTime in the RFC 5280 DER profile:
// "YYMMDDHHMMSSZ", with the sliding century window (00-49 → 20xx,
// 50-99 → 19xx).
read_utc_time :: proc "contextless" (r: ^Cursor) -> (value: dt.Time, err: Error) {
content, eerr := expect(r, universal(.UTC_Time))
if eerr != .None {
return {}, eerr
}
if len(content) != 13 || content[12] != 'Z' {
return {}, .Invalid_Time
}
yy, ok := _two_digits(content[0:2])
if !ok {
return {}, .Invalid_Time
}
year := 2000 + yy
if yy >= 50 {
year = 1900 + yy
}
secs := _unix_from_fields(year, content[2:12]) or_return
return _time_from_unix(secs), .None
}
// Reads a GeneralizedTime in the RFC 5280 DER profile: "YYYYMMDDHHMMSSZ", Zulu only, no fractional seconds.
read_generalized_time :: proc "contextless" (r: ^Cursor) -> (value: dt.Time, err: Error) {
content, eerr := expect(r, universal(.Generalized_Time))
if eerr != .None {
return {}, eerr
}
if len(content) != 15 || content[14] != 'Z' {
return {}, .Invalid_Time
}
hi, ok1 := _two_digits(content[0:2])
lo, ok2 := _two_digits(content[2:4])
if !ok1 || !ok2 {
return {}, .Invalid_Time
}
secs := _unix_from_fields(hi * 100 + lo, content[4:14]) or_return
return _time_from_unix(secs), .None
}
// Reads either time form, PKIX Validity uses UTCTime for dates through 2049 and GeneralizedTime from 2050 on.
read_time :: proc "contextless" (r: ^Cursor) -> (value: dt.Time, err: Error) {
tag, perr := peek_tag(r)
if perr != .None {
return {}, perr
}
if tag == universal(.Generalized_Time) {
return read_generalized_time(r)
}
return read_utc_time(r)
}
// OBJECT IDENTIFIER helpers (allocating).
// Decodes validated OID content octets (from read_oid) into their integer arcs,
// e.g. {1, 2, 840, 113549, 1, 1, 1}. Arcs beyond u64 (legal per X.660, see
// Arc_Overflow) are not representable; compare such OIDs by their raw bytes instead.
oid_components :: proc(raw: []byte, allocator := context.allocator) -> (arcs: []u64, err: Error) {
if len(raw) == 0 {
return nil, .Invalid_Object_Identifier
}
count := 1 // the first octet encodes two arcs
for b in raw {
if b & 0x80 == 0 {
count += 1
}
}
out, merr := make([]u64, count, allocator)
if merr != nil {
return nil, .Allocation_Failed
}
idx := 0
acc: u64 = 0
first := true
for b in raw {
if acc > max(u64) >> 7 {
delete(out, allocator)
return nil, .Arc_Overflow
}
acc = acc << 7 | u64(b & 0x7F)
if b & 0x80 != 0 {
continue
}
if first {
// X.690 section 8.19.4: the first subidentifier encodes the first
// two arcs as arc1*40 + arc2 (arc1 limited to 0..2; arc2
// unbounded only when arc1 == 2).
switch {
case acc < 40:
out[idx] = 0
out[idx + 1] = acc
case acc < 80:
out[idx] = 1
out[idx + 1] = acc - 40
case:
out[idx] = 2
out[idx + 1] = acc - 80
}
idx += 2
first = false
} else {
out[idx] = acc
idx += 1
}
acc = 0
}
return out, .None
}
// Renders OID content octets in dotted-decimal form ("1.2.840.113549.1.1.1") for diagnostics.
// The arcs are streamed directly into the result; the only allocation is the returned string.
oid_to_string :: proc(raw: []byte, allocator := context.allocator) -> (str: string, err: Error) {
if len(raw) == 0 {
return "", .Invalid_Object_Identifier
}
sb: strings.Builder
if _, berr := strings.builder_init(&sb, allocator); berr != nil {
return "", .Allocation_Failed
}
defer if err != .None {
strings.builder_destroy(&sb)
}
// Builder writes swallow allocator failures, so tally the written vs expected lengths
// and treat any shortfall as an allocation failure.
written, expected := 0, 0
acc: u64 = 0
first := true
for b in raw {
if acc > max(u64) >> 7 {
err = .Arc_Overflow
return "", err
}
acc = acc << 7 | u64(b & 0x7F)
if b & 0x80 != 0 {
continue
}
if first {
// See oid_components for the X.690 section 8.19.4 split of the first subidentifier.
arc1, arc2: u64
switch {
case acc < 40:
arc1, arc2 = 0, acc
case acc < 80:
arc1, arc2 = 1, acc - 40
case:
arc1, arc2 = 2, acc - 80
}
written += strings.write_u64(&sb, arc1)
written += strings.write_byte(&sb, '.')
written += strings.write_u64(&sb, arc2)
expected += _decimal_len(arc1) + 1 + _decimal_len(arc2)
first = false
} else {
written += strings.write_byte(&sb, '.')
written += strings.write_u64(&sb, acc)
expected += 1 + _decimal_len(acc)
}
acc = 0
}
if written != expected {
err = .Allocation_Failed
return "", err
}
return strings.to_string(sb), .None
}
@(private)
_decimal_len :: proc "contextless" (v: u64) -> (n: int) {
n = 1
x := v
for x >= 10 {
x /= 10
n += 1
}
return n
}
@(private)
_read_tag :: proc "contextless" (r: ^Cursor) -> (tag: Tag, err: Error) {
if is_empty(r) {
return {}, .Truncated
}
b := r.data[r.pos]
r.pos += 1
tag.class = Class(b >> 6)
tag.constructed = b & 0x20 != 0
number := u32(b & 0x1F)
if number != 0x1F {
tag.number = number
return tag, .None
}
// High-tag-number form (X.690 section 8.1.2.4): base-128, minimal (first
// octet may not be 0x80), and the resulting number must be >= 31.
number = 0
for i := 0; ; i += 1 {
if is_empty(r) {
return {}, .Truncated
}
nb := r.data[r.pos]
r.pos += 1
if i == 0 && nb == 0x80 {
return {}, .Invalid_Tag
}
if number > (max(u32) >> 7) {
return {}, .Invalid_Tag
}
number = number << 7 | u32(nb & 0x7F)
if nb & 0x80 == 0 {
break
}
}
if number < 0x1F {
return {}, .Invalid_Tag
}
tag.number = number
return tag, .None
}
@(private)
_read_length :: proc "contextless" (r: ^Cursor) -> (length: int, err: Error) {
if is_empty(r) {
return 0, .Truncated
}
b := r.data[r.pos]
r.pos += 1
if b & 0x80 == 0 {
return int(b), .None
}
n := int(b & 0x7F)
if n == 0 {
// 0x80: indefinite length, BER only.
return 0, .Invalid_Length
}
if n > 4 {
// Lengths beyond 2^31 are not plausible inputs here.
return 0, .Invalid_Length
}
if remaining(r) < n {
return 0, .Truncated
}
value := 0
for i in 0 ..< n {
value = value << 8 | int(r.data[r.pos + i])
}
r.pos += n
// DER minimality: no leading zero octet, and the long form may only be used for lengths >= 128.
if r.data[r.pos - n] == 0 || value < 0x80 {
return 0, .Invalid_Length
}
if value < 0 {
return 0, .Invalid_Length
}
return value, .None
}
// Enforces X.690 section 8.3: at least one octet, and minimal (the first nine bits may not be all-zero or all-one).
@(private)
_check_integer :: proc "contextless" (content: []byte) -> Error {
switch len(content) {
case 0:
return .Invalid_Integer
case 1:
return .None
}
if content[0] == 0x00 && content[1] & 0x80 == 0 {
return .Invalid_Integer
}
if content[0] == 0xFF && content[1] & 0x80 != 0 {
return .Invalid_Integer
}
return .None
}
@(private)
_two_digits :: proc "contextless" (b: []byte) -> (value: int, ok: bool) {
if b[0] < '0' || b[0] > '9' || b[1] < '0' || b[1] > '9' {
return 0, false
}
return int(b[0] - '0') * 10 + int(b[1] - '0'), true
}
// Converts Unix seconds to a time.Time, saturating at time.Time's i64-nanosecond
// bounds (near year 2262) rather than overflowing. See the note above
// read_utc_time for why far-future dates are saturated instead of rejected.
@(private)
_time_from_unix :: proc "contextless" (secs: i64) -> dt.Time {
NS_PER_SEC :: i64(1_000_000_000)
if secs > max(i64) / NS_PER_SEC {
return dt.Time{_nsec = max(i64)}
}
if secs < min(i64) / NS_PER_SEC {
return dt.Time{_nsec = min(i64)}
}
return dt.Time{_nsec = secs * NS_PER_SEC}
}
// Converts a year plus "MMDDHHMMSS" into seconds since the Unix epoch,
// validating field ranges. Computed directly (via the civil-date algorithm
// below) rather than through time.Time so the whole year 1..9999 range is
// computable before _time_from_unix decides how to represent it.
@(private)
_unix_from_fields :: proc "contextless" (year: int, fields: []byte) -> (unix_seconds: i64, err: Error) {
month, mo_ok := _two_digits(fields[0:2])
day, d_ok := _two_digits(fields[2:4])
hour, h_ok := _two_digits(fields[4:6])
minute, min_ok := _two_digits(fields[6:8])
second, s_ok := _two_digits(fields[8:10])
if !mo_ok || !d_ok || !h_ok || !min_ok || !s_ok {
return 0, .Invalid_Time
}
if month < 1 || month > 12 || day < 1 || day > 31 {
return 0, .Invalid_Time
}
if hour > 23 || minute > 59 || second > 59 {
return 0, .Invalid_Time
}
days := _days_from_civil(i64(year), month, day)
return days * 86400 + i64(hour) * 3600 + i64(minute) * 60 + i64(second), .None
}
// Returns the number of days since 1970-01-01 for a proleptic-Gregorian date
// (Ref: http://howardhinnant.github.io/date_algorithms.html#days_from_civil).
// Exact for any representable year; no epoch-range limit.
@(private)
_days_from_civil :: proc "contextless" (y: i64, m, d: int) -> i64 {
yy := y - (m <= 2 ? 1 : 0)
era := (yy >= 0 ? yy : yy - 399) / 400
yoe := yy - era * 400 // [0, 399]
doy := i64((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1) // [0, 365]
doe := yoe * 365 + yoe / 4 - yoe / 100 + doy // [0, 146096]
return era * 146097 + doe - 719468
}

View File

@@ -0,0 +1,29 @@
/*
Strict DER (Distinguished Encoding Rules) reader and writer for the PKIX
subset of ASN.1, the substrate for X.509 certificates and related structures.
Reader: a `Cursor` over the input; `read_*` procs return VIEWS into it (only
`oid_components` / `oid_to_string` take an allocator). The input must outlive
the results.
Writer: build a declarative tree of `Value` nodes with the constructors, then
`encoded_len` + `encode` (no allocation, into a caller buffer) or `marshal`
(one allocation). Constructors BORROW their inputs, so build and encode
within one expression (or back children with a slice that outlives the call);
the encoded output is self-contained and aliases nothing.
Scope & limitations:
- DER only (no BER/CER); strict (minimal lengths, minimal integers, ...).
- PKIX subset: no typed readers for STRING/REAL/... (walk with `read_any`);
the writer emits low-tag-number identifiers only (tag number <= 30).
Times use core:time.Time per the RFC 5280 DER profile (Zulu, seconds present,
no fractional seconds). time.Time is i64 nanoseconds (tops out near year 2262)
while UTCTime/GeneralizedTime reach 9999; on read, dates beyond that saturate.
See:
- [[ https://www.itu.int/rec/T-REC-X.690 ]]
- [[ https://www.rfc-editor.org/rfc/rfc5280 ]]
*/
package asn1

View File

@@ -0,0 +1,493 @@
package asn1
/*
DER (Distinguished Encoding Rules) writer, the inverse of the cursor
reader, and the encoding substrate for signatures, keys, and certificates.
The model is declarative: build a tree of `Value` nodes with the
constructors below, then turn it into bytes with `encoded_len` + `encode`
(no allocation, into a caller buffer) or `marshal` (one allocation, owned
slice). A SEQUENCE/SET simply holds its children, so length is discovered
by a measure pass rather than back-patched, and `set` can sort its
children (DER SET OF ordering).
Zero-copy, with a lifetime caveat: the constructors BORROW their byte and
child inputs (no copies), so a Value tree is only valid while those inputs
live. In practice build and encode the tree within one expression, or back
its children with a slice/array that outlives the encode call:
// one expression (inputs r, s outlive the call):
out := marshal(sequence({integer_unsigned(r), integer_unsigned(s)})) or_return
DER is canonical by construction: definite minimal-length headers, minimal
INTEGER magnitudes with the sign octet inserted only when required. The
writer emits low-tag-number identifiers only (tag number <= 30), which
covers all of PKIX; high-tag-number form is not supported.
See:
- [[ https://www.itu.int/rec/T-REC-X.690 ]]
*/
import dt "core:time"
// Selects how a Value's content octets are produced when encoding.
@(private)
_Form :: enum u8 {
Primitive, // content holds the exact content octets, emitted verbatim.
Constructed, // children holds the sub-values, emitted in order.
Integer_Magnitude, // content holds an unsigned big-endian magnitude; DER INTEGER rules are applied on emit.
Bit_String_Octets, // content holds whole-octet payload; a leading 0x00 unused-bits count is added on emit.
Bit_String_Wrapped, // children's DER becomes the payload, behind the 0x00 unused-bits count.
Time, // _when is formatted to UTCTime/GeneralizedTime per the tag on emit.
Raw, // content is a complete pre-encoded element, emitted verbatim (no added tag/length).
}
// Value is a node in a to-be-encoded DER tree. Construct it with the
// helpers below rather than by hand; the fields are an implementation
// detail. The byte/child inputs are borrowed (see the package lifetime note).
Value :: struct {
tag: Tag,
form: _Form,
content: []byte,
children: []Value,
_when: dt.Time, // meaningful only when form == .Time: the instant to format on emit
}
@(rodata, private)
_BOOL_FALSE := []byte{0x00}
@(rodata, private)
_BOOL_TRUE := []byte{0xFF}
// Builds a primitive value with `tag` and the exact `content`
// octets (emitted verbatim). The caller owns canonical-form correctness.
primitive :: proc "contextless" (tag: Tag, content: []byte) -> Value {
return Value{tag = tag, form = .Primitive, content = content}
}
// Builds a value whose encoding IS `encoded` verbatim, a complete
// already-DER-encoded element spliced in as-is (no tag/length added). The
// composition primitive for nesting an independently-marshalled structure
// (a signed CertificationRequestInfo, a pre-built TBSCertificate) inside a
// parent without re-encoding it.
raw :: proc "contextless" (encoded: []byte) -> Value {
return Value{form = .Raw, content = encoded}
}
// Builds a BOOLEAN (DER: 0x00 / 0xFF).
boolean :: proc "contextless" (v: bool) -> Value {
return Value{tag = universal(.Boolean), form = .Primitive, content = v ? _BOOL_TRUE : _BOOL_FALSE}
}
// Builds an INTEGER from an unsigned big-endian magnitude: leading zero
// octets are dropped (minimal encoding) and a single 0x00 sign octet is
// inserted when the top bit would otherwise read as negative. An empty
// or all-zero magnitude encodes as 0. The inverse of read_unsigned_integer_bytes.
integer_unsigned :: proc "contextless" (magnitude: []byte) -> Value {
return Value{tag = universal(.Integer), form = .Integer_Magnitude, content = magnitude}
}
// Builds an INTEGER from content octets that are ALREADY a minimal
// two's-complement encoding (e.g. a serial preserved verbatim from
// read_integer_bytes). No normalization is applied.
integer_raw :: proc "contextless" (content: []byte) -> Value {
return Value{tag = universal(.Integer), form = .Primitive, content = content}
}
// Builds an OCTET STRING wrapping `content`.
octet_string :: proc "contextless" (content: []byte) -> Value {
return Value{tag = universal(.Octet_String), form = .Primitive, content = content}
}
// Builds an OCTET STRING whose content is the DER encoding of `children`,
// the form X.509 Extension.extnValue uses to carry an extension's value. The
// OCTET STRING stays primitive (0x04); its content is just the children's
// concatenated DER, so this reuses the constructed-content machinery without
// a wrapper octet.
octet_string_wrap :: proc "contextless" (children: []Value) -> Value {
return Value{tag = universal(.Octet_String), form = .Constructed, children = children}
}
// Builds a SEQUENCE from its sub-values (borrowed).
sequence :: proc "contextless" (children: []Value) -> Value {
return Value{tag = universal(.Sequence, true), form = .Constructed, children = children}
}
// Builds a SET from its sub-values, emitted in the given order. DER SET OF
// requires components sorted by their encoding (X.690 section 11.6); this
// constructor does NOT sort, so a SET OF with more than one element must be
// given pre-sorted (a single-element RDN, the common PKIX case, is trivially
// ordered).
set :: proc "contextless" (children: []Value) -> Value {
return Value{tag = universal(.Set, true), form = .Constructed, children = children}
}
// Builds a SET OF from its sub-values, sorted into the DER canonical order
// (X.690 section 11.6: component encodings ascending, shorter padded with
// trailing 0-octets). Unlike the other constructors this one ALLOCATES, it
// must encode each child to compare them, and it sorts `children` in place;
// the returned Value then borrows that reordered slice as usual. Scratch is
// taken from and released to `allocator`. 0/1-element sets need no work.
@(require_results)
set_of :: proc(children: []Value, allocator := context.allocator) -> (value: Value, err: Error) {
n := len(children)
if n <= 1 {
return set(children), .None
}
encs, merr := make([][]byte, n, allocator)
if merr != nil {
return {}, .Allocation_Failed
}
defer {
for e in encs {
delete(e, allocator)
}
delete(encs, allocator)
}
for i in 0 ..< n {
encs[i] = marshal(children[i], allocator) or_return
}
// Insertion sort (n is small for a SET OF) keying children on their encodings.
for i in 1 ..< n {
for j := i; j > 0 && _der_less(encs[j], encs[j - 1]); j -= 1 {
encs[j], encs[j - 1] = encs[j - 1], encs[j]
children[j], children[j - 1] = children[j - 1], children[j]
}
}
return set(children), .None
}
// Compares two encodings as octet strings with the shorter padded at its
// trailing end with 0-octets (X.690 section 11.6 SET OF ordering).
@(private)
_der_less :: proc "contextless" (a, b: []byte) -> bool {
n := min(len(a), len(b))
for i in 0 ..< n {
if a[i] != b[i] {
return a[i] < b[i]
}
}
if len(a) < len(b) {
for i in n ..< len(b) {
if b[i] != 0x00 {
return true // a's zero padding is below b's non-zero tail
}
}
return false
}
for i in n ..< len(a) {
if a[i] != 0x00 {
return false // a's non-zero tail is above b's zero padding
}
}
return false
}
// Builds a NULL (empty content).
null :: proc "contextless" () -> Value {
return Value{tag = universal(.Null), form = .Primitive, content = nil}
}
// Builds an OBJECT IDENTIFIER from already-encoded content octets (the form
// PKIX OIDs are held and compared in, e.g. the package's known-OID
// constants). The content is emitted verbatim; the caller owns its validity.
object_identifier :: proc "contextless" (content: []byte) -> Value {
return Value{tag = universal(.Object_Identifier), form = .Primitive, content = content}
}
// Builds a BIT STRING from a whole-octet payload (unused-bits count 0), the
// only form PKIX uses for SubjectPublicKeyInfo keys and signature values.
bit_string_octets :: proc "contextless" (payload: []byte) -> Value {
return Value{tag = universal(.Bit_String), form = .Bit_String_Octets, content = payload}
}
// Builds a BIT STRING (whole octets) whose payload is the DER encoding of
// `children`, the form SubjectPublicKeyInfo uses to carry an RSAPublicKey
// SEQUENCE inside the subjectPublicKey bit string.
bit_string_wrap :: proc "contextless" (children: []Value) -> Value {
return Value{tag = universal(.Bit_String), form = .Bit_String_Wrapped, children = children}
}
// Builds a UTCTime ("YYMMDDHHMMSSZ", RFC 5280 DER profile). Appropriate for
// instants in 1950..=2049; the sliding-window century is what the reader
// (read_utc_time) decodes, so use generalized_time outside that range.
utc_time :: proc "contextless" (at: dt.Time) -> Value {
return Value{tag = universal(.UTC_Time), form = .Time, _when = at}
}
// Builds a GeneralizedTime ("YYYYMMDDHHMMSSZ", RFC 5280 DER profile: Zulu,
// seconds present, no fractional part). The inverse of read_generalized_time.
generalized_time :: proc "contextless" (at: dt.Time) -> Value {
return Value{tag = universal(.Generalized_Time), form = .Time, _when = at}
}
// 2050-01-01T00:00:00Z: the RFC 5280 boundary between the two time forms.
@(private)
_UNIX_2050 :: i64(2_524_608_000)
// Builds a UTCTime or GeneralizedTime, auto-selecting the form per the RFC
// 5280 profile: UTCTime for instants in 1950..=2049, GeneralizedTime from
// 2050 on. (X.509 validity dates are written this way.)
time :: proc "contextless" (at: dt.Time) -> Value {
if dt.to_unix_seconds(at) < _UNIX_2050 {
return utc_time(at)
}
return generalized_time(at)
}
// Builds a primitive [number] IMPLICIT value carrying raw content octets,
// e.g. AuthorityKeyIdentifier's keyIdentifier [0] IMPLICIT OCTET STRING.
context_primitive :: proc "contextless" (number: u32, content: []byte) -> Value {
return Value{tag = context_specific(number, false), form = .Primitive, content = content}
}
// Builds a constructed [number] EXPLICIT wrapper around the given sub-values,
// e.g. TBSCertificate's version [0] EXPLICIT INTEGER.
context_explicit :: proc "contextless" (number: u32, children: []Value) -> Value {
return Value{tag = context_specific(number, true), form = .Constructed, children = children}
}
// Returns the exact number of bytes encode/marshal will write
// for `v`, including its identifier and length octets.
encoded_len :: proc(v: Value) -> int {
if v.form == .Raw {
return len(v.content) // already a complete element
}
clen := _content_len(v)
return _tag_len(v.tag) + _length_len(clen) + clen
}
// Writes the DER encoding of `v` into `dst` and returns the number
// of bytes written. `dst` must be at least encoded_len(v) bytes; if it is
// shorter, nothing is written and Buffer_Too_Small is returned.
encode :: proc(v: Value, dst: []byte) -> (n: int, err: Error) {
need := encoded_len(v)
if len(dst) < need {
return 0, .Buffer_Too_Small
}
_emit(v, dst[:need])
return need, .None
}
// Encodes `v` into a freshly allocated slice the caller owns.
marshal :: proc(v: Value, allocator := context.allocator) -> (out: []byte, err: Error) {
n := encoded_len(v)
buf, merr := make([]byte, n, allocator)
if merr != nil {
return nil, .Allocation_Failed
}
_emit(v, buf)
return buf, .None
}
@(private)
_content_len :: proc(v: Value) -> int {
switch v.form {
case .Primitive:
return len(v.content)
case .Integer_Magnitude:
start, pad := _int_shape(v.content)
if start == len(v.content) {
return 1 // zero encodes as a single 0x00 octet
}
return (len(v.content) - start) + (pad ? 1 : 0)
case .Bit_String_Octets:
return 1 + len(v.content) // leading unused-bits octet (0x00)
case .Bit_String_Wrapped:
total := 1 // leading unused-bits octet (0x00)
for child in v.children {
total += encoded_len(child)
}
return total
case .Time:
return _time_content_len(v.tag)
case .Constructed:
total := 0
for child in v.children {
total += encoded_len(child)
}
return total
case .Raw:
return len(v.content) // handled in encoded_len; here for exhaustiveness
}
return 0
}
// Writes v's complete encoding so that it ENDS at dst[len(dst)], i.e. into
// the tail of dst, and returns the bytes written. `dst` is the exactly-sized
// region this node may occupy (encoded_len(v) == len(dst) at the top call).
//
// Emitting back-to-front means a constructed node's content length falls out
// of where its children landed (no second measure pass): encoded_len does the
// single O(n) sizing pass, this does the single O(n) write pass.
@(private)
_emit :: proc(v: Value, dst: []byte) -> int {
if v.form == .Raw {
n := len(v.content)
copy(dst[len(dst) - n:], v.content)
return n
}
end := len(dst)
switch v.form {
case .Raw: // handled by the early return above
case .Primitive:
end -= len(v.content)
copy(dst[end:], v.content)
case .Integer_Magnitude:
start, pad := _int_shape(v.content)
if start == len(v.content) {
end -= 1
dst[end] = 0x00 // zero -> single 0x00 octet
} else {
body := v.content[start:]
end -= len(body)
copy(dst[end:], body)
if pad {
end -= 1
dst[end] = 0x00 // sign octet so the value reads as non-negative
}
}
case .Bit_String_Octets:
end -= len(v.content)
copy(dst[end:], v.content)
end -= 1
dst[end] = 0x00 // unused-bits count: whole octets
case .Bit_String_Wrapped:
for i := len(v.children) - 1; i >= 0; i -= 1 {
end -= _emit(v.children[i], dst[:end])
}
end -= 1
dst[end] = 0x00 // unused-bits count: whole octets
case .Time:
tmp: [15]byte // GeneralizedTime is the longest form
n := _format_time(tmp[:], v._when, v.tag.number == u32(Tag_Number.Generalized_Time))
end -= n
copy(dst[end:], tmp[:n])
case .Constructed:
for i := len(v.children) - 1; i >= 0; i -= 1 {
end -= _emit(v.children[i], dst[:end])
}
}
clen := len(dst) - end // content length, read off the cursor, never recomputed
tmp: [9]byte // identifier byte + up to 8 length octets covers any int length
lw := _write_length(tmp[:], clen)
end -= lw
copy(dst[end:], tmp[:lw])
end -= 1
dst[end] = _tag_byte(v.tag)
return len(dst) - end
}
// UTCTime content is "YYMMDDHHMMSSZ" (13 octets); GeneralizedTime is
// "YYYYMMDDHHMMSSZ" (15). The content length is fixed by the tag, so it is
// known for the measure pass without formatting.
@(private)
_time_content_len :: proc "contextless" (tag: Tag) -> int {
return tag.number == u32(Tag_Number.Generalized_Time) ? 15 : 13
}
// Formats `at` into dst as the RFC 5280 DER time profile (Zulu, seconds
// present, no fractional part) and returns the bytes written: 15 for
// GeneralizedTime ("YYYYMMDDHHMMSSZ"), 13 for UTCTime ("YYMMDDHHMMSSZ", with
// the low two year digits). dst must hold the full width.
@(private)
_format_time :: proc "contextless" (dst: []byte, at: dt.Time, generalized: bool) -> int {
// time_to_datetime decomposes the instant into UTC calendar fields; it only
// fails outside time.Time's range, which an in-range time.Time can't reach.
c, _ := dt.time_to_datetime(at)
p := 0
if generalized {
p += _write_digits(dst[p:], int(c.year), 4)
} else {
p += _write_digits(dst[p:], int(c.year % 100), 2)
}
p += _write_digits(dst[p:], int(c.month), 2)
p += _write_digits(dst[p:], int(c.day), 2)
p += _write_digits(dst[p:], int(c.hour), 2)
p += _write_digits(dst[p:], int(c.minute), 2)
p += _write_digits(dst[p:], int(c.second), 2)
dst[p] = 'Z'
p += 1
return p
}
// Writes `value` as exactly `width` zero-padded decimal digits.
@(private)
_write_digits :: proc "contextless" (dst: []byte, value, width: int) -> int {
x := value
for i := width - 1; i >= 0; i -= 1 {
dst[i] = byte('0' + x % 10)
x /= 10
}
return width
}
// Reports the index of the first significant magnitude octet
// (== len(mag) when the value is zero) and whether a 0x00 sign octet must
// be prepended so the result reads as non-negative.
@(private)
_int_shape :: proc "contextless" (mag: []byte) -> (start: int, pad: bool) {
start = 0
for start < len(mag) && mag[start] == 0x00 {
start += 1
}
if start == len(mag) {
return start, false
}
return start, mag[start] & 0x80 != 0
}
// _length_len / _write_length are the inverse of _read_length: definite
// form, minimal (short form below 128, otherwise the fewest octets).
@(private)
_length_len :: proc "contextless" (length: int) -> int {
if length < 0x80 {
return 1
}
n := 1
v := length
for v > 0 {
v >>= 8
n += 1
}
return n
}
@(private)
_write_length :: proc "contextless" (dst: []byte, length: int) -> int {
if length < 0x80 {
dst[0] = byte(length)
return 1
}
nbytes := 0
v := length
for v > 0 {
v >>= 8
nbytes += 1
}
dst[0] = 0x80 | byte(nbytes)
for i in 0 ..< nbytes {
dst[1 + i] = byte(length >> uint(8 * (nbytes - 1 - i)))
}
return 1 + nbytes
}
// _tag_len / _tag_byte are the inverse of _read_tag, low-tag-number form
// only (number <= 30); PKIX never needs high-tag-number identifiers.
@(private)
_tag_len :: proc "contextless" (tag: Tag) -> int {
return 1
}
@(private)
_tag_byte :: proc "contextless" (tag: Tag) -> byte {
assert_contextless(tag.number <= 30, "asn1: high-tag-number form is not supported by the writer")
b := byte(tag.class) << 6
if tag.constructed {
b |= 0x20
}
b |= byte(tag.number) & 0x1F
return b
}

View File

@@ -425,15 +425,21 @@ parse_hostname_or_endpoint :: proc(endpoint_str: string) -> (target: Host_Or_End
// Takes an endpoint string and returns its parts.
// Returns ok=false if port is not a number.
split_port :: proc(endpoint_str: string) -> (addr_or_host: string, port: int, ok: bool) {
// IP6 [addr_or_host]:port
if i := strings.last_index(endpoint_str, "]:"); i >= 0 {
addr_or_host = endpoint_str[1:i]
port, ok = strconv.parse_int(endpoint_str[i+2:], 10)
// IP6 [addr_or_host]:port — the bracketed form requires a leading
// '[', so only treat a closing "]:" as the port separator when it
// sits at index >= 1 (i.e. an opening '[' is actually present).
// Inputs that contain "]:" without an opening bracket fall through
// to the plain host/port handling below.
if len(endpoint_str) > 0 && endpoint_str[0] == '[' {
if i := strings.last_index(endpoint_str, "]:"); i >= 1 {
addr_or_host = endpoint_str[1:i]
port, ok = strconv.parse_int(endpoint_str[i+2:], 10)
if port > 65535 {
ok = false
if port > 65535 {
ok = false
}
return
}
return
}
if n := strings.count(endpoint_str, ":"); n == 1 {

View File

@@ -0,0 +1,2 @@
*.json
*.zip

View File

@@ -0,0 +1,133 @@
package test_core_x509
// Deterministic mutational fuzzing for the certificate parser, seeded
// (and reproducible) via the test runner's logged random seed.
//
// Invariants on every input: parse never panics, never reads outside
// the input (bounds checks + address sanitizer in CI), never leaks on
// error paths (the runner's tracking allocator), and on success every
// raw view lies within the input buffer.
import "core:crypto/x509"
import "core:math/rand"
import "core:testing"
FUZZ_MUTATE_ITERS :: 1500
FUZZ_RANDOM_ITERS :: 2048
// _check_views asserts that a successfully-parsed Certificate only
// references memory inside its input.
@(private="file")
_check_views :: proc(t: ^testing.T, cert: ^x509.Certificate, der: []byte) {
in_bounds :: proc(view: []byte, der: []byte) -> bool {
if len(view) == 0 {
return true
}
base := uintptr(raw_data(der))
view_start := uintptr(raw_data(view))
return view_start >= base && view_start + uintptr(len(view)) <= base + uintptr(len(der))
}
testing.expect(t, in_bounds(cert.raw, der), "raw out of bounds")
testing.expect(t, in_bounds(cert.raw_tbs, der), "raw_tbs out of bounds")
testing.expect(t, in_bounds(cert.raw_issuer, der), "raw_issuer out of bounds")
testing.expect(t, in_bounds(cert.raw_subject, der), "raw_subject out of bounds")
testing.expect(t, in_bounds(cert.raw_spki, der), "raw_spki out of bounds")
testing.expect(t, in_bounds(cert.serial, der), "serial out of bounds")
testing.expect(t, in_bounds(cert.signature, der), "signature out of bounds")
testing.expect(t, cert.version >= 1 && cert.version <= 3, "version range")
for name in cert.dns_names {
testing.expect(t, in_bounds(transmute([]byte)name, der), "dns name out of bounds")
}
for ip in cert.ip_addresses {
testing.expect(t, in_bounds(ip, der), "ip out of bounds")
testing.expect(t, len(ip) == 4 || len(ip) == 16, "ip width")
}
for ext in cert.extensions {
testing.expect(t, in_bounds(ext.oid, der), "ext oid out of bounds")
testing.expect(t, in_bounds(ext.value, der), "ext value out of bounds")
}
}
// Every single-bit flip of a real certificate: parse must fail cleanly
// or succeed with intact invariants.
@(test)
test_fuzz_bitflips :: proc(t: ^testing.T) {
buf := make([]byte, len(EC_DER))
defer delete(buf)
for i in 0 ..< len(EC_DER) {
for bit in 0 ..< uint(8) {
copy(buf, EC_DER)
buf[i] ~= 1 << bit
cert, err := x509.parse(buf)
if err == .None {
_check_views(t, &cert, buf)
x509.destroy(&cert)
}
}
}
}
// Random multi-byte mutations across all three fixtures.
@(test)
test_fuzz_mutations :: proc(t: ^testing.T) {
fixtures := [?][]byte{RSA_DER, PSS_DER, EC_DER, ED_DER}
max_len := 0
for f in fixtures {
max_len = max(max_len, len(f))
}
buf := make([]byte, max_len)
defer delete(buf)
for _ in 0 ..< FUZZ_MUTATE_ITERS {
fixture := rand.choice(fixtures[:])
input := buf[:len(fixture)]
copy(input, fixture)
// Mutate 1-16 positions, occasionally truncating instead,
// length-field damage is where TLV parsers historically break.
if rand.int_max(8) == 0 {
input = input[:rand.int_max(len(input) + 1)]
}
for _ in 0 ..< 1 + rand.int_max(16) {
if len(input) == 0 {
break
}
input[rand.int_max(len(input))] = byte(rand.uint32())
}
cert, err := x509.parse(input)
if err == .None {
_check_views(t, &cert, input)
x509.destroy(&cert)
}
}
}
// Pure noise: parse must reject (or, vanishingly unlikely, accept with
// invariants intact) without panicking.
@(test)
test_fuzz_random_garbage :: proc(t: ^testing.T) {
buf: [128]byte
for _ in 0 ..< FUZZ_RANDOM_ITERS {
n := rand.int_max(len(buf) + 1)
input := buf[:n]
for i in 0 ..< n {
input[i] = byte(rand.uint32())
}
// Bias towards the outer shape so mutation reaches the TBS walk.
if n > 4 && rand.int_max(2) == 0 {
input[0] = 0x30
input[1] = byte(rand.int_max(n))
}
cert, err := x509.parse(input)
if err == .None {
_check_views(t, &cert, input)
x509.destroy(&cert)
}
}
}

View File

@@ -0,0 +1,138 @@
package test_core_x509
// Out-of-memory robustness: parse must surface Allocation_Failed
// cleanly and leak nothing when any one of its table allocations
// fails. A failing allocator wraps a tracking allocator; sweeping the
// fail point across every allocation site (and verifying zero leaked
// blocks after each) exercises every OOM path and its unwind.
import "base:runtime"
import "core:mem"
import "core:crypto/x509"
import "core:testing"
import "core:time"
// Failing_Allocator passes through to a backing allocator but returns
// Out_Of_Memory on the (fail_at)-th allocation request, counting only
// the allocating modes.
@(private="file")
Failing_Allocator :: struct {
backing: runtime.Allocator,
count: int,
fail_at: int, // -1 = never fail
}
@(private="file")
failing_allocator_proc :: proc(
data: rawptr, mode: runtime.Allocator_Mode,
size, alignment: int, old_memory: rawptr, old_size: int,
loc := #caller_location,
) -> ([]byte, runtime.Allocator_Error) {
fa := (^Failing_Allocator)(data)
#partial switch mode {
case .Alloc, .Alloc_Non_Zeroed, .Resize, .Resize_Non_Zeroed:
if fa.count == fa.fail_at {
fa.count += 1
return nil, .Out_Of_Memory
}
fa.count += 1
}
return fa.backing.procedure(fa.backing.data, mode, size, alignment, old_memory, old_size, loc)
}
@(private="file")
failing_allocator :: proc(fa: ^Failing_Allocator) -> runtime.Allocator {
return {procedure = failing_allocator_proc, data = fa}
}
@(test)
test_oom_parse_sweep :: proc(t: ^testing.T) {
// A cert that drives all three table allocations (extensions, DNS
// SANs, IP SANs): the EC fixture has SANs + KU + EKU + BC.
der := EC_DER
// First, learn how many allocations a clean parse makes.
total: int
{
track: mem.Tracking_Allocator
mem.tracking_allocator_init(&track, context.allocator)
defer mem.tracking_allocator_destroy(&track)
fa := Failing_Allocator{backing = mem.tracking_allocator(&track), fail_at = -1}
cert, err := x509.parse(der, failing_allocator(&fa))
testing.expect_value(t, err, x509.Error.None)
x509.destroy(&cert, failing_allocator(&fa))
total = fa.count
testing.expect(t, total >= 3, "expected at least 3 allocation sites")
testing.expect_value(t, len(track.allocation_map), 0)
}
// Now fail at each allocation in turn; every one must yield a clean
// Allocation_Failed and leak nothing.
for k in 0 ..< total {
track: mem.Tracking_Allocator
mem.tracking_allocator_init(&track, context.allocator)
defer mem.tracking_allocator_destroy(&track)
fa := Failing_Allocator{backing = mem.tracking_allocator(&track), fail_at = k}
cert, err := x509.parse(der, failing_allocator(&fa))
if err == .None {
// This allocation site wasn't on the parse path for this
// input; clean up and move on.
x509.destroy(&cert, failing_allocator(&fa))
} else {
testing.expectf(t, err == .Allocation_Failed,
"fail_at=%d: expected Allocation_Failed, got %v", k, err)
}
// Either way, parse must own no memory afterwards (its failure
// path calls destroy internally; the success path we cleaned).
testing.expectf(t, len(track.allocation_map) == 0,
"fail_at=%d: %d block(s) leaked", k, len(track.allocation_map))
}
}
// verify_chain allocates exactly one block (the chain buffer). Failing
// it must yield Allocation_Failed and leak nothing. Certificates are
// parsed with the real allocator; only verify_chain gets the failing one.
@(test)
test_oom_verify_chain :: proc(t: ^testing.T) {
leaf, _ := x509.parse(EC_CHAIN_LEAF); defer x509.destroy(&leaf)
inter, _ := x509.parse(EC_CHAIN_INTER); defer x509.destroy(&inter)
root, _ := x509.parse(EC_CHAIN_ROOT); defer x509.destroy(&root)
opts := x509.Verify_Options{
roots = {&root},
intermediates = {&inter},
current_time = time.unix(CHAIN_NOW, 0),
}
total: int
{
track: mem.Tracking_Allocator
mem.tracking_allocator_init(&track, context.allocator)
defer mem.tracking_allocator_destroy(&track)
fa := Failing_Allocator{backing = mem.tracking_allocator(&track), fail_at = -1}
chain, err := x509.verify_chain(&leaf, opts, failing_allocator(&fa))
testing.expect_value(t, err, x509.Error.None)
delete(chain, failing_allocator(&fa))
total = fa.count
testing.expect(t, total >= 1, "verify_chain should allocate at least once")
testing.expect_value(t, len(track.allocation_map), 0)
}
for k in 0 ..< total {
track: mem.Tracking_Allocator
mem.tracking_allocator_init(&track, context.allocator)
defer mem.tracking_allocator_destroy(&track)
fa := Failing_Allocator{backing = mem.tracking_allocator(&track), fail_at = k}
chain, err := x509.verify_chain(&leaf, opts, failing_allocator(&fa))
if err == .None {
delete(chain, failing_allocator(&fa))
} else {
testing.expectf(t, err == .Allocation_Failed,
"fail_at=%d: expected Allocation_Failed, got %v", k, err)
}
testing.expectf(t, len(track.allocation_map) == 0,
"fail_at=%d: %d block(s) leaked", k, len(track.allocation_map))
}
}

View File

@@ -0,0 +1,868 @@
package test_core_x509
// Fixtures generated with openssl 3.x (see testdata/):
//
// openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \
// -subj "/C=US/O=Odin Test/CN=localhost" \
// -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \
// -addext "basicConstraints=critical,CA:TRUE"
//
// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
// -sha256 -days 3650 -nodes -subj "/CN=example.com" \
// -addext "subjectAltName=DNS:example.com,DNS:*.example.com,IP:2001:db8::1" \
// -addext "extendedKeyUsage=serverAuth" \
// -addext "keyUsage=critical,digitalSignature" \
// -addext "basicConstraints=critical,CA:FALSE"
//
// openssl req -x509 -newkey ed25519 -days 3650 -nodes -subj "/CN=ed.test"
//
// Field expectations below were cross-checked against
// `openssl x509 -noout -text`.
import "core:crypto/hash"
import "core:crypto/x509"
import "core:encoding/asn1"
import "core:testing"
import "core:time"
RSA_DER := #load("testdata/rsa.der")
EC_DER := #load("testdata/ec.der")
ED_DER := #load("testdata/ed.der")
// Self-signed RSA-PSS CA (SHA-256, MGF1-SHA-256, salt 32), generated with:
// openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 3650 \
// -subj "/CN=pss.rsa.test" -sigopt rsa_padding_mode:pss \
// -sigopt rsa_pss_saltlen:32 -addext "basicConstraints=critical,CA:TRUE"
PSS_DER := #load("testdata/pss.der")
// Self-signed sha1WithRSAEncryption cert (openssl req -x509 -newkey rsa:2048
// -sha1), for the RFC 9155 SHA-1 rejection test.
RSA_SHA1_DER := #load("testdata/rsa_sha1.der")
// Chains for verify_chain (generated by testdata/gen_chains.sh):
// P-256: chain_ec_root -> chain_ec_inter (pathlen:0) -> chain_ec_leaf
// (CN=leaf.example.com, EKU serverAuth); chain_ec_other_root is
// an unrelated anchor for the wrong-root case.
// Ed25519: chain_ed_root -> chain_ed_leaf (CN=ed-leaf.example.com).
EC_CHAIN_ROOT := #load("testdata/chain_ec_root.der")
EC_CHAIN_INTER := #load("testdata/chain_ec_inter.der")
EC_CHAIN_LEAF := #load("testdata/chain_ec_leaf.der")
EC_OTHER_ROOT := #load("testdata/chain_ec_other_root.der")
ED_CHAIN_ROOT := #load("testdata/chain_ed_root.der")
ED_CHAIN_LEAF := #load("testdata/chain_ed_leaf.der")
// Expired root (validity 2010-2015) that signed a still-valid leaf;
// RFC 5280 section 6.1 does not validate the trust anchor itself.
EC_EXPIRED_ROOT := #load("testdata/chain_ec_expired_root.der")
EC_EXP_LEAF := #load("testdata/chain_ec_expleaf.der")
// Negative path-validation fixtures: each chain trips one verify_chain
// rejection. neg_root signs the *_inter certs; pl_a -> pl_b -> pl_leaf
// chains deeper to violate pathlen:0 on pl_a.
NEG_ROOT := #load("testdata/neg_root.der")
NEG_NOKCS_INTER := #load("testdata/neg_nokcs_inter.der") // CA, no keyCertSign
NEG_NOKCS_LEAF := #load("testdata/neg_nokcs_leaf.der")
NEG_NOTCA_INTER := #load("testdata/neg_notca_inter.der") // CA:FALSE
NEG_NOTCA_LEAF := #load("testdata/neg_notca_leaf.der")
NEG_EXPINTER := #load("testdata/neg_expinter_inter.der") // expired CA
NEG_EXPINTER_LEAF := #load("testdata/neg_expinter_leaf.der")
NEG_PL_A := #load("testdata/neg_pl_a.der") // pathlen:0
NEG_PL_B := #load("testdata/neg_pl_b.der")
NEG_PL_LEAF := #load("testdata/neg_pl_leaf.der")
NEG_EKU_INTER := #load("testdata/neg_eku_inter.der") // emailProtection CA
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
EKU_ALT_GOOD := #load("testdata/eku_alt_good.der") // same key, serverAuth
EKU_ALT_LEAF := #load("testdata/eku_alt_leaf.der") // serverAuth leaf
// Cross-signed cycle: A and B mutually cross-certify; trusting neither
// must terminate (no hang) and report Unknown_Authority.
CYC_A := #load("testdata/cyc_a.der")
CYC_B := #load("testdata/cyc_b.der")
CYC_A_BY_B := #load("testdata/cyc_a_by_b.der")
CYC_B_BY_A := #load("testdata/cyc_b_by_a.der")
CYC_LEAF := #load("testdata/cyc_leaf.der")
// Reference times relative to the chain fixtures (issued 2026-06-13,
// leaves valid ~10 years). 2027 is inside every window; 2020 precedes
// the leaf; 2040 is past the leaf (which expires first).
CHAIN_NOW :: i64(1798761600) // 2027-01-01Z
CHAIN_BEFORE :: i64(1577836800) // 2020-01-01Z
CHAIN_AFTER :: i64(2208988800) // 2040-01-01Z
// A real CA root (GoDaddy G2) carrying serial number 0 — a
// non-conformant but in-the-wild case surfaced by differential testing
// against Go's crypto/x509 over the system CA store.
SERIAL_ZERO_DER := #load("testdata/serial_zero.der")
// A cert with two subjectAltName extensions, from the x509-limbo
// corpus — RFC 5280 section 4.2 forbids duplicate extension OIDs.
DUP_EXTENSION_DER := #load("testdata/dup_extension.der")
@(test)
test_duplicate_extension :: proc(t: ^testing.T) {
cert, err := x509.parse(DUP_EXTENSION_DER)
if err == .None {
x509.destroy(&cert)
}
testing.expect_value(t, err, x509.Error.Duplicate_Extension)
}
@(test)
test_parse_rsa :: proc(t: ^testing.T) {
cert, err := x509.parse(RSA_DER)
defer x509.destroy(&cert)
testing.expect_value(t, err, x509.Error.None)
testing.expect_value(t, cert.version, 3)
testing.expect_value(t, cert.signature_algorithm, x509.Signature_Algorithm.RSA_SHA256)
testing.expect_value(t, cert.public_key_algorithm, x509.Public_Key_Algorithm.RSA)
// serial=074476066FA3819E72A324BC52CEF3920F0E7156 (20 octets).
testing.expect_value(t, len(cert.serial), 20)
testing.expect_value(t, cert.serial[0], u8(0x07))
testing.expect_value(t, cert.serial[19], u8(0x56))
// 2048-bit modulus, e = 65537.
testing.expect_value(t, len(cert.rsa_n), 256)
testing.expect_value(t, len(cert.rsa_e), 3)
testing.expect_value(t, cert.rsa_e[0], u8(0x01))
testing.expect_value(t, cert.rsa_e[2], u8(0x01))
// notBefore=Jun 12 18:53:09 2026 GMT.
testing.expect_value(t, time.to_unix_seconds(cert.not_before), i64(1781290389))
testing.expect_value(t, cert.basic_constraints_valid, true)
testing.expect_value(t, cert.is_ca, true)
testing.expect_value(t, cert.max_path_len, -1)
testing.expect_value(t, len(cert.dns_names), 1)
testing.expect_value(t, cert.dns_names[0], "localhost")
testing.expect_value(t, len(cert.ip_addresses), 1)
testing.expect_value(t, len(cert.ip_addresses[0]), 4)
testing.expect_value(t, cert.ip_addresses[0][0], u8(127))
// Self-signed: issuer bytes == subject bytes.
testing.expect(t, len(cert.raw_issuer) > 0)
testing.expect_value(t, string(cert.raw_issuer), string(cert.raw_subject))
// openssl always adds SKI; self-signed adds matching AKI.
testing.expect(t, len(cert.subject_key_id) > 0, "SKI expected")
testing.expect(t, !cert.unhandled_critical, "all criticals understood")
// The raw views must cover real ranges.
testing.expect_value(t, len(cert.raw), len(RSA_DER))
testing.expect(t, len(cert.raw_tbs) > 0)
testing.expect(t, len(cert.raw_spki) > 0)
testing.expect_value(t, len(cert.signature), 256)
}
// parse_dn / dn_get / dn_string / serial_string: the consumer helpers over the
// raw Name / serial DER. rsa.der's subject is C=US, O=Odin Test, CN=localhost.
@(test)
test_parse_dn_and_serial :: proc(t: ^testing.T) {
cert, err := x509.parse(RSA_DER)
defer x509.destroy(&cert)
testing.expect_value(t, err, x509.Error.None)
attrs, derr := x509.parse_dn(cert.raw_subject)
defer delete(attrs)
testing.expect_value(t, derr, x509.Error.None)
testing.expect_value(t, len(attrs), 3)
// DER order: C, O, CN.
testing.expect_value(t, attrs[0].type, x509.DN_Attribute_Type.Country)
testing.expect_value(t, attrs[0].value, "US")
testing.expect_value(t, attrs[2].type, x509.DN_Attribute_Type.Common_Name)
testing.expect_value(t, attrs[2].value, "localhost")
cn, ok := x509.dn_get(attrs, .Common_Name)
testing.expect(t, ok && cn == "localhost", "dn_get CN")
_, missing := x509.dn_get(attrs, .Locality)
testing.expect(t, !missing, "dn_get absent attribute")
// RFC 4514: reverse RDN order, short names.
s := x509.dn_string(attrs)
defer delete(s)
testing.expect_value(t, s, "CN=localhost,O=Odin Test,C=US")
// serial (074476066FA3819E72A324BC52CEF3920F0E7156) as colon-hex.
ss := x509.serial_string(&cert)
defer delete(ss)
testing.expect_value(t, ss, "07:44:76:06:6F:A3:81:9E:72:A3:24:BC:52:CE:F3:92:0F:0E:71:56")
// parse_dn is the inverse of marshal_dn: re-encoding is byte-identical here
// (the cert's string types match marshal_dn's C=PrintableString, else UTF8).
der, merr := x509.marshal_dn(attrs)
defer delete(der)
testing.expect_value(t, merr, x509.Error.None)
testing.expect_value(t, string(der), string(cert.raw_subject))
}
// marshal_pkcs8 for RSA: PrivateKeyInfo { 0, AlgId{rsaEncryption, NULL},
// OCTET STRING { RSAPrivateKey { 0, n, e, d, p, q, dp, dq, iq } } }. Uses small
// fixed components (top bit clear, so no sign octet) and walks the DER back.
@(test)
test_marshal_pkcs8_rsa :: proc(t: ^testing.T) {
comps := [8][]byte{{0x01, 0x02}, {0x01, 0x00, 0x01}, {0x03}, {0x04}, {0x05}, {0x06}, {0x07}, {0x08}}
der, err := x509.marshal_pkcs8(
{
algorithm = .RSA,
rsa_n = comps[0], rsa_e = comps[1], rsa_d = comps[2], rsa_p = comps[3],
rsa_q = comps[4], rsa_dp = comps[5], rsa_dq = comps[6], rsa_iq = comps[7],
},
)
defer delete(der)
testing.expect_value(t, err, x509.Error.None)
cur: asn1.Cursor
asn1.cursor_init(&cur, der)
body, _ := asn1.read_sequence(&cur)
ver, _ := asn1.read_i64(&body)
testing.expect_value(t, ver, i64(0))
alg, _ := asn1.read_sequence(&body)
oid, _ := asn1.read_oid(&alg)
testing.expect_value(t, string(oid), "\x2A\x86\x48\x86\xF7\x0D\x01\x01\x01") // rsaEncryption
testing.expect_value(t, asn1.read_null(&alg), asn1.Error.None)
testing.expect_value(t, asn1.done(&alg), asn1.Error.None)
pk, _ := asn1.read_octet_string(&body)
testing.expect_value(t, asn1.done(&body), asn1.Error.None)
inner: asn1.Cursor
asn1.cursor_init(&inner, pk)
rpk, _ := asn1.read_sequence(&inner)
rver, _ := asn1.read_i64(&rpk)
testing.expect_value(t, rver, i64(0)) // two-prime
for want in comps {
got, ie := asn1.read_unsigned_integer_bytes(&rpk)
testing.expect_value(t, ie, asn1.Error.None)
testing.expect_value(t, string(got), string(want))
}
testing.expect_value(t, asn1.done(&rpk), asn1.Error.None) // exactly 9 integers
testing.expect_value(t, asn1.done(&inner), asn1.Error.None)
}
// private_key_clear wipes the secret bytes the struct's slices point at (not
// just the slice headers), and zeroes the struct.
@(test)
test_private_key_clear :: proc(t: ^testing.T) {
scalar := [4]byte{0xAA, 0xBB, 0xCC, 0xDD}
d := [3]byte{0x11, 0x22, 0x33}
p := [2]byte{0x44, 0x55}
k := x509.Private_Key {
algorithm = .RSA,
private = scalar[:],
rsa_d = d[:],
rsa_p = p[:],
}
x509.private_key_clear(&k)
// Secret buffers zeroed in place.
testing.expect(t, scalar == {0, 0, 0, 0}, "private wiped")
testing.expect(t, d == {0, 0, 0}, "rsa_d wiped")
testing.expect(t, p == {0, 0}, "rsa_p wiped")
// Struct headers dropped.
testing.expect(t, k.private == nil && k.rsa_d == nil && k.algorithm == .Unknown, "struct zeroed")
}
@(test)
test_parse_ec :: proc(t: ^testing.T) {
cert, err := x509.parse(EC_DER)
defer x509.destroy(&cert)
testing.expect_value(t, err, x509.Error.None)
testing.expect_value(t, cert.signature_algorithm, x509.Signature_Algorithm.ECDSA_SHA256)
testing.expect_value(t, cert.public_key_algorithm, x509.Public_Key_Algorithm.ECDSA_P256)
testing.expect_value(t, len(cert.ec_point), 65)
testing.expect_value(t, cert.ec_point[0], u8(0x04))
testing.expect_value(t, time.to_unix_seconds(cert.not_after), i64(2096650389)) // notAfter Jun 9 18:53:09 2036 GMT
testing.expect_value(t, cert.basic_constraints_valid, true)
testing.expect_value(t, cert.is_ca, false)
testing.expect_value(t, cert.has_key_usage, true)
testing.expect(t, .Digital_Signature in cert.key_usage)
testing.expect(t, .Key_Cert_Sign not_in cert.key_usage)
testing.expect_value(t, cert.has_ext_key_usage, true)
testing.expect(t, .Server_Auth in cert.ext_key_usage)
testing.expect(t, .Client_Auth not_in cert.ext_key_usage)
testing.expect_value(t, cert.eku_has_unknown, false)
testing.expect_value(t, len(cert.dns_names), 2)
testing.expect_value(t, cert.dns_names[0], "example.com")
testing.expect_value(t, cert.dns_names[1], "*.example.com")
testing.expect_value(t, len(cert.ip_addresses), 1)
testing.expect_value(t, len(cert.ip_addresses[0]), 16)
testing.expect_value(t, cert.ip_addresses[0][0], u8(0x20))
testing.expect_value(t, cert.ip_addresses[0][1], u8(0x01))
}
@(test)
test_parse_ed25519 :: proc(t: ^testing.T) {
cert, err := x509.parse(ED_DER)
defer x509.destroy(&cert)
testing.expect_value(t, err, x509.Error.None)
testing.expect_value(t, cert.signature_algorithm, x509.Signature_Algorithm.Ed25519)
testing.expect_value(t, cert.public_key_algorithm, x509.Public_Key_Algorithm.Ed25519)
testing.expect_value(t, len(cert.ec_point), 32)
testing.expect_value(t, len(cert.signature), 64)
}
// Serial 0 decodes to the single octet {0x00}, not empty (the
// openssl-aligned convention; see Certificate.serial).
@(test)
test_serial_zero :: proc(t: ^testing.T) {
cert, err := x509.parse(SERIAL_ZERO_DER)
defer x509.destroy(&cert)
testing.expect_value(t, err, x509.Error.None)
testing.expect_value(t, len(cert.serial), 1)
testing.expect_value(t, cert.serial[0], u8(0x00))
testing.expect_value(t, cert.is_ca, true)
}
@(test)
test_valid_at :: proc(t: ^testing.T) {
cert, err := x509.parse(RSA_DER)
defer x509.destroy(&cert)
testing.expect_value(t, err, x509.Error.None)
INSIDE := time.unix(1893456000, 0) // 2030-01-01Z
BEFORE := time.unix(1577836800, 0) // 2020-01-01Z
AFTER := time.unix(2208988800, 0) // 2040-01-01Z
testing.expect_value(t, x509.valid_at(&cert, INSIDE), true)
testing.expect_value(t, x509.valid_at(&cert, BEFORE), false)
testing.expect_value(t, x509.valid_at(&cert, AFTER), false)
// Boundaries are inclusive.
testing.expect_value(t, x509.valid_at(&cert, cert.not_before), true)
testing.expect_value(t, x509.valid_at(&cert, cert.not_after), true)
}
@(test)
test_verify_hostname :: proc(t: ^testing.T) {
ec, err := x509.parse(EC_DER)
defer x509.destroy(&ec)
testing.expect_value(t, err, x509.Error.None)
// Exact and case-insensitive.
testing.expect_value(t, x509.verify_hostname(&ec, "example.com"), x509.Error.None)
testing.expect_value(t, x509.verify_hostname(&ec, "EXAMPLE.COM"), x509.Error.None)
testing.expect_value(t, x509.verify_hostname(&ec, "example.com."), x509.Error.None)
// Wildcard: exactly one left-most label.
testing.expect_value(t, x509.verify_hostname(&ec, "api.example.com"), x509.Error.None)
testing.expect_value(t, x509.verify_hostname(&ec, "API.Example.Com"), x509.Error.None)
testing.expect_value(t, x509.verify_hostname(&ec, "a.b.example.com"), x509.Error.Hostname_Mismatch)
testing.expect_value(t, x509.verify_hostname(&ec, "example.org"), x509.Error.Hostname_Mismatch)
testing.expect_value(t, x509.verify_hostname(&ec, "com"), x509.Error.Hostname_Mismatch)
// IPv6 SAN 2001:db8::1.
testing.expect_value(t, x509.verify_hostname(&ec, "2001:db8::1"), x509.Error.None)
testing.expect_value(t, x509.verify_hostname(&ec, "2001:db8::2"), x509.Error.Hostname_Mismatch)
// IPv4 against the RSA cert (127.0.0.1 SAN).
rsa, rerr := x509.parse(RSA_DER)
defer x509.destroy(&rsa)
testing.expect_value(t, rerr, x509.Error.None)
testing.expect_value(t, x509.verify_hostname(&rsa, "127.0.0.1"), x509.Error.None)
testing.expect_value(t, x509.verify_hostname(&rsa, "127.0.0.2"), x509.Error.Hostname_Mismatch)
testing.expect_value(t, x509.verify_hostname(&rsa, "localhost"), x509.Error.None)
// Ed cert has no SANs at all.
ed, eerr := x509.parse(ED_DER)
defer x509.destroy(&ed)
testing.expect_value(t, eerr, x509.Error.None)
testing.expect_value(t, x509.verify_hostname(&ed, "ed.test"), x509.Error.No_SAN)
}
// verify_hostname must accept any bytes in the host string and return a
// result (never fault), whatever odd punctuation the input contains. In
// normal use the host comes from the application, not the certificate;
// this just pins down robust behaviour on unusual inputs.
@(test)
test_verify_hostname_hostile_input :: proc(t: ^testing.T) {
ec, err := x509.parse(EC_DER)
defer x509.destroy(&ec)
testing.expect_value(t, err, x509.Error.None)
hostile := []string{
"]:80",
"]:",
"]",
"[",
"[]",
"::::",
":",
"]:a]:b",
"\x00\x01\x02",
"foo]:bar",
"[not-closed",
"*.*.*",
}
for h in hostile {
// Any result is acceptable; not panicking is the point.
_ = x509.verify_hostname(&ec, h)
}
}
// Every truncation of a real certificate must fail cleanly.
@(test)
test_truncation_sweep :: proc(t: ^testing.T) {
for n in 0 ..< len(EC_DER) {
cert, err := x509.parse(EC_DER[:n])
if err == .None {
x509.destroy(&cert)
}
testing.expect(t, err != .None, "truncated certificate must be rejected")
}
// Trailing garbage is rejected too.
{
grown := make([]byte, len(EC_DER) + 1)
defer delete(grown)
copy(grown, EC_DER)
_, err := x509.parse(grown)
testing.expect(t, err != .None, "trailing bytes must be rejected")
}
}
// ---- signature & chain verification ----
@(test)
test_verify_signature_ec :: proc(t: ^testing.T) {
leaf, e1 := x509.parse(EC_CHAIN_LEAF); defer x509.destroy(&leaf)
inter, e2 := x509.parse(EC_CHAIN_INTER); defer x509.destroy(&inter)
root, e3 := x509.parse(EC_CHAIN_ROOT); defer x509.destroy(&root)
testing.expect_value(t, e1, x509.Error.None)
testing.expect_value(t, e2, x509.Error.None)
testing.expect_value(t, e3, x509.Error.None)
// Correct issuers verify; cross-pairings do not.
testing.expect_value(t, x509.verify_signature(&leaf, &inter), x509.Error.None)
testing.expect_value(t, x509.verify_signature(&inter, &root), x509.Error.None)
testing.expect_value(t, x509.verify_signature(&leaf, &root), x509.Error.Signature_Invalid)
}
@(test)
test_verify_signature_ed :: proc(t: ^testing.T) {
leaf, e1 := x509.parse(ED_CHAIN_LEAF); defer x509.destroy(&leaf)
root, e2 := x509.parse(ED_CHAIN_ROOT); defer x509.destroy(&root)
testing.expect_value(t, e1, x509.Error.None)
testing.expect_value(t, e2, x509.Error.None)
testing.expect_value(t, x509.verify_signature(&leaf, &root), x509.Error.None)
}
// RSA PKCS#1 v1.5 verification is wired through to core:crypto/rsa. rsa.der is
// a self-signed RSA-SHA256 CA, so it is its own issuer and its signature must
// verify; pairing an RSA-signed cert with a non-RSA issuer key is unsupported.
@(test)
test_verify_signature_rsa :: proc(t: ^testing.T) {
rsa, e1 := x509.parse(RSA_DER); defer x509.destroy(&rsa)
ec, e2 := x509.parse(EC_DER); defer x509.destroy(&ec)
testing.expect_value(t, e1, x509.Error.None)
testing.expect_value(t, e2, x509.Error.None)
testing.expect_value(t, x509.verify_signature(&rsa, &rsa), x509.Error.None)
testing.expect_value(t, x509.verify_signature(&rsa, &ec), x509.Error.Unsupported_Algorithm)
}
// SHA-1 signatures are deprecated and must be rejected, not verified (RFC 9155).
// The cert still parses (RSA_SHA1 is recognized for identification), but
// verify_signature refuses it rather than returning .None or .Signature_Invalid.
@(test)
test_verify_signature_sha1_rejected :: proc(t: ^testing.T) {
c, err := x509.parse(RSA_SHA1_DER); defer x509.destroy(&c)
testing.expect_value(t, err, x509.Error.None)
testing.expect_value(t, c.signature_algorithm, x509.Signature_Algorithm.RSA_SHA1)
testing.expect_value(t, x509.verify_signature(&c, &c), x509.Error.Unsupported_Algorithm)
}
// RSA-PSS end-to-end: the parser decodes RSASSA-PSS-params (hash, MGF, salt)
// off the signatureAlgorithm, and verify_signature feeds them to
// rsa.verify_pss. pss.der is a self-signed RSA-PSS CA, so it is its own issuer.
@(test)
test_verify_signature_rsa_pss :: proc(t: ^testing.T) {
pss, err := x509.parse(PSS_DER); defer x509.destroy(&pss)
testing.expect_value(t, err, x509.Error.None)
testing.expect_value(t, pss.signature_algorithm, x509.Signature_Algorithm.RSA_PSS)
// Decoded parameters (SHA-256, MGF1-SHA-256, salt length 32).
testing.expect_value(t, pss.pss_hash, hash.Algorithm.SHA256)
testing.expect_value(t, pss.pss_mgf_hash, hash.Algorithm.SHA256)
testing.expect_value(t, pss.pss_salt_len, 32)
// The PSS signature verifies against the embedded key.
testing.expect_value(t, x509.verify_signature(&pss, &pss), x509.Error.None)
}
@(test)
test_verify_chain_ec :: proc(t: ^testing.T) {
leaf, e1 := x509.parse(EC_CHAIN_LEAF); defer x509.destroy(&leaf)
inter, e2 := x509.parse(EC_CHAIN_INTER); defer x509.destroy(&inter)
root, e3 := x509.parse(EC_CHAIN_ROOT); defer x509.destroy(&root)
testing.expect_value(t, e1, x509.Error.None)
testing.expect_value(t, e2, x509.Error.None)
testing.expect_value(t, e3, x509.Error.None)
opts := x509.Verify_Options{
roots = {&root},
intermediates = {&inter},
current_time = time.unix(CHAIN_NOW, 0),
dns_name = "leaf.example.com",
required_eku = .Server_Auth,
}
chain, err := x509.verify_chain(&leaf, opts)
defer delete(chain)
testing.expect_value(t, err, x509.Error.None)
testing.expect_value(t, len(chain), 3)
if len(chain) == 3 {
testing.expect(t, chain[0] == &leaf, "chain[0] is the leaf")
testing.expect(t, chain[1] == &inter, "chain[1] is the intermediate")
testing.expect(t, chain[2] == &root, "chain[2] is the anchor")
}
}
@(test)
test_verify_chain_ed :: proc(t: ^testing.T) {
leaf, e1 := x509.parse(ED_CHAIN_LEAF); defer x509.destroy(&leaf)
root, e2 := x509.parse(ED_CHAIN_ROOT); defer x509.destroy(&root)
testing.expect_value(t, e1, x509.Error.None)
testing.expect_value(t, e2, x509.Error.None)
opts := x509.Verify_Options{
roots = {&root},
current_time = time.unix(CHAIN_NOW, 0),
dns_name = "ed-leaf.example.com",
}
chain, err := x509.verify_chain(&leaf, opts)
defer delete(chain)
testing.expect_value(t, err, x509.Error.None)
testing.expect_value(t, len(chain), 2)
}
@(test)
test_verify_chain_negatives :: proc(t: ^testing.T) {
leaf, _ := x509.parse(EC_CHAIN_LEAF); defer x509.destroy(&leaf)
inter, _ := x509.parse(EC_CHAIN_INTER); defer x509.destroy(&inter)
root, _ := x509.parse(EC_CHAIN_ROOT); defer x509.destroy(&root)
other, _ := x509.parse(EC_OTHER_ROOT); defer x509.destroy(&other)
roots := []^x509.Certificate{&root}
inters := []^x509.Certificate{&inter}
// Missing the intermediate: cannot bridge leaf -> root.
{
opts := x509.Verify_Options{roots = roots, 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)
}
// Untrusted anchor.
{
opts := x509.Verify_Options{roots = {&other}, intermediates = inters, 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)
}
// Expired (now is past the leaf's notAfter).
{
opts := x509.Verify_Options{roots = roots, intermediates = inters, current_time = time.unix(CHAIN_AFTER, 0)}
c, err := x509.verify_chain(&leaf, opts); delete(c)
testing.expect_value(t, err, x509.Error.Expired)
}
// Not yet valid (now precedes the leaf's notBefore).
{
opts := x509.Verify_Options{roots = roots, intermediates = inters, current_time = time.unix(CHAIN_BEFORE, 0)}
c, err := x509.verify_chain(&leaf, opts); delete(c)
testing.expect_value(t, err, x509.Error.Not_Yet_Valid)
}
// Hostname mismatch on an otherwise-valid chain.
{
opts := x509.Verify_Options{roots = roots, intermediates = inters, current_time = time.unix(CHAIN_NOW, 0), dns_name = "wrong.example.com"}
c, err := x509.verify_chain(&leaf, opts); delete(c)
testing.expect_value(t, err, x509.Error.Hostname_Mismatch)
}
// Required EKU the leaf does not carry (it has serverAuth only).
{
opts := x509.Verify_Options{roots = roots, intermediates = inters, current_time = time.unix(CHAIN_NOW, 0), required_eku = .Client_Auth}
c, err := x509.verify_chain(&leaf, opts); delete(c)
testing.expect_value(t, err, x509.Error.Incompatible_Usage)
}
}
// The trust anchor is validated like any issuer (matching Go/OpenSSL):
// an expired root is rejected, so a chain whose only anchor is expired
// fails to build. (Resilience to expired roots comes from offering an
// alternate valid anchor, not from skipping the check.)
@(test)
test_verify_chain_expired_anchor_rejected :: proc(t: ^testing.T) {
leaf, e1 := x509.parse(EC_EXP_LEAF); defer x509.destroy(&leaf)
root, e2 := x509.parse(EC_EXPIRED_ROOT); defer x509.destroy(&root)
testing.expect_value(t, e1, x509.Error.None)
testing.expect_value(t, e2, x509.Error.None)
// Sanity: the anchor really is expired at CHAIN_NOW.
testing.expect_value(t, x509.valid_at(&root, time.unix(CHAIN_NOW, 0)), false)
opts := x509.Verify_Options{roots = {&root}, current_time = time.unix(CHAIN_NOW, 0)}
chain, err := x509.verify_chain(&leaf, opts)
defer delete(chain)
testing.expect_value(t, err, x509.Error.Unknown_Authority)
}
// EKU nesting (surfaced by x509-limbo): an intermediate constrained to
// emailProtection must not be usable to issue a serverAuth leaf when the
// caller requires serverAuth. Without an EKU requirement the chain still
// builds (EKU is unconstrained).
@(test)
test_verify_chain_eku_nesting :: proc(t: ^testing.T) {
root, _ := x509.parse(NEG_ROOT); defer x509.destroy(&root)
inter, _ := x509.parse(NEG_EKU_INTER); defer x509.destroy(&inter)
leaf, _ := x509.parse(NEG_EKU_LEAF); defer x509.destroy(&leaf)
now := time.unix(CHAIN_NOW, 0)
// No EKU requirement: the email-only intermediate does not block it.
{
opts := x509.Verify_Options{roots = {&root}, intermediates = {&inter}, current_time = now}
c, err := x509.verify_chain(&leaf, opts)
defer delete(c)
testing.expect_value(t, err, x509.Error.None)
testing.expect_value(t, len(c), 3)
}
// Requiring serverAuth: the intermediate's emailProtection-only EKU
// forbids it, so the path is rejected.
{
opts := x509.Verify_Options{roots = {&root}, intermediates = {&inter}, current_time = now, required_eku = .Server_Auth}
c, err := x509.verify_chain(&leaf, opts); delete(c)
testing.expect_value(t, err, x509.Error.Incompatible_Usage)
}
}
// EKU nesting must be enforced DURING path-building The two intermediates
// share a subject DN and key (so the leaf signature-verifies through
// either), differing only in EKU, and we offer them in BOTH orders, the
// result must be the same successful chain regardless of ordering.
@(test)
test_verify_chain_eku_alt_path :: proc(t: ^testing.T) {
root, _ := x509.parse(NEG_ROOT); defer x509.destroy(&root)
bad, _ := x509.parse(EKU_ALT_BAD); defer x509.destroy(&bad)
good, _ := x509.parse(EKU_ALT_GOOD); defer x509.destroy(&good)
leaf, _ := x509.parse(EKU_ALT_LEAF); defer x509.destroy(&leaf)
now := time.unix(CHAIN_NOW, 0)
// emailProtection-only intermediate offered first:
{
opts := x509.Verify_Options{
roots = {&root},
intermediates = {&bad, &good},
current_time = now,
required_eku = .Server_Auth,
}
c, err := x509.verify_chain(&leaf, opts)
defer delete(c)
testing.expect_value(t, err, x509.Error.None)
testing.expect_value(t, len(c), 3)
if len(c) == 3 {
testing.expect(t, c[1] == &good, "must select the serverAuth-permitting intermediate")
}
}
// serverAuth intermediate offered first: same successful result.
{
opts := x509.Verify_Options{
roots = {&root},
intermediates = {&good, &bad},
current_time = now,
required_eku = .Server_Auth,
}
c, err := x509.verify_chain(&leaf, opts)
defer delete(c)
testing.expect_value(t, err, x509.Error.None)
testing.expect_value(t, len(c), 3)
if len(c) == 3 {
testing.expect(t, c[1] == &good, "must select the serverAuth-permitting intermediate")
}
}
}
// 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_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)
testing.expect_value(t, err, x509.Error.Unknown_Authority)
}
// A mutually cross-signed CA cycle (neither anchor trusted) must not
// hang the path search: the cycle/depth/signature-budget bounds make it
// terminate with Unknown_Authority. If this test ever hangs rather than
// failing, the search lost its bound. (CVE-2024-0567 shape.)
@(test)
test_verify_chain_cross_signed_cycle :: proc(t: ^testing.T) {
a, _ := x509.parse(CYC_A); defer x509.destroy(&a)
b, _ := x509.parse(CYC_B); defer x509.destroy(&b)
a_by_b, _ := x509.parse(CYC_A_BY_B); defer x509.destroy(&a_by_b)
b_by_a, _ := x509.parse(CYC_B_BY_A); defer x509.destroy(&b_by_a)
leaf, _ := x509.parse(CYC_LEAF); defer x509.destroy(&leaf)
// Every cross-cert and self-signed CA is offered as an intermediate;
// no trust anchor is supplied, so the loop can never terminate at a
// root.
opts := x509.Verify_Options{
intermediates = {&a, &b, &a_by_b, &b_by_a},
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)
}
// End-to-end: the self-signed RSA-SHA256 CA validates as a chain with itself
// as the trust anchor — the RSA PKCS#1 v1.5 signature check now runs.
@(test)
test_verify_chain_rsa_selfsigned :: proc(t: ^testing.T) {
rsa, err := x509.parse(RSA_DER); defer x509.destroy(&rsa)
testing.expect_value(t, err, x509.Error.None)
opts := x509.Verify_Options{roots = {&rsa}, current_time = time.unix(CHAIN_NOW, 0)}
c, verr := x509.verify_chain(&rsa, opts); defer delete(c)
testing.expect_value(t, verr, x509.Error.None)
testing.expect(t, len(c) >= 1, "RSA self-signed chain built")
}
// Each negative chain must be rejected for the right structural reason
// (all collapse to Unknown_Authority: no usable path to the anchor).
// Together these exercise the RFC 5280 6.1.4 issuer checks — keyCertSign,
// cA, intermediate validity, pathLenConstraint — which the happy-path
// tests never trip.
@(test)
test_verify_chain_bad_issuers :: proc(t: ^testing.T) {
root, _ := x509.parse(NEG_ROOT); defer x509.destroy(&root)
now := time.unix(CHAIN_NOW, 0)
// Intermediate is a CA but its KeyUsage omits keyCertSign.
{
inter, _ := x509.parse(NEG_NOKCS_INTER); defer x509.destroy(&inter)
leaf, _ := x509.parse(NEG_NOKCS_LEAF); defer x509.destroy(&leaf)
opts := x509.Verify_Options{roots = {&root}, intermediates = {&inter}, current_time = now}
c, err := x509.verify_chain(&leaf, opts); delete(c)
testing.expect_value(t, err, x509.Error.Unknown_Authority)
}
// Intermediate carries basicConstraints cA=FALSE.
{
inter, _ := x509.parse(NEG_NOTCA_INTER); defer x509.destroy(&inter)
leaf, _ := x509.parse(NEG_NOTCA_LEAF); defer x509.destroy(&leaf)
opts := x509.Verify_Options{roots = {&root}, intermediates = {&inter}, current_time = now}
c, err := x509.verify_chain(&leaf, opts); delete(c)
testing.expect_value(t, err, x509.Error.Unknown_Authority)
}
// Intermediate is expired (validity 2010-2015): unlike the trust
// anchor, an intermediate's validity IS enforced.
{
inter, _ := x509.parse(NEG_EXPINTER); defer x509.destroy(&inter)
leaf, _ := x509.parse(NEG_EXPINTER_LEAF); defer x509.destroy(&leaf)
testing.expect_value(t, x509.valid_at(&inter, now), false)
opts := x509.Verify_Options{roots = {&root}, intermediates = {&inter}, current_time = now}
c, err := x509.verify_chain(&leaf, opts); delete(c)
testing.expect_value(t, err, x509.Error.Unknown_Authority)
}
// pathlen:0 on pl_a forbids pl_b (a CA) beneath it.
{
a, _ := x509.parse(NEG_PL_A); defer x509.destroy(&a)
b, _ := x509.parse(NEG_PL_B); defer x509.destroy(&b)
leaf, _ := x509.parse(NEG_PL_LEAF); defer x509.destroy(&leaf)
opts := x509.Verify_Options{roots = {&root}, intermediates = {&a, &b}, current_time = now}
c, err := x509.verify_chain(&leaf, opts); delete(c)
testing.expect_value(t, err, x509.Error.Unknown_Authority)
}
}
// Single-byte tamper sweep: no one-byte mutation of a signed certificate
// may BOTH parse cleanly AND verify against its true issuer. The whole
// TBSCertificate is signature-covered and the framing/algorithm/signature
// bytes are structurally checked, so every flip must be caught by parse
// or by the signature. A survivor would mean a trusted region the
// signature does not actually protect.
@(test)
test_verify_signature_tamper_sweep :: proc(t: ^testing.T) {
check :: proc(t: ^testing.T, der, issuer_der: []byte) {
issuer, ierr := x509.parse(issuer_der)
defer x509.destroy(&issuer)
testing.expect_value(t, ierr, x509.Error.None)
buf := make([]byte, len(der))
defer delete(buf)
for pos in 0 ..< len(der) {
copy(buf, der)
buf[pos] ~= 0x01
cert, perr := x509.parse(buf)
if perr == .None {
accepted := x509.verify_signature(&cert, &issuer) == .None
x509.destroy(&cert)
testing.expectf(t, !accepted, "tampered byte %d parsed and verified", pos)
}
}
}
check(t, EC_CHAIN_LEAF, EC_CHAIN_INTER)
check(t, ED_CHAIN_LEAF, ED_CHAIN_ROOT)
}

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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
tests/core/crypto/x509/testdata/ec.der vendored Normal file

Binary file not shown.

BIN
tests/core/crypto/x509/testdata/ed.der vendored Normal file

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.

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.

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.

Binary file not shown.

Binary file not shown.

BIN
tests/core/crypto/x509/testdata/pss.der vendored Normal file

Binary file not shown.

BIN
tests/core/crypto/x509/testdata/rsa.der vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,46 @@
### x509-limbo — core/crypto/x509 path-validation conformance
Differential-tests `core:crypto/x509` `verify_chain` against the
[x509-limbo][1] corpus (~9.8k adversarial path-validation testcases,
~30k certificates). Structured like `tests/core/crypto/wycheproof`: a
dedicated package whose corpus lives, gitignored, under
`tests/core/assets/`.
Fetch the corpus:
```
curl -sL https://raw.githubusercontent.com/C2SP/x509-limbo/main/limbo.json \
-o tests/core/assets/X509-Limbo/limbo.json
```
Run:
```
odin test tests/core/crypto/x509_limbo -o:speed
```
When the corpus is absent the suite logs a notice and passes, so a plain
`odin test` sweep never breaks.
Each case carries its own `expected_result`, so the harness needs no
external oracle. Reviewed, documented divergences are listed by testcase id
in `allow.odin`:
- `ACCEPT_ALLOW` — cases limbo marks FAILURE that we accept. All are policy
layers outside RFC 5280 path validation that this package intentionally
does not enforce (revocation, CABF Baseline Requirements issuance
conformance, per-extension criticality/presence policy, lenient
serials/dNSName syntax, name-constraint DoS bailout).
- `REJECT_ALLOW` — cases limbo accepts that we reject (the safe, stricter
direction), all using a name-constraint form we fail closed on
(directoryName / rfc822Name / otherName).
Any diverging case NOT allow-listed fails the test — the on-change
regression gate, in particular for the security-critical "we accept a chain
limbo rejects" direction. When bumping the corpus, run once, review each new
divergence, and add it to the appropriate list (or fix the finding).
Covered: RSA (PKCS#1 v1.5 + PSS), ECDSA P-256/P-384, Ed25519, and dNSName /
iPAddress name constraints. Chains using P-521 / Ed448 / DSA are skipped.
[1]: https://github.com/C2SP/x509-limbo

View File

@@ -0,0 +1,84 @@
package test_x509_limbo
// Reviewed & documented divergences from the x509-limbo corpus, keyed by
// testcase id. Any diverging case NOT listed here is treated as a REGRESSION
// and fails the test (see x509_limbo.odin).
//
// ACCEPT_ALLOW: cases limbo marks FAILURE that we ACCEPT. Every one is a policy
// layer OUTSIDE RFC 5280 path validation that this package intentionally does
// not enforce — revocation (crl::*), CABF Baseline Requirements issuance
// conformance (webpki::cn/eku/forbidden-rsa/san/aki, ca-as-leaf), per-extension
// criticality and presence policy (aki/ski/pc/san, nc must-be-critical), lenient
// serials / dNSName syntax, and the name-constraint DoS bailout. None is a
// path-validation integrity bug (no accepted bad signature or broken chain).
ACCEPT_ALLOW := []string {
"crl::crlnumber-critical",
"crl::crlnumber-missing",
"crl::issuer-missing-crlsign",
"crl::revoked-certificate-with-crl",
"pathlen::max-chain-depth-0-exhausted",
"pathlen::max-chain-depth-1-exhausted",
"pathological::nc-dos-1",
"pathological::nc-dos-2",
"rfc5280::aki::critical-aki",
"rfc5280::aki::cross-signed-root-missing-aki",
"rfc5280::aki::intermediate-missing-aki",
"rfc5280::aki::leaf-missing-aki",
"rfc5280::ca-empty-subject",
"rfc5280::leaf-ku-keycertsign",
"rfc5280::nc::invalid-dnsname-leading-period",
"rfc5280::nc::permitted-dns-match-noncritical",
"rfc5280::pc::ica-noncritical-pc",
"rfc5280::root-inconsistent-ca-extensions",
"rfc5280::root-missing-basic-constraints",
"rfc5280::root-non-critical-basic-constraints",
"rfc5280::san::noncritical-with-empty-subject",
"rfc5280::san::underscore-dns",
"rfc5280::serial::too-long",
"rfc5280::serial::zero",
"rfc5280::ski::critical-ski",
"rfc5280::ski::intermediate-missing-ski",
"rfc5280::ski::root-missing-ski",
"webpki::aki::root-with-aki-all-fields",
"webpki::aki::root-with-aki-authoritycertissuer",
"webpki::aki::root-with-aki-authoritycertserialnumber",
"webpki::aki::root-with-aki-missing-keyidentifier",
"webpki::aki::root-with-aki-ski-mismatch",
"webpki::ca-as-leaf",
"webpki::cn::case-mismatch",
"webpki::cn::ipv4-hex-mismatch",
"webpki::cn::ipv4-leading-zeros-mismatch",
"webpki::cn::ipv6-non-rfc5952-mismatch",
"webpki::cn::ipv6-uncompressed-mismatch",
"webpki::cn::ipv6-uppercase-mismatch",
"webpki::cn::not-in-san",
"webpki::cn::punycode-not-in-san",
"webpki::cn::utf8-vs-punycode-mismatch",
"webpki::ee-basicconstraints-ca",
"webpki::eku::ee-anyeku",
"webpki::eku::ee-critical-eku",
"webpki::eku::ee-without-eku",
"webpki::eku::root-has-eku",
"webpki::forbidden-rsa-key-not-divisible-by-8-in-leaf",
"webpki::forbidden-rsa-not-divisible-by-8-in-root",
"webpki::forbidden-weak-rsa-in-leaf",
"webpki::forbidden-weak-rsa-key-in-root",
"webpki::malformed-aia",
"webpki::san::public-suffix-multi-label-wildcard-san",
"webpki::san::public-suffix-private-namespace-wildcard-san",
"webpki::san::public-suffix-wildcard-san",
"webpki::san::san-critical-with-nonempty-subject",
}
// REJECT_ALLOW: cases limbo ACCEPTS that we REJECT.
// All use a name-constraint GeneralName form we fail closed on:
// directoryName, rfc822Name (email), or otherName (These types are not implemented)
REJECT_ALLOW := []string {
"rfc5280::nc::nc-forbids-othername-noop",
"rfc5280::nc::nc-permits-email-domain",
"rfc5280::nc::nc-permits-email-exact",
"rfc5280::nc::nc-permits-email-literal-asterisk-exact-match",
"rfc5280::nc::nc-permits-email-literal-double-asterisk",
"rfc5280::nc::nc-permits-email-literal-mid-asterisk",
"rfc5280::nc::permitted-dn-match",
}

View File

@@ -0,0 +1,39 @@
package test_x509_limbo
import "core:encoding/json"
import "core:os"
// The subset of the C2SP x509-limbo limbo.json schema this harness consumes.
// See https://github.com/C2SP/x509-limbo.
Limbo :: struct {
testcases: []Testcase `json:"testcases"`,
}
Testcase :: struct {
id: string `json:"id"`,
expected_result: string `json:"expected_result"`, // "SUCCESS" | "FAILURE"
validation_kind: string `json:"validation_kind"`, // "SERVER" | "CLIENT" | ...
validation_time: string `json:"validation_time"`, // RFC 3339, or "" when null
peer_certificate: string `json:"peer_certificate"`, // the leaf, PEM
untrusted_intermediates: []string `json:"untrusted_intermediates"`, // PEM
trusted_certs: []string `json:"trusted_certs"`, // the roots, PEM
expected_peer_name: Peer_Name `json:"expected_peer_name"`,
}
Peer_Name :: struct {
kind: string `json:"kind"`, // "DNS" | "IP" | ...
value: string `json:"value"`,
}
// load reads and parses limbo.json at `path` into `dst`, allocating into the
// current context allocator. Returns false when the file is absent (the caller
// then skips the suite) or does not parse.
load :: proc(dst: ^Limbo, path: string) -> bool {
raw, err := os.read_entire_file_from_path(path, context.allocator)
if err != os.ERROR_NONE {
return false
}
defer delete(raw)
return json.unmarshal(raw, dst) == nil
}

View File

@@ -0,0 +1,235 @@
package test_x509_limbo
// Differential conformance harness for core:crypto/x509 verify_chain against the
// C2SP x509-limbo corpus.
//
// Fetch the corpus (once):
// curl -sL https://raw.githubusercontent.com/C2SP/x509-limbo/main/limbo.json \
// -o tests/core/assets/X509-Limbo/limbo.json
// Run:
// odin test tests/core/crypto/x509_limbo -o:speed
//
// When the corpus is absent the suite logs a notice and passes, so a plain
// `odin test` does not break. Reviewed divergences are in allow.odin.
// Any test case failure not in the allow.odin should be considered a regression.
import "core:log"
import "core:mem"
import "core:os"
import "core:slice"
import "core:testing"
import "core:time"
import "core:crypto/x509"
import "core:encoding/pem"
BASE_PATH :: ODIN_ROOT + "tests/core/assets/X509-Limbo"
// 2024-01-01T00:00:00Z, x509-limbo's default validation instant for cases whose
// validation_time is null.
DEFAULT_TIME :: i64(1_704_067_200)
// Arena for the parsed 41 MB corpus (strings + case structs).
CORPUS_ARENA_SIZE :: 5 * 41 * (1024 * 1024)
Bucket :: enum {
Agree, // our verdict matches expected_result
Skip, // the chain uses an algorithm we do not verify (P-521 / Ed448 / DSA)
We_Accept_They_Reject, // DANGEROUS: expected FAILURE, we accepted
We_Reject_They_Accept, // safe/stricter: expected SUCCESS, we rejected
}
@(test)
test_x509_limbo :: proc(t: ^testing.T) {
arena: mem.Arena
backing, berr := make([]byte, CORPUS_ARENA_SIZE)
if berr != nil {
log.errorf("x509-limbo: could not reserve %d bytes", CORPUS_ARENA_SIZE)
return
}
defer delete(backing)
mem.arena_init(&arena, backing)
context.allocator = mem.arena_allocator(&arena)
path, _ := os.join_path([]string{BASE_PATH, "limbo.json"}, context.allocator)
corpus: Limbo
if !load(&corpus, path) {
log.infof("x509-limbo corpus not found at %s; skipping (fetch from C2SP/x509-limbo)", path)
return
}
counts: [Bucket]int
// These outlive the per-case temp resets, so back them with the corpus arena.
unexpected_accept := make([dynamic]string)
unexpected_reject := make([dynamic]string)
for &tc in corpus.testcases {
bucket := run_case(&tc)
counts[bucket] += 1
#partial switch bucket {
case .We_Accept_They_Reject:
if !slice.contains(ACCEPT_ALLOW, tc.id) {
append(&unexpected_accept, tc.id)
}
case .We_Reject_They_Accept:
if !slice.contains(REJECT_ALLOW, tc.id) {
append(&unexpected_reject, tc.id)
}
}
free_all(context.temp_allocator)
}
log.infof(
"x509-limbo: %d cases | agree %d | skip %d | we-accept/they-reject %d | we-reject/they-accept %d",
len(corpus.testcases),
counts[.Agree],
counts[.Skip],
counts[.We_Accept_They_Reject],
counts[.We_Reject_They_Accept],
)
// Security-critical gate: no un-reviewed chain we accept that limbo rejects.
for id in unexpected_accept {
testing.expectf(t, false, "x509-limbo REGRESSION (we accept, limbo rejects): %s", id)
}
// Over-rejection gate (safe direction, but still a reviewed-set change).
for id in unexpected_reject {
testing.expectf(t, false, "x509-limbo over-rejection (we reject, limbo accepts): %s", id)
}
}
// run_case parses a testcase's certificates, runs verify_chain, and buckets the
// verdict against the case's expected_result. All per-case allocation uses the
// temp allocator, reset by the caller after each case.
run_case :: proc(tc: ^Testcase) -> Bucket {
expect_ok := tc.expected_result == "SUCCESS"
// Fixed backing keeps ^Certificate addresses stable; chains are tiny.
store: [64]x509.Certificate
n := 0
leaf: ^x509.Certificate
inters:= make([dynamic]^x509.Certificate,context.temp_allocator)
roots:= make([dynamic]^x509.Certificate,context.temp_allocator)
// A cert that fails to parse (or overflows the backing) means we reject.
if lp := _add_pems(store[:], &n, tc.peer_certificate, nil); lp == nil {
return _verdict(false, expect_ok)
} else {
leaf = lp
}
for s in tc.untrusted_intermediates {
if _add_pems(store[:], &n, s, &inters) == nil {
return _verdict(false, expect_ok)
}
}
for s in tc.trusted_certs {
if _add_pems(store[:], &n, s, &roots) == nil {
return _verdict(false, expect_ok)
}
}
// Skip chains that use an algorithm the verifier cannot check; verify_chain
// would report Unsupported_Algorithm, which is neither accept nor reject.
for &c in store[:n] {
if !_supported_key(c.public_key_algorithm) {
return .Skip
}
}
if !_supported_sig(leaf.signature_algorithm) {
return .Skip
}
for c in inters {
if !_supported_sig(c.signature_algorithm) {
return .Skip
}
}
now := DEFAULT_TIME
if tc.validation_time != "" {
if tm, consumed := time.rfc3339_to_time_utc(tc.validation_time); consumed > 0 {
now = time.to_unix_seconds(tm)
}
}
opts := x509.Verify_Options {
roots = roots[:],
intermediates = inters[:],
current_time = time.unix(now, 0),
}
if tc.expected_peer_name.kind != "" && tc.expected_peer_name.value != "" {
opts.dns_name = tc.expected_peer_name.value
}
// limbo's SERVER profile implies the serverAuth EKU (webpki / CABF).
if tc.validation_kind == "SERVER" {
opts.required_eku = .Server_Auth
}
// The chain is temp-allocated; the caller's per-case free_all reclaims it.
_, verr := x509.verify_chain(leaf, opts, context.temp_allocator)
return _verdict(verr == .None, expect_ok)
}
// _add_pems decodes every PEM block in `s` into `store` (bumping `n`), appending
// each parsed cert's pointer to `out` when non-nil, and returns the first cert's
// pointer (nil on any parse failure or backing overflow). A single limbo field
// may hold a bundle of concatenated certificates.
_add_pems :: proc(
store: []x509.Certificate,
n: ^int,
s: string,
out: ^[dynamic]^x509.Certificate,
) -> ^x509.Certificate {
first: ^x509.Certificate
data := transmute([]byte)s
for len(data) > 0 {
blk, rest, derr := pem.decode(data, context.temp_allocator)
if derr != nil || blk == nil {
break // no more blocks
}
data = rest
if n^ >= len(store) {
return nil
}
c, perr := x509.parse(pem.block_bytes(blk), context.temp_allocator)
if perr != .None {
return nil
}
store[n^] = c
p := &store[n^]
n^ += 1
if first == nil {
first = p
}
if out != nil {
append(out, p)
}
}
return first
}
_verdict :: proc(our_ok, expect_ok: bool) -> Bucket {
switch {
case our_ok == expect_ok:
return .Agree
case our_ok && !expect_ok:
return .We_Accept_They_Reject
case:
return .We_Reject_They_Accept
}
}
_supported_key :: proc(a: x509.Public_Key_Algorithm) -> bool {
return a == .RSA || a == .ECDSA_P256 || a == .ECDSA_P384 || a == .Ed25519
}
_supported_sig :: proc(a: x509.Signature_Algorithm) -> bool {
#partial switch a {
case .RSA_SHA1, .RSA_SHA256, .RSA_SHA384, .RSA_SHA512, .RSA_PSS:
return true
case .ECDSA_SHA256, .ECDSA_SHA384, .ECDSA_SHA512, .Ed25519:
return true
}
return false
}

View File

@@ -12,7 +12,7 @@ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
# The OpenSSL CLI tool installed can be used to easily generate digests:
# $ openssl sha3-512 -mac HMAC -macopt key:"$HMAC_KEY" $FILE_NAME
TEST_SUITES = ['PNG', 'XML', 'BMP', 'JPG', 'Wycheproof', 'Noise']
TEST_SUITES = ['PNG', 'XML', 'BMP', 'JPG', 'Wycheproof', 'Noise', 'X509-Limbo']
ASSETS_BASE_URL = "https://raw.githubusercontent.com/odin-lang/test-assets/master/{}/{}"
HMAC_KEY = "https://odin-lang.org"
HMAC_HASH = hashlib.sha3_512
@@ -635,6 +635,8 @@ HMAC_DIGESTS = {
'cacophony.txt': "d2c492f02287ee562a9cf43a4cd2a055f5235734779d091bc404aede4c37a552029e76c12e873632e52423804ad24b86138cd3d4fe54506108571d9f2f925033",
'noise-c-basic.txt': "7e25c5d692c53dd045060f27a562332178ca030d17687fc2ab58216b4298fb577ddc9d703db11c920fb04c28604fdc1a30337f58aad3617e07201a62c3a9b295",
'snow.txt': "35088303de90e6bac22656e1e0ac4a5ea73d8cdb5ada23a078d703e14714ffe19e08437bf46108e0419acd8b5be6fa797a68a621c20cdd063d94a4a970aa8634",
'limbo.json': "640a07beb54c7b409d54a7250854c735e92ebddc648c108c053c34d01fa249d844f659e2beadf0cfd583fbe3cda5acda8148e98cdef5388a5387d25316ceaee3",
}
def try_download_file(url, out_file):

View File

@@ -0,0 +1,222 @@
package test_core_asn1
// Deterministic structure-aware fuzzing for the DER reader. The test
// runner seeds context.random_generator and logs the seed, so any
// failure reproduces with -define:ODIN_TEST_RANDOM_SEED=n.
//
// The load-bearing invariant: DER is canonical, so for any element the
// strict reader ACCEPTS, re-encoding the header from (tag, len) must
// reproduce the input bytes exactly. Any acceptance of a non-minimal
// encoding fails the oracle without needing a crash.
import "core:bytes"
import "core:encoding/asn1"
import "core:math/rand"
import "core:testing"
FUZZ_RANDOM_ITERS :: 4096
FUZZ_MUTATE_ITERS :: 2048
FUZZ_MAX_INPUT :: 96
FUZZ_WALK_DEPTH :: 8
// _encode_header re-encodes a DER identifier + length the canonical
// way; used as the acceptance oracle.
@(private="file")
_encode_header :: proc(tag: asn1.Tag, length: int, out: ^[dynamic]byte) {
b := byte(u8(tag.class) << 6)
if tag.constructed {
b |= 0x20
}
if tag.number < 0x1F {
append(out, b | byte(tag.number))
} else {
append(out, b | 0x1F)
// Base-128, big-endian, minimal.
tmp: [5]byte
n := 0
v := tag.number
for {
tmp[n] = byte(v & 0x7F)
n += 1
v >>= 7
if v == 0 {
break
}
}
for i := n - 1; i >= 0; i -= 1 {
c := tmp[i]
if i > 0 {
c |= 0x80
}
append(out, c)
}
}
if length < 0x80 {
append(out, byte(length))
} else {
tmp: [4]byte
n := 0
v := length
for v > 0 {
tmp[n] = byte(v & 0xFF)
n += 1
v >>= 8
}
append(out, 0x80 | byte(n))
for i := n - 1; i >= 0; i -= 1 {
append(out, tmp[i])
}
}
}
// _walk recursively consumes every element in the reader, applying the
// canonical re-encode oracle to each accepted element.
@(private="file")
_walk :: proc(t: ^testing.T, r: ^asn1.Cursor, depth: int, scratch: ^[dynamic]byte) {
for !asn1.is_empty(r) {
start := r.pos
tag, content, err := asn1.read_any(r)
if err != .None {
return
}
element := r.data[start:r.pos]
// Oracle: canonical re-encode must reproduce the element.
clear(scratch)
_encode_header(tag, len(content), scratch)
append(scratch, ..content)
if !bytes.equal(scratch[:], element) {
testing.expectf(t, false, "accepted non-canonical element: % 02x", element)
return
}
if tag.constructed && depth < FUZZ_WALK_DEPTH {
sub := asn1.Cursor{data = content}
_walk(t, &sub, depth + 1, scratch)
}
}
}
@(test)
test_fuzz_read_any_random :: proc(t: ^testing.T) {
buf: [FUZZ_MAX_INPUT]byte
scratch: [dynamic]byte
defer delete(scratch)
for _ in 0 ..< FUZZ_RANDOM_ITERS {
n := rand.int_max(FUZZ_MAX_INPUT + 1)
input := buf[:n]
for i in 0 ..< n {
input[i] = byte(rand.uint32())
}
// Bias half the inputs towards plausible structure: a known
// universal tag and a length that fits.
if n >= 2 && rand.int_max(2) == 0 {
tags := [?]byte{0x02, 0x03, 0x04, 0x05, 0x06, 0x17, 0x18, 0x30, 0x31, 0xA0}
input[0] = rand.choice(tags[:])
input[1] = byte(rand.int_max(n))
}
r: asn1.Cursor
asn1.cursor_init(&r, input)
_walk(t, &r, 0, &scratch)
}
}
@(test)
test_fuzz_typed_readers_random :: proc(t: ^testing.T) {
buf: [FUZZ_MAX_INPUT]byte
for _ in 0 ..< FUZZ_RANDOM_ITERS {
n := rand.int_max(FUZZ_MAX_INPUT + 1)
input := buf[:n]
for i in 0 ..< n {
input[i] = byte(rand.uint32())
}
// Every typed reader must fail cleanly or uphold its contract;
// none may panic. Fresh reader per call: a failed read may
// leave the cursor mid-element by design.
{
r: asn1.Cursor
asn1.cursor_init(&r, input)
if v, err := asn1.read_i64(&r); err == .None {
_ = v
}
}
{
r: asn1.Cursor
asn1.cursor_init(&r, input)
if mag, err := asn1.read_unsigned_integer_bytes(&r); err == .None {
// Magnitude is minimal: no leading zero unless the
// value IS zero.
if len(mag) > 1 {
testing.expect(t, mag[0] != 0x00, "non-minimal magnitude")
}
}
}
{
r: asn1.Cursor
asn1.cursor_init(&r, input)
if bits, unused, err := asn1.read_bit_string(&r); err == .None {
testing.expect(t, unused <= 7)
if unused > 0 {
testing.expect(t, len(bits) > 0)
mask := byte(1 << uint(unused)) - 1
testing.expect(t, bits[len(bits) - 1] & mask == 0, "padding bits set")
}
}
}
{
r: asn1.Cursor
asn1.cursor_init(&r, input)
if raw, err := asn1.read_oid(&r); err == .None {
// Layer contract: structural acceptance by read_oid
// means the decoders yield either arcs or Arc_Overflow
// (X.660 arcs are unbounded) — never a structural error.
// This invariant caught a real bug on this fuzzer's
// first run.
arcs, aerr := asn1.oid_components(raw)
str, serr := asn1.oid_to_string(raw)
testing.expect(t, aerr == .None || aerr == .Arc_Overflow, "components: structural error after acceptance")
testing.expect_value(t, serr, aerr)
if aerr == .None {
testing.expect(t, len(arcs) >= 2)
testing.expect(t, len(str) >= 3)
}
delete(arcs)
delete(str)
}
}
{
r: asn1.Cursor
asn1.cursor_init(&r, input)
if _, err := asn1.read_time(&r); err == .None {
// Acceptance implies the RFC 5280 profile already
// validated ranges; nothing further to check here.
continue
}
}
}
}
@(test)
test_fuzz_mutated_spki :: proc(t: ^testing.T) {
spki := make_spki()
defer delete(spki)
buf := make([]byte, len(spki))
defer delete(buf)
for _ in 0 ..< FUZZ_MUTATE_ITERS {
copy(buf, spki[:])
// 1-8 random byte mutations; structure mostly survives, so the
// parser gets dragged deep before hitting the damage.
for _ in 0 ..< 1 + rand.int_max(8) {
buf[rand.int_max(len(buf))] = byte(rand.uint32())
}
// Must never panic; success or clean error are both fine.
_, _ = parse_spki(buf)
}
}

View File

@@ -0,0 +1,158 @@
package test_core_asn1
// Deterministic fuzzing for the DER WRITER. The runner seeds
// context.random_generator and logs the seed, so any failure reproduces
// with -define:ODIN_TEST_RANDOM_SEED=n. Run under -sanitize:address to turn
// the value-tree's borrow discipline into a checked property: a stray
// borrow of an out-of-scope composite-literal temporary is a stack
// use-after-scope ASan would catch here.
//
// Invariants:
// - marshal of any constructed tree never crashes / writes out of bounds;
// - the output is well-formed DER (a read_any walk consumes it exactly);
// - leaf values survive a marshal -> read round-trip.
import "core:bytes"
import "core:encoding/asn1"
import "core:math/rand"
import "core:testing"
import "core:time"
WRITER_FUZZ_ITERS :: 2048
WRITER_FUZZ_DEPTH :: 6
// _gen_bytes allocates 0..max random bytes from the temp arena.
@(private = "file")
_gen_bytes :: proc(max: int) -> []byte {
n := rand.int_max(max)
b := make([]byte, n, context.temp_allocator)
for i in 0 ..< n {
b[i] = byte(rand.int_max(256))
}
return b
}
@(private = "file")
_gen_leaf :: proc() -> asn1.Value {
switch rand.int_max(7) {
case 0:
return asn1.boolean(rand.int_max(2) == 1)
case 1:
return asn1.null()
case 2:
return asn1.integer_unsigned(_gen_bytes(20))
case 3:
return asn1.octet_string(_gen_bytes(20))
case 4:
return asn1.bit_string_octets(_gen_bytes(20))
case 5:
return asn1.object_identifier(_gen_bytes(12)) // raw OID octets: read_any tolerates any content
case:
return asn1.generalized_time(time.unix(i64(rand.int_max(2_000_000_000)), 0))
}
}
// _gen_value builds a random tree; constructed nodes draw their children
// arrays from the temp arena (pre-sized, so the slices set() / sequence()
// borrow never move), freed wholesale after each iteration.
@(private = "file")
_gen_value :: proc(depth: int) -> asn1.Value {
if depth <= 0 || rand.int_max(3) == 0 {
return _gen_leaf()
}
n := rand.int_max(4) // 0..3 children
kids := make([]asn1.Value, n, context.temp_allocator)
for i in 0 ..< n {
kids[i] = _gen_value(depth - 1)
}
switch rand.int_max(5) {
case 0:
return asn1.sequence(kids)
case 1:
return asn1.set(kids)
case 2:
return asn1.context_explicit(u32(rand.int_max(8)), kids)
case 3:
return asn1.bit_string_wrap(kids)
case:
sv, serr := asn1.set_of(kids, context.temp_allocator) // allocates scratch + sorts in place
if serr != .None {
return asn1.set(kids)
}
return sv
}
}
// _walk recurses through a marshalled tree with read_any, asserting every
// element frames cleanly and the input is consumed exactly.
@(private = "file")
_walk :: proc(t: ^testing.T, data: []byte, depth: int) {
cur: asn1.Cursor
asn1.cursor_init(&cur, data)
for !asn1.is_empty(&cur) {
tag, content, err := asn1.read_any(&cur)
testing.expect_value(t, err, asn1.Error.None)
if err != .None {
return
}
if tag.constructed && depth > 0 {
_walk(t, content, depth - 1)
}
}
}
@(test)
test_fuzz_writer_wellformed :: proc(t: ^testing.T) {
for _ in 0 ..< WRITER_FUZZ_ITERS {
tree := _gen_value(WRITER_FUZZ_DEPTH)
out, err := asn1.marshal(tree)
testing.expect_value(t, err, asn1.Error.None)
if err == .None {
testing.expect_value(t, len(out), asn1.encoded_len(tree)) // sizing == emission
_walk(t, out, 64)
delete(out)
}
free_all(context.temp_allocator)
}
}
@(test)
test_fuzz_writer_roundtrip :: proc(t: ^testing.T) {
for _ in 0 ..< WRITER_FUZZ_ITERS {
// OCTET STRING: content survives verbatim.
payload := _gen_bytes(32)
if o, e := asn1.marshal(asn1.octet_string(payload)); e == .None {
cur: asn1.Cursor
asn1.cursor_init(&cur, o)
got, re := asn1.read_octet_string(&cur)
testing.expect_value(t, re, asn1.Error.None)
testing.expect_value(t, asn1.done(&cur), asn1.Error.None)
testing.expect(t, bytes.equal(got, payload), "octet string round-trip")
delete(o)
}
// BIT STRING (whole octets): payload survives verbatim.
bits := _gen_bytes(32)
if o, e := asn1.marshal(asn1.bit_string_octets(bits)); e == .None {
cur: asn1.Cursor
asn1.cursor_init(&cur, o)
got, re := asn1.read_bit_string_octets(&cur)
testing.expect_value(t, re, asn1.Error.None)
testing.expect(t, bytes.equal(got, bits), "bit string round-trip")
delete(o)
}
// BOOLEAN.
bv := rand.int_max(2) == 1
if o, e := asn1.marshal(asn1.boolean(bv)); e == .None {
cur: asn1.Cursor
asn1.cursor_init(&cur, o)
got, re := asn1.read_boolean(&cur)
testing.expect_value(t, re, asn1.Error.None)
testing.expect_value(t, got, bv)
delete(o)
}
free_all(context.temp_allocator)
}
}

View File

@@ -0,0 +1,119 @@
package test_core_asn1
// Out-of-memory robustness for the allocating OID helpers. A failing
// allocator wraps a tracking allocator; sweeping the fail point across
// every allocation (including the Builder's internal growth in
// oid_to_string) must yield Allocation_Failed with nothing leaked.
import "base:runtime"
import "core:mem"
import "core:encoding/asn1"
import "core:testing"
@(private="file")
Failing_Allocator :: struct {
backing: runtime.Allocator,
count: int,
fail_at: int,
}
@(private="file")
failing_allocator_proc :: proc(
data: rawptr, mode: runtime.Allocator_Mode,
size, alignment: int, old_memory: rawptr, old_size: int,
loc := #caller_location,
) -> ([]byte, runtime.Allocator_Error) {
fa := (^Failing_Allocator)(data)
#partial switch mode {
case .Alloc, .Alloc_Non_Zeroed, .Resize, .Resize_Non_Zeroed:
if fa.count == fa.fail_at {
fa.count += 1
return nil, .Out_Of_Memory
}
fa.count += 1
}
return fa.backing.procedure(fa.backing.data, mode, size, alignment, old_memory, old_size, loc)
}
@(private="file")
failing_allocator :: proc(fa: ^Failing_Allocator) -> runtime.Allocator {
return {procedure = failing_allocator_proc, data = fa}
}
// rsaEncryption (1.2.840.113549.1.1.1) — seven arcs rendering to a
// 20-char string, enough to drive the Builder in oid_to_string past
// its initial capacity so the resize path is on the sweep.
@(private="file")
LONG_OID := []byte{0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01}
@(test)
test_oom_oid_components :: proc(t: ^testing.T) {
raw: asn1.Cursor
asn1.cursor_init(&raw, LONG_OID)
oid, oerr := asn1.read_oid(&raw)
testing.expect_value(t, oerr, asn1.Error.None)
total: int
{
track: mem.Tracking_Allocator
mem.tracking_allocator_init(&track, context.allocator)
defer mem.tracking_allocator_destroy(&track)
fa := Failing_Allocator{backing = mem.tracking_allocator(&track), fail_at = -1}
arcs, err := asn1.oid_components(oid, failing_allocator(&fa))
testing.expect_value(t, err, asn1.Error.None)
delete(arcs, failing_allocator(&fa))
total = fa.count
testing.expect(t, total >= 1)
testing.expect_value(t, len(track.allocation_map), 0)
}
for k in 0 ..< total {
track: mem.Tracking_Allocator
mem.tracking_allocator_init(&track, context.allocator)
defer mem.tracking_allocator_destroy(&track)
fa := Failing_Allocator{backing = mem.tracking_allocator(&track), fail_at = k}
arcs, err := asn1.oid_components(oid, failing_allocator(&fa))
if err == .None {
delete(arcs, failing_allocator(&fa))
} else {
testing.expectf(t, err == .Allocation_Failed, "k=%d: got %v", k, err)
}
testing.expectf(t, len(track.allocation_map) == 0, "k=%d: %d leaked", k, len(track.allocation_map))
}
}
@(test)
test_oom_oid_to_string :: proc(t: ^testing.T) {
raw: asn1.Cursor
asn1.cursor_init(&raw, LONG_OID)
oid, oerr := asn1.read_oid(&raw)
testing.expect_value(t, oerr, asn1.Error.None)
total: int
{
track: mem.Tracking_Allocator
mem.tracking_allocator_init(&track, context.allocator)
defer mem.tracking_allocator_destroy(&track)
fa := Failing_Allocator{backing = mem.tracking_allocator(&track), fail_at = -1}
str, err := asn1.oid_to_string(oid, failing_allocator(&fa))
testing.expect_value(t, err, asn1.Error.None)
delete(str, failing_allocator(&fa))
total = fa.count
testing.expect(t, total >= 1)
testing.expect_value(t, len(track.allocation_map), 0)
}
for k in 0 ..< total {
track: mem.Tracking_Allocator
mem.tracking_allocator_init(&track, context.allocator)
defer mem.tracking_allocator_destroy(&track)
fa := Failing_Allocator{backing = mem.tracking_allocator(&track), fail_at = k}
str, err := asn1.oid_to_string(oid, failing_allocator(&fa))
if err == .None {
delete(str, failing_allocator(&fa))
} else {
testing.expectf(t, err == .Allocation_Failed, "k=%d: got %v", k, err)
}
testing.expectf(t, len(track.allocation_map) == 0, "k=%d: %d leaked", k, len(track.allocation_map))
}
}

View File

@@ -0,0 +1,532 @@
package test_core_asn1
import "core:encoding/asn1"
import "core:testing"
import "core:time"
// ============================================================
// Tag and length forms
// ============================================================
@(test)
test_tag_forms :: proc(t: ^testing.T) {
// Low tag, primitive universal.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x02, 0x01, 0x05})
tag, content, err := asn1.read_any(&r)
testing.expect_value(t, err, asn1.Error.None)
testing.expect_value(t, tag, asn1.universal(.Integer))
testing.expect_value(t, len(content), 1)
testing.expect_value(t, asn1.done(&r), asn1.Error.None)
}
// Constructed context-specific [0].
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0xA0, 0x00})
tag, _, err := asn1.read_any(&r)
testing.expect_value(t, err, asn1.Error.None)
testing.expect_value(t, tag, asn1.context_specific(0))
}
// High-tag-number form: [31] primitive → 9F 1F.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x9F, 0x1F, 0x00})
tag, _, err := asn1.read_any(&r)
testing.expect_value(t, err, asn1.Error.None)
testing.expect_value(t, tag.number, u32(31))
}
// High-tag form used for a number < 31 is non-minimal → invalid.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x9F, 0x1E, 0x00})
_, _, err := asn1.read_any(&r)
testing.expect_value(t, err, asn1.Error.Invalid_Tag)
}
// High-tag form with 0x80 lead continuation octet is non-minimal.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x9F, 0x80, 0x1F, 0x00})
_, _, err := asn1.read_any(&r)
testing.expect_value(t, err, asn1.Error.Invalid_Tag)
}
}
@(test)
test_length_forms :: proc(t: ^testing.T) {
// Long-form length for 128 bytes: 81 80.
{
buf: [131]byte
buf[0] = 0x04
buf[1] = 0x81
buf[2] = 0x80
r: asn1.Cursor
asn1.cursor_init(&r, buf[:])
_, content, err := asn1.read_any(&r)
testing.expect_value(t, err, asn1.Error.None)
testing.expect_value(t, len(content), 128)
}
// Long form for a short length (81 05) is non-minimal.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x04, 0x81, 0x05, 1, 2, 3, 4, 5})
_, _, err := asn1.read_any(&r)
testing.expect_value(t, err, asn1.Error.Invalid_Length)
}
// Leading zero octet in long form (82 00 80) is non-minimal.
{
buf: [131]byte
buf[0] = 0x04
buf[1] = 0x82
buf[2] = 0x00
buf[3] = 0x80
r: asn1.Cursor
asn1.cursor_init(&r, buf[:])
_, _, err := asn1.read_any(&r)
testing.expect_value(t, err, asn1.Error.Invalid_Length)
}
// Indefinite length (80) is BER, never DER.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x30, 0x80, 0x00, 0x00})
_, _, err := asn1.read_any(&r)
testing.expect_value(t, err, asn1.Error.Invalid_Length)
}
// Length past end of input.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x04, 0x05, 1, 2})
_, _, err := asn1.read_any(&r)
testing.expect_value(t, err, asn1.Error.Truncated)
}
}
// ============================================================
// Scalar types
// ============================================================
@(test)
test_boolean :: proc(t: ^testing.T) {
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x01, 0x01, 0xFF})
v, err := asn1.read_boolean(&r)
testing.expect_value(t, err, asn1.Error.None)
testing.expect_value(t, v, true)
}
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x01, 0x01, 0x00})
v, err := asn1.read_boolean(&r)
testing.expect_value(t, err, asn1.Error.None)
testing.expect_value(t, v, false)
}
// DER: any value other than 0x00/0xFF is invalid.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x01, 0x01, 0x01})
_, err := asn1.read_boolean(&r)
testing.expect_value(t, err, asn1.Error.Invalid_Boolean)
}
// Wrong width.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x01, 0x02, 0xFF, 0xFF})
_, err := asn1.read_boolean(&r)
testing.expect_value(t, err, asn1.Error.Invalid_Boolean)
}
}
@(test)
test_integer :: proc(t: ^testing.T) {
Case :: struct {
der: []byte,
value: i64,
err: asn1.Error,
}
cases := []Case{
{der = {0x02, 0x01, 0x00}, value = 0},
{der = {0x02, 0x01, 0x7F}, value = 127},
{der = {0x02, 0x02, 0x00, 0x80}, value = 128},
{der = {0x02, 0x01, 0x80}, value = -128},
{der = {0x02, 0x02, 0xFF, 0x7F}, value = -129},
{der = {0x02, 0x08, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, value = max(i64)},
{der = {0x02, 0x08, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, value = min(i64)},
// Non-minimal: redundant leading 0x00 / 0xFF.
{der = {0x02, 0x02, 0x00, 0x05}, err = .Invalid_Integer},
{der = {0x02, 0x02, 0xFF, 0x85}, err = .Invalid_Integer},
// Empty content.
{der = {0x02, 0x00}, err = .Invalid_Integer},
// Too wide for i64.
{der = {0x02, 0x09, 0x01, 0, 0, 0, 0, 0, 0, 0, 0}, err = .Integer_Overflow},
}
for c in cases {
r: asn1.Cursor
asn1.cursor_init(&r, c.der)
v, err := asn1.read_i64(&r)
testing.expect_value(t, err, c.err)
if c.err == .None {
testing.expect_value(t, v, c.value)
}
}
}
@(test)
test_unsigned_integer :: proc(t: ^testing.T) {
// 0x00 sign octet stripped: 255 encodes as 02 02 00 FF.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x02, 0x02, 0x00, 0xFF})
mag, err := asn1.read_unsigned_integer_bytes(&r)
testing.expect_value(t, err, asn1.Error.None)
testing.expect_value(t, len(mag), 1)
testing.expect_value(t, mag[0], u8(0xFF))
}
// Zero stays one octet.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x02, 0x01, 0x00})
mag, err := asn1.read_unsigned_integer_bytes(&r)
testing.expect_value(t, err, asn1.Error.None)
testing.expect_value(t, len(mag), 1)
testing.expect_value(t, mag[0], u8(0x00))
}
// Negative rejected.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x02, 0x01, 0x80})
_, err := asn1.read_unsigned_integer_bytes(&r)
testing.expect_value(t, err, asn1.Error.Negative_Integer)
}
}
@(test)
test_bit_string :: proc(t: ^testing.T) {
// Whole octets: 03 03 00 A0 0F.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x03, 0x03, 0x00, 0xA0, 0x0F})
octets, err := asn1.read_bit_string_octets(&r)
testing.expect_value(t, err, asn1.Error.None)
testing.expect_value(t, len(octets), 2)
}
// 4 unused bits, correctly zero-padded: A0 = 1010_0000.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x03, 0x02, 0x04, 0xA0})
bits, unused, err := asn1.read_bit_string(&r)
testing.expect_value(t, err, asn1.Error.None)
testing.expect_value(t, unused, 4)
testing.expect_value(t, len(bits), 1)
}
// Non-zero padding bits violate DER.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x03, 0x02, 0x04, 0xA1})
_, _, err := asn1.read_bit_string(&r)
testing.expect_value(t, err, asn1.Error.Invalid_Bit_String)
}
// Unused count > 7.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x03, 0x02, 0x08, 0xA0})
_, _, err := asn1.read_bit_string(&r)
testing.expect_value(t, err, asn1.Error.Invalid_Bit_String)
}
// Empty payload must declare zero unused bits.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x03, 0x01, 0x03})
_, _, err := asn1.read_bit_string(&r)
testing.expect_value(t, err, asn1.Error.Invalid_Bit_String)
}
// Empty content entirely.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x03, 0x00})
_, _, err := asn1.read_bit_string(&r)
testing.expect_value(t, err, asn1.Error.Invalid_Bit_String)
}
// PKIX shape requires unused == 0.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x03, 0x02, 0x04, 0xA0})
_, err := asn1.read_bit_string_octets(&r)
testing.expect_value(t, err, asn1.Error.Invalid_Bit_String)
}
}
@(test)
test_null :: proc(t: ^testing.T) {
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x05, 0x00})
testing.expect_value(t, asn1.read_null(&r), asn1.Error.None)
}
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x05, 0x01, 0x00})
testing.expect_value(t, asn1.read_null(&r), asn1.Error.Invalid_Null)
}
}
// ============================================================
// OBJECT IDENTIFIER
// ============================================================
@(test)
test_oid :: proc(t: ^testing.T) {
// rsaEncryption: 1.2.840.113549.1.1.1
rsa_encryption := []byte{0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01}
{
r: asn1.Cursor
asn1.cursor_init(&r, rsa_encryption)
raw, err := asn1.read_oid(&r)
testing.expect_value(t, err, asn1.Error.None)
testing.expect_value(t, len(raw), 9)
str, serr := asn1.oid_to_string(raw)
defer delete(str)
testing.expect_value(t, serr, asn1.Error.None)
testing.expect_value(t, str, "1.2.840.113549.1.1.1")
arcs, aerr := asn1.oid_components(raw)
defer delete(arcs)
testing.expect_value(t, aerr, asn1.Error.None)
testing.expect_value(t, len(arcs), 7)
testing.expect_value(t, arcs[3], u64(113549))
}
// ecPublicKey: 1.2.840.10045.2.1
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01})
raw, err := asn1.read_oid(&r)
testing.expect_value(t, err, asn1.Error.None)
str, serr := asn1.oid_to_string(raw)
defer delete(str)
testing.expect_value(t, serr, asn1.Error.None)
testing.expect_value(t, str, "1.2.840.10045.2.1")
}
// Arc-2 base offset: 2.5.4.3 (id-at-commonName) → 55 04 03.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x06, 0x03, 0x55, 0x04, 0x03})
raw, err := asn1.read_oid(&r)
testing.expect_value(t, err, asn1.Error.None)
str, serr := asn1.oid_to_string(raw)
defer delete(str)
testing.expect_value(t, serr, asn1.Error.None)
testing.expect_value(t, str, "2.5.4.3")
}
// Empty content is invalid.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x06, 0x00})
_, err := asn1.read_oid(&r)
testing.expect_value(t, err, asn1.Error.Invalid_Object_Identifier)
}
// Non-minimal subidentifier (leading 0x80 continuation octet).
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x06, 0x03, 0x2A, 0x80, 0x01})
_, err := asn1.read_oid(&r)
testing.expect_value(t, err, asn1.Error.Invalid_Object_Identifier)
}
// Truncated subidentifier (continuation bit set on last octet).
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x06, 0x02, 0x2A, 0x86})
_, err := asn1.read_oid(&r)
testing.expect_value(t, err, asn1.Error.Invalid_Object_Identifier)
}
}
// ============================================================
// Time
// ============================================================
@(test)
test_time :: proc(t: ^testing.T) {
// Times are returned as time.Time; compare via Unix seconds.
// Reference epochs computed independently (e.g.
// `date -u -d '1999-01-01T00:00:00Z' +%s`). UTCTime century window:
// 990101000000Z → 1999, 490101000000Z → 2049.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x17, 0x0D, '9', '9', '0', '1', '0', '1', '0', '0', '0', '0', '0', '0', 'Z'})
v, err := asn1.read_utc_time(&r)
testing.expect_value(t, err, asn1.Error.None)
testing.expect_value(t, time.to_unix_seconds(v), i64(915148800)) // 1999-01-01T00:00:00Z
}
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x17, 0x0D, '4', '9', '0', '1', '0', '1', '0', '0', '0', '0', '0', '0', 'Z'})
v, err := asn1.read_utc_time(&r)
testing.expect_value(t, err, asn1.Error.None)
testing.expect_value(t, time.to_unix_seconds(v), i64(2493072000)) // 2049-01-01T00:00:00Z
}
// GeneralizedTime: 20260612153000Z.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x18, 0x0F, '2', '0', '2', '6', '0', '6', '1', '2', '1', '5', '3', '0', '0', '0', 'Z'})
v, err := asn1.read_generalized_time(&r)
testing.expect_value(t, err, asn1.Error.None)
testing.expect_value(t, time.to_unix_seconds(v), i64(1781278200)) // 2026-06-12T15:30:00Z
}
// read_time dispatches on tag.
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x18, 0x0F, '2', '0', '5', '0', '0', '1', '0', '1', '0', '0', '0', '0', '0', '0', 'Z'})
v, err := asn1.read_time(&r)
testing.expect_value(t, err, asn1.Error.None)
testing.expect_value(t, time.to_unix_seconds(v), i64(2524608000)) // 2050-01-01T00:00:00Z
}
// Far-future dates (year 9999, the RFC 5280 no-expiration sentinel)
// must still parse, but time.Time tops out near year 2262, so the
// value SATURATES rather than carrying the literal 9999 epoch. This
// is an intentional limitation (see asn1's _time_from_unix): the
// 9999 sentinel just needs to read as "effectively never expires".
{
r: asn1.Cursor
asn1.cursor_init(&r, []byte{0x18, 0x0F, '9', '9', '9', '9', '1', '2', '3', '1', '2', '3', '5', '9', '5', '9', 'Z'})
v, err := asn1.read_generalized_time(&r)
testing.expect_value(t, err, asn1.Error.None)
// Saturated near year 2262, NOT the true 9999 epoch (253402300799).
testing.expect(t, time.to_unix_seconds(v) >= i64(9_223_372_036), "9999 saturates to time.Time max")
testing.expect(t, time.to_unix_seconds(v) < i64(253402300799), "saturated, not the literal 9999 epoch")
}
// RFC 5280 profile rejections: offset instead of Z, missing
// seconds, fractional seconds, month/day out of range.
bad := [][]byte{
{0x17, 0x11, '9', '9', '0', '1', '0', '1', '0', '0', '0', '0', '0', '0', '+', '0', '1', '0', '0'},
{0x17, 0x0B, '9', '9', '0', '1', '0', '1', '0', '0', '0', '0', 'Z'},
{0x18, 0x12, '2', '0', '2', '6', '0', '6', '1', '2', '1', '5', '3', '0', '0', '0', '.', '5', '0', 'Z'},
{0x17, 0x0D, '9', '9', '1', '3', '0', '1', '0', '0', '0', '0', '0', '0', 'Z'},
{0x17, 0x0D, '9', '9', '0', '1', '0', '0', '0', '0', '0', '0', '0', '0', 'Z'},
{0x17, 0x0D, '9', '9', '0', '1', '0', '1', '2', '4', '0', '0', '0', '0', 'Z'},
}
for der in bad {
r: asn1.Cursor
asn1.cursor_init(&r, der)
_, err := asn1.read_time(&r)
testing.expect(t, err != .None, "malformed time must be rejected")
}
}
// ============================================================
// Compound structures
// ============================================================
// A real SubjectPublicKeyInfo for an EC P-256 key:
// SEQUENCE {
// SEQUENCE { OID ecPublicKey, OID prime256v1 }
// BIT STRING (65 octets of uncompressed point, 0 unused bits)
// }
@(private)
make_spki :: proc(allocator := context.allocator) -> [dynamic]byte {
out: [dynamic]byte
out.allocator = allocator
append(&out, 0x30, 0x59) // SEQUENCE, len 89
append(&out, 0x30, 0x13) // SEQUENCE, len 19
append(&out, 0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01) // ecPublicKey
append(&out, 0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07) // prime256v1
append(&out, 0x03, 0x42, 0x00) // BIT STRING, len 66, 0 unused
append(&out, 0x04) // uncompressed point marker
for i in 0 ..< 64 {
append(&out, u8(i))
}
return out
}
@(private)
parse_spki :: proc(data: []byte) -> (point: []byte, err: asn1.Error) {
r: asn1.Cursor
asn1.cursor_init(&r, data)
spki, serr := asn1.read_sequence(&r)
if serr != .None {
return nil, serr
}
if derr := asn1.done(&r); derr != .None {
return nil, derr
}
alg, aerr := asn1.read_sequence(&spki)
if aerr != .None {
return nil, aerr
}
alg_oid, oerr := asn1.read_oid(&alg)
if oerr != .None {
return nil, oerr
}
_ = alg_oid
curve_oid, cerr := asn1.read_oid(&alg)
if cerr != .None {
return nil, cerr
}
_ = curve_oid
if derr := asn1.done(&alg); derr != .None {
return nil, derr
}
key, kerr := asn1.read_bit_string_octets(&spki)
if kerr != .None {
return nil, kerr
}
if derr := asn1.done(&spki); derr != .None {
return nil, derr
}
return key, .None
}
@(test)
test_spki_walk :: proc(t: ^testing.T) {
spki := make_spki()
defer delete(spki)
point, err := parse_spki(spki[:])
testing.expect_value(t, err, asn1.Error.None)
testing.expect_value(t, len(point), 65)
testing.expect_value(t, point[0], u8(0x04))
}
// Every truncation of a valid structure must error cleanly — never
// panic, never succeed.
@(test)
test_truncation_sweep :: proc(t: ^testing.T) {
spki := make_spki()
defer delete(spki)
for n in 0 ..< len(spki) {
_, err := parse_spki(spki[:n])
testing.expect(t, err != .None, "truncated input must be rejected")
}
}
@(test)
test_explicit_optional :: proc(t: ^testing.T) {
// [0] EXPLICIT INTEGER 2 (the X.509 version field shape), then an
// INTEGER at the outer level.
der := []byte{0xA0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x01, 0x07}
r: asn1.Cursor
asn1.cursor_init(&r, der)
inner, present, err := asn1.read_explicit(&r, 0)
testing.expect_value(t, err, asn1.Error.None)
testing.expect_value(t, present, true)
version, verr := asn1.read_i64(&inner)
testing.expect_value(t, verr, asn1.Error.None)
testing.expect_value(t, version, i64(2))
testing.expect_value(t, asn1.done(&inner), asn1.Error.None)
// Absent optional: next element is [1]? No — it's the INTEGER, so
// read_explicit(1) must not consume.
_, present2, err2 := asn1.read_explicit(&r, 1)
testing.expect_value(t, err2, asn1.Error.None)
testing.expect_value(t, present2, false)
serial, serr := asn1.read_i64(&r)
testing.expect_value(t, serr, asn1.Error.None)
testing.expect_value(t, serial, i64(7))
testing.expect_value(t, asn1.done(&r), asn1.Error.None)
}

View File

@@ -0,0 +1,298 @@
package test_core_asn1
import "core:bytes"
import "core:encoding/asn1"
import "core:testing"
import "core:time"
@(private = "file")
_marshal :: proc(t: ^testing.T, v: asn1.Value) -> []byte {
out, err := asn1.marshal(v)
testing.expect_value(t, err, asn1.Error.None)
return out
}
@(private = "file")
_expect_der :: proc(t: ^testing.T, v: asn1.Value, want: []byte) {
got := _marshal(t, v)
defer delete(got)
testing.expect_value(t, len(got), asn1.encoded_len(v)) // encoded_len must match what was written
testing.expectf(t, bytes.equal(got, want), "got %x, want %x", got, want)
}
// INTEGER from an unsigned magnitude: minimal stripping + sign-octet
// insertion, the inverse of read_unsigned_integer_bytes.
@(test)
test_writer_integer_unsigned :: proc(t: ^testing.T) {
_expect_der(t, asn1.integer_unsigned({}), {0x02, 0x01, 0x00}) // empty -> 0
_expect_der(t, asn1.integer_unsigned({0x00}), {0x02, 0x01, 0x00}) // 0
_expect_der(t, asn1.integer_unsigned({0x00, 0x00}), {0x02, 0x01, 0x00}) // all-zero -> 0
_expect_der(t, asn1.integer_unsigned({0x2A}), {0x02, 0x01, 0x2A}) // 42
_expect_der(t, asn1.integer_unsigned({0x00, 0x2A}), {0x02, 0x01, 0x2A}) // strip leading zero
_expect_der(t, asn1.integer_unsigned({0x80}), {0x02, 0x02, 0x00, 0x80}) // 128: insert sign octet
_expect_der(t, asn1.integer_unsigned({0xFF, 0xFF}), {0x02, 0x03, 0x00, 0xFF, 0xFF}) // 65535
_expect_der(t, asn1.integer_unsigned({0x01, 0x00, 0x01}), {0x02, 0x03, 0x01, 0x00, 0x01}) // 65537 (RSA e)
}
// Length octets: short form below 128, otherwise minimal long form. Drive
// the boundary with OCTET STRINGs of crafted sizes and check the header.
@(test)
test_writer_length_forms :: proc(t: ^testing.T) {
cases := []struct {
n: int,
head: []byte,
} {
{0, {0x04, 0x00}},
{1, {0x04, 0x01}},
{127, {0x04, 0x7F}}, // last short-form length
{128, {0x04, 0x81, 0x80}}, // first long form
{255, {0x04, 0x81, 0xFF}},
{256, {0x04, 0x82, 0x01, 0x00}},
{300, {0x04, 0x82, 0x01, 0x2C}},
}
for c in cases {
content := make([]byte, c.n)
defer delete(content)
got := _marshal(t, asn1.octet_string(content))
defer delete(got)
testing.expectf(t, len(got) >= len(c.head), "n=%d: short output", c.n)
testing.expectf(t, bytes.equal(got[:len(c.head)], c.head), "n=%d: header %x, want %x", c.n, got[:len(c.head)], c.head)
testing.expect_value(t, len(got), len(c.head) + c.n)
}
}
// encode into a buffer one byte too small must write nothing and report it.
@(test)
test_writer_buffer_too_small :: proc(t: ^testing.T) {
v := asn1.integer_unsigned({0x80}) // encodes to 4 bytes
need := asn1.encoded_len(v)
testing.expect_value(t, need, 4)
short := make([]byte, need - 1)
defer delete(short)
n, err := asn1.encode(v, short)
testing.expect_value(t, err, asn1.Error.Buffer_Too_Small)
testing.expect_value(t, n, 0)
exact := make([]byte, need)
defer delete(exact)
n2, err2 := asn1.encode(v, exact)
testing.expect_value(t, err2, asn1.Error.None)
testing.expect_value(t, n2, need)
}
// Round-trip every write back through the cursor reader: SEQUENCE { INTEGER
// r, INTEGER s }, the ECDSA signature shape, with s's top bit set so a sign
// octet is inserted on write and stripped on read.
@(test)
test_writer_roundtrip_ecdsa_sig :: proc(t: ^testing.T) {
r := []byte{0x01, 0x23, 0x45, 0x67}
s := []byte{0x80, 0x00, 0x00, 0x01} // top bit set
sig := _marshal(t, asn1.sequence({asn1.integer_unsigned(r), asn1.integer_unsigned(s)}))
defer delete(sig)
// s gained a 0x00 sign octet: 2 + (2+4) + (2+5) = 15 bytes.
testing.expect_value(t, len(sig), 15)
cur: asn1.Cursor
asn1.cursor_init(&cur, sig)
seq, serr := asn1.read_sequence(&cur)
testing.expect_value(t, serr, asn1.Error.None)
gr, rerr := asn1.read_unsigned_integer_bytes(&seq)
gs, srerr := asn1.read_unsigned_integer_bytes(&seq)
testing.expect_value(t, rerr, asn1.Error.None)
testing.expect_value(t, srerr, asn1.Error.None)
testing.expect_value(t, asn1.done(&seq), asn1.Error.None)
testing.expect_value(t, asn1.done(&cur), asn1.Error.None)
testing.expect(t, bytes.equal(gr, r), "r round-trips")
testing.expect(t, bytes.equal(gs, s), "s round-trips")
}
// Structural Tier 1 encoders: NULL, OID passthrough, BIT STRING (whole
// octets), SET, and the context-specific [n] wrappers, by exact bytes.
@(test)
test_writer_structural :: proc(t: ^testing.T) {
_expect_der(t, asn1.null(), {0x05, 0x00})
// BIT STRING, whole octets: 03 <len+1> 00 <payload>.
_expect_der(t, asn1.bit_string_octets({0xCA, 0xFE}), {0x03, 0x03, 0x00, 0xCA, 0xFE})
_expect_der(t, asn1.bit_string_octets({}), {0x03, 0x01, 0x00})
// OID passthrough: rsaEncryption (1.2.840.113549.1.1.1) content octets.
rsa_oid := []byte{0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01}
_expect_der(t, asn1.object_identifier(rsa_oid), {0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01})
// [0] IMPLICIT primitive, and [0] EXPLICIT wrapping INTEGER 2.
_expect_der(t, asn1.context_primitive(0, {0xAB, 0xCD}), {0x80, 0x02, 0xAB, 0xCD})
_expect_der(t, asn1.context_explicit(0, {asn1.integer_unsigned({0x02})}), {0xA0, 0x03, 0x02, 0x01, 0x02})
// SET emits in the given order (single/pre-sorted element is the contract).
_expect_der(t, asn1.set({asn1.boolean(false)}), {0x31, 0x03, 0x01, 0x01, 0x00})
}
// A SubjectPublicKeyInfo-shaped tree round-trips through the reader:
// SEQUENCE { SEQUENCE { OID, NULL }, BIT STRING }.
@(test)
test_writer_spki_shape :: proc(t: ^testing.T) {
oid := []byte{0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01} // rsaEncryption
key := []byte{0x30, 0x06, 0x02, 0x01, 0x2A, 0x02, 0x01, 0x03} // opaque inner key octets
der := _marshal(t, asn1.sequence({asn1.sequence({asn1.object_identifier(oid), asn1.null()}), asn1.bit_string_octets(key)}))
defer delete(der)
cur: asn1.Cursor
asn1.cursor_init(&cur, der)
spki, e0 := asn1.read_sequence(&cur)
testing.expect_value(t, e0, asn1.Error.None)
alg, e1 := asn1.read_sequence(&spki)
testing.expect_value(t, e1, asn1.Error.None)
got_oid, e2 := asn1.read_oid(&alg)
testing.expect_value(t, e2, asn1.Error.None)
testing.expect(t, bytes.equal(got_oid, oid), "oid round-trips")
testing.expect_value(t, asn1.read_null(&alg), asn1.Error.None)
testing.expect_value(t, asn1.done(&alg), asn1.Error.None)
got_key, e3 := asn1.read_bit_string_octets(&spki)
testing.expect_value(t, e3, asn1.Error.None)
testing.expect(t, bytes.equal(got_key, key), "key bits round-trip")
testing.expect_value(t, asn1.done(&spki), asn1.Error.None)
testing.expect_value(t, asn1.done(&cur), asn1.Error.None)
}
// time() auto-selects UTCTime (<2050) vs GeneralizedTime (>=2050) per RFC 5280.
@(test)
test_writer_time_auto :: proc(t: ^testing.T) {
// 2049-12-31T23:59:59Z -> UTCTime (tag 0x17).
before := _marshal(t, asn1.time(time.unix(2524607999, 0)))
defer delete(before)
testing.expect_value(t, before[0], u8(0x17))
// 2050-01-01T00:00:00Z -> GeneralizedTime (tag 0x18).
after := _marshal(t, asn1.time(time.unix(2524608000, 0)))
defer delete(after)
testing.expect_value(t, after[0], u8(0x18))
}
// set_of sorts components into DER canonical order (X.690 11.6, by encoding).
@(test)
test_writer_set_of :: proc(t: ^testing.T) {
// Integers given 3,1,2 must emit sorted 1,2,3.
kids := [3]asn1.Value{asn1.integer_unsigned({0x03}), asn1.integer_unsigned({0x01}), asn1.integer_unsigned({0x02})}
v, err := asn1.set_of(kids[:])
testing.expect_value(t, err, asn1.Error.None)
out := _marshal(t, v)
defer delete(out)
cur: asn1.Cursor
asn1.cursor_init(&cur, out)
s, e := asn1.read_set(&cur)
testing.expect_value(t, e, asn1.Error.None)
for want in ([]byte{0x01, 0x02, 0x03}) {
got, ge := asn1.read_unsigned_integer_bytes(&s)
testing.expect_value(t, ge, asn1.Error.None)
testing.expect(t, len(got) == 1 && got[0] == want, "sorted ascending")
}
testing.expect_value(t, asn1.done(&s), asn1.Error.None)
// Ordering is by ENCODING, not value: 2 (02 01 02) sorts before 256
// (02 02 01 00) because the length octet 0x01 < 0x02.
kids2 := [2]asn1.Value{asn1.integer_unsigned({0x01, 0x00}), asn1.integer_unsigned({0x02})}
v2, err2 := asn1.set_of(kids2[:])
testing.expect_value(t, err2, asn1.Error.None)
_expect_der(t, v2, {0x31, 0x07, 0x02, 0x01, 0x02, 0x02, 0x02, 0x01, 0x00})
}
// raw() splices a complete pre-encoded element in verbatim, the composition
// primitive for nesting independently-marshalled structures.
@(test)
test_writer_raw :: proc(t: ^testing.T) {
pre := []byte{0x02, 0x01, 0x2A} // a pre-encoded INTEGER 42
_expect_der(t, asn1.raw(pre), {0x02, 0x01, 0x2A})
// embedded beside another value inside a SEQUENCE.
_expect_der(t, asn1.sequence({asn1.raw(pre), asn1.boolean(true)}), {0x30, 0x06, 0x02, 0x01, 0x2A, 0x01, 0x01, 0xFF})
}
@(private = "file")
_expect_time :: proc(t: ^testing.T, v: asn1.Value, tag: byte, ascii: string) {
got := _marshal(t, v)
defer delete(got)
want := make([]byte, 2 + len(ascii))
defer delete(want)
want[0] = tag
want[1] = byte(len(ascii))
copy(want[2:], transmute([]byte)ascii)
testing.expectf(t, bytes.equal(got, want), "got %x, want %x", got, want)
}
// UTCTime / GeneralizedTime formatting (option B: time.Time formatted into
// the output at emit) by exact bytes, plus a round-trip through the reader.
// Tags: UTCTime 0x17, GeneralizedTime 0x18.
@(test)
test_writer_time :: proc(t: ^testing.T) {
epoch := time.unix(0, 0) // 1970-01-01 00:00:00Z
_expect_time(t, asn1.generalized_time(epoch), 0x18, "19700101000000Z")
_expect_time(t, asn1.utc_time(epoch), 0x17, "700101000000Z")
y2027 := time.unix(1798761600, 0) // 2027-01-01 00:00:00Z
_expect_time(t, asn1.generalized_time(y2027), 0x18, "20270101000000Z")
_expect_time(t, asn1.utc_time(y2027), 0x17, "270101000000Z")
// Nonzero month/day/time-of-day: 2023-11-14 22:13:20Z.
tod := time.unix(1700000000, 0)
_expect_time(t, asn1.generalized_time(tod), 0x18, "20231114221320Z")
// Round-trip both forms through the reader.
{
der := _marshal(t, asn1.generalized_time(tod))
defer delete(der)
cur: asn1.Cursor
asn1.cursor_init(&cur, der)
got, err := asn1.read_generalized_time(&cur)
testing.expect_value(t, err, asn1.Error.None)
testing.expect_value(t, time.to_unix_seconds(got), i64(1700000000))
}
{
der := _marshal(t, asn1.utc_time(y2027))
defer delete(der)
cur: asn1.Cursor
asn1.cursor_init(&cur, der)
got, err := asn1.read_utc_time(&cur)
testing.expect_value(t, err, asn1.Error.None)
testing.expect_value(t, time.to_unix_seconds(got), i64(1798761600))
}
}
// Nesting + a second leaf type: SEQUENCE { BOOLEAN, SEQUENCE { INTEGER } }.
// Re-encoding the parsed structure must reproduce the bytes exactly
// (DER is canonical), which is the core correctness property for a writer.
@(test)
test_writer_nested_and_idempotent :: proc(t: ^testing.T) {
inner := asn1.sequence({asn1.integer_unsigned({0x2A})})
outer := asn1.sequence({asn1.boolean(true), inner})
der := _marshal(t, outer)
defer delete(der)
// 30 08 01 01 FF 30 03 02 01 2A
want := []byte{0x30, 0x08, 0x01, 0x01, 0xFF, 0x30, 0x03, 0x02, 0x01, 0x2A}
testing.expectf(t, bytes.equal(der, want), "got %x, want %x", der, want)
// Walk it back through the reader to confirm it parses as DER.
cur: asn1.Cursor
asn1.cursor_init(&cur, der)
o, oerr := asn1.read_sequence(&cur)
testing.expect_value(t, oerr, asn1.Error.None)
b, berr := asn1.read_boolean(&o)
testing.expect_value(t, berr, asn1.Error.None)
testing.expect_value(t, b, true)
i, ierr := asn1.read_sequence(&o)
testing.expect_value(t, ierr, asn1.Error.None)
mag, merr := asn1.read_unsigned_integer_bytes(&i)
testing.expect_value(t, merr, asn1.Error.None)
testing.expect(t, bytes.equal(mag, {0x2A}), "inner integer round-trips")
testing.expect_value(t, asn1.done(&o), asn1.Error.None)
testing.expect_value(t, asn1.done(&cur), asn1.Error.None)
}

View File

@@ -139,6 +139,13 @@ IP_Address_Parsing_Test_Vectors :: []IP_Address_Parsing_Test_Vector{
{ .IP4, "[10.0.128.31] :80", "", ""},
{ .IP4, "[255.255.255.255]:65536", "", ""},
// "]:" with no opening '[' is not a bracketed host:port; it parses
// as a plain host (no port) and so is not a valid address here.
{ .IP4, "]:80", "", ""},
{ .IP4, "]:", "", ""},
{ .IP6, "]:1", "", ""},
{ .IP4, "foo]:80", "", ""},
// numbers-and-dots notation, but not dotted-decimal
{ .IP4_Alt, "1.2.03.4", "01020304", ""},

View File

@@ -13,6 +13,8 @@ download_assets :: proc "contextless" () {
@(require) import "c/libc"
@(require) import "compress"
@(require) import "container"
@(require) import "crypto/x509"
@(require) import "encoding/asn1"
@(require) import "encoding/base32"
@(require) import "encoding/base64"
@(require) import "encoding/cbor"