mirror of
https://github.com/odin-lang/Odin.git
synced 2026-08-30 08:51:30 +00:00
Merge pull request #6838 from kalsprite/x.509
core:encoding/asn1 (strict DER) + core:crypto/x509 (X.509 v3)
This commit is contained in:
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -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: |
|
||||
|
||||
@@ -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
99
core/crypto/x509/doc.odin
Normal 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
160
core/crypto/x509/ext.odin
Normal 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
|
||||
}
|
||||
438
core/crypto/x509/marshal.odin
Normal file
438
core/crypto/x509/marshal.odin
Normal 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
198
core/crypto/x509/name.odin
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
363
core/crypto/x509/name_constraints.odin
Normal file
363
core/crypto/x509/name_constraints.odin
Normal 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
751
core/crypto/x509/parse.odin
Normal 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
143
core/crypto/x509/pkcs8.odin
Normal 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
|
||||
}
|
||||
536
core/crypto/x509/verify.odin
Normal file
536
core/crypto/x509/verify.odin
Normal 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
247
core/crypto/x509/x509.odin
Normal 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)
|
||||
72
core/encoding/asn1/asn1.odin
Normal file
72
core/encoding/asn1/asn1.odin
Normal 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}
|
||||
}
|
||||
619
core/encoding/asn1/cursor.odin
Normal file
619
core/encoding/asn1/cursor.odin
Normal 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
|
||||
}
|
||||
29
core/encoding/asn1/doc.odin
Normal file
29
core/encoding/asn1/doc.odin
Normal 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
|
||||
493
core/encoding/asn1/writer.odin
Normal file
493
core/encoding/asn1/writer.odin
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
2
tests/core/assets/X509-Limbo/.gitignore
vendored
Normal file
2
tests/core/assets/X509-Limbo/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.json
|
||||
*.zip
|
||||
133
tests/core/crypto/x509/fuzz_x509.odin
Normal file
133
tests/core/crypto/x509/fuzz_x509.odin
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
138
tests/core/crypto/x509/oom_x509.odin
Normal file
138
tests/core/crypto/x509/oom_x509.odin
Normal 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))
|
||||
}
|
||||
}
|
||||
868
tests/core/crypto/x509/test_core_x509.odin
Normal file
868
tests/core/crypto/x509/test_core_x509.odin
Normal 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)
|
||||
}
|
||||
BIN
tests/core/crypto/x509/testdata/chain_ec_expired_root.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/chain_ec_expired_root.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/chain_ec_expleaf.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/chain_ec_expleaf.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/chain_ec_inter.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/chain_ec_inter.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/chain_ec_leaf.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/chain_ec_leaf.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/chain_ec_other_root.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/chain_ec_other_root.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/chain_ec_root.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/chain_ec_root.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/chain_ed_leaf.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/chain_ed_leaf.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/chain_ed_root.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/chain_ed_root.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/cyc_a.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/cyc_a.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/cyc_a_by_b.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/cyc_a_by_b.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/cyc_b.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/cyc_b.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/cyc_b_by_a.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/cyc_b_by_a.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/cyc_leaf.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/cyc_leaf.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/dup_extension.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/dup_extension.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/ec.der
vendored
Normal file
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
BIN
tests/core/crypto/x509/testdata/ed.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/eku_alt_bad.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/eku_alt_bad.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/eku_alt_good.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/eku_alt_good.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/eku_alt_leaf.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/eku_alt_leaf.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/nc_dir_inter.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/nc_dir_inter.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/nc_excl_inter.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/nc_excl_inter.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/nc_inter.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/nc_inter.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/nc_leaf_bad.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/nc_leaf_bad.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/nc_leaf_dir.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/nc_leaf_dir.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/nc_leaf_ok.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/nc_leaf_ok.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/nc_leaf_wild.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/nc_leaf_wild.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/nc_root.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/nc_root.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/neg_eku_inter.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/neg_eku_inter.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/neg_eku_leaf.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/neg_eku_leaf.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/neg_expinter_inter.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/neg_expinter_inter.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/neg_expinter_leaf.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/neg_expinter_leaf.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/neg_nc_inter.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/neg_nc_inter.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/neg_nc_leaf.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/neg_nc_leaf.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/neg_nokcs_inter.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/neg_nokcs_inter.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/neg_nokcs_leaf.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/neg_nokcs_leaf.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/neg_notca_inter.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/neg_notca_inter.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/neg_notca_leaf.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/neg_notca_leaf.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/neg_pl_a.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/neg_pl_a.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/neg_pl_b.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/neg_pl_b.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/neg_pl_leaf.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/neg_pl_leaf.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/neg_root.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/neg_root.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/pss.der
vendored
Normal file
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
BIN
tests/core/crypto/x509/testdata/rsa.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/rsa_sha1.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/rsa_sha1.der
vendored
Normal file
Binary file not shown.
BIN
tests/core/crypto/x509/testdata/serial_zero.der
vendored
Normal file
BIN
tests/core/crypto/x509/testdata/serial_zero.der
vendored
Normal file
Binary file not shown.
46
tests/core/crypto/x509_limbo/README.md
Normal file
46
tests/core/crypto/x509_limbo/README.md
Normal 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
|
||||
84
tests/core/crypto/x509_limbo/allow.odin
Normal file
84
tests/core/crypto/x509_limbo/allow.odin
Normal 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",
|
||||
}
|
||||
39
tests/core/crypto/x509_limbo/schemas.odin
Normal file
39
tests/core/crypto/x509_limbo/schemas.odin
Normal 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
|
||||
}
|
||||
235
tests/core/crypto/x509_limbo/x509_limbo.odin
Normal file
235
tests/core/crypto/x509_limbo/x509_limbo.odin
Normal 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
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
222
tests/core/encoding/asn1/fuzz_asn1.odin
Normal file
222
tests/core/encoding/asn1/fuzz_asn1.odin
Normal 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)
|
||||
}
|
||||
}
|
||||
158
tests/core/encoding/asn1/fuzz_writer.odin
Normal file
158
tests/core/encoding/asn1/fuzz_writer.odin
Normal 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)
|
||||
}
|
||||
}
|
||||
119
tests/core/encoding/asn1/oom_asn1.odin
Normal file
119
tests/core/encoding/asn1/oom_asn1.odin
Normal 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))
|
||||
}
|
||||
}
|
||||
532
tests/core/encoding/asn1/test_core_asn1.odin
Normal file
532
tests/core/encoding/asn1/test_core_asn1.odin
Normal 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)
|
||||
}
|
||||
298
tests/core/encoding/asn1/test_writer.odin
Normal file
298
tests/core/encoding/asn1/test_writer.odin
Normal 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)
|
||||
}
|
||||
@@ -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", ""},
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user