diff --git a/runtime/src/crypto.c b/runtime/src/crypto.c index 73858db..08d1f37 100644 --- a/runtime/src/crypto.c +++ b/runtime/src/crypto.c @@ -1485,6 +1485,9 @@ typedef struct { const uint8_t *ec_x, *ec_y; /* 32 bytes each when EC P-256 */ /* validity as YYYYMMDDHHMMSSZ-comparable 14-byte strings */ char not_before[15], not_after[15]; + /* the bytes of tbsCertificate after subjectPublicKeyInfo โ€” optional + * uniqueIDs then the [3] extensions; walked lazily by the SAN check. */ + const uint8_t *ext_area; size_t ext_area_len; } x509_cert; enum { WO_X509_SIG_RSA_PKCS1_SHA256 = 1, WO_X509_SIG_RSA_PSS_SHA256, WO_X509_SIG_ECDSA_P256_SHA256, WO_X509_SIG_UNKNOWN = 0 }; @@ -1596,12 +1599,92 @@ static int x509_parse(const uint8_t *der_buf, size_t len, x509_cert *c) { } else { return -1; } - /* extensions [3] (incl. SAN) are left for phase F, where the target - * hostname is known and can be matched. Chain signature, SPKI and - * validity are settled here. */ + /* Remaining tbs bytes (optional uniqueIDs + [3] extensions). The chain + * signature, SPKI and validity are settled above; the SAN/hostname check + * walks this area on demand (wo_x509_check_host). */ + c->ext_area = tbs.p; c->ext_area_len = (size_t)(tbs.end - tbs.p); return 0; } +static const uint8_t OID_SAN[] = { 0x55, 0x1d, 0x11 }; /* 2.5.29.17 */ + +/* Case-insensitive match of a presented dNSName pattern against a hostname, + * with a single left-most "*" wildcard (RFC 6125 ยง6.4.3): "*.example.com" + * matches one label, never a bare "example.com" or a dotted sub-label. */ +static int host_match(const char *pat, size_t patlen, const char *host, + size_t hostlen) { + if (patlen == 0 || hostlen == 0) return 0; + if (pat[0] == '*') { + /* pattern is "*"+rest; rest must start with '.'. Match the suffix and + * require the wildcard to cover exactly one (non-empty, dot-free) label. */ + if (patlen < 2 || pat[1] != '.') return 0; + const char *rest = pat + 1; size_t restlen = patlen - 1; + if (hostlen <= restlen) return 0; + size_t hlead = hostlen - restlen; /* the part '*' must cover */ + for (size_t i = 0; i < hlead; i++) + if (host[i] == '.') return 0; /* no dot in the wildcard */ + /* suffix compare, case-insensitive */ + for (size_t i = 0; i < restlen; i++) { + char a = rest[i], b = host[hlead + i]; + if (a >= 'A' && a <= 'Z') a = (char)(a + 32); + if (b >= 'A' && b <= 'Z') b = (char)(b + 32); + if (a != b) return 0; + } + return 1; + } + if (patlen != hostlen) return 0; + for (size_t i = 0; i < patlen; i++) { + char a = pat[i], b = host[i]; + if (a >= 'A' && a <= 'Z') a = (char)(a + 32); + if (b >= 'A' && b <= 'Z') b = (char)(b + 32); + if (a != b) return 0; + } + return 1; +} + +/* Verify `hostname` against the certificate's subjectAltName dNSName entries + * (RFC 6125). Returns 1 if any SAN dNSName matches, 0 otherwise (including no + * SAN present โ€” a cert without SAN is not accepted for a hostname). The legacy + * CN fallback is deliberately not implemented. */ +int wo_x509_check_host(const uint8_t *cert_der, size_t cert_len, + const char *hostname, size_t hostlen) { + x509_cert c; + if (x509_parse(cert_der, cert_len, &c) != 0) return 0; + der r = { c.ext_area, c.ext_area + c.ext_area_len }; + /* skip optional issuerUniqueID [1] (0x81) / subjectUniqueID [2] (0x82) */ + while (r.p < r.end && (*r.p == 0x81 || *r.p == 0x82)) + if (der_skip(&r) < 0) return 0; + der exp; + if (der_into(&r, 0xA3, &exp) < 0) return 0; /* [3] EXPLICIT */ + der exts; + if (der_into(&exp, 0x30, &exts) < 0) return 0; /* SEQUENCE OF Extension */ + while (exts.p < exts.end) { + der ext; + if (der_into(&exts, 0x30, &ext) < 0) return 0; + const uint8_t *oid; size_t oidlen; + if (der_tlv(&ext, &oid, &oidlen) != 0x06) return 0; + /* optional critical BOOLEAN */ + if (ext.p < ext.end && *ext.p == 0x01) + if (der_skip(&ext) < 0) return 0; + const uint8_t *val; size_t vallen; + if (der_tlv(&ext, &val, &vallen) != 0x04) return 0; /* OCTET STRING */ + if (!oid_eq(oid, oidlen, OID_SAN, sizeof OID_SAN)) continue; + /* val = SEQUENCE OF GeneralName; dNSName is [2] IMPLICIT IA5String. */ + der names = { val, val + vallen }, seq; + if (der_into(&names, 0x30, &seq) < 0) return 0; + while (seq.p < seq.end) { + const uint8_t *gn; size_t gnlen; + int tag = der_tlv(&seq, &gn, &gnlen); + if (tag < 0) return 0; + if (tag == 0x82 && /* dNSName */ + host_match((const char *)gn, gnlen, hostname, hostlen)) + return 1; + } + return 0; /* SAN present, no match */ + } + return 0; /* no SAN extension */ +} + /* Verify `c`'s signature over its tbsCertificate using an issuer public key * already parsed into `issuer`. 1 valid, 0 otherwise. */ static int x509_verify_sig(const x509_cert *c, const x509_cert *issuer) { diff --git a/runtime/src/crypto.h b/runtime/src/crypto.h index d2bf1eb..aa511db 100644 --- a/runtime/src/crypto.h +++ b/runtime/src/crypto.h @@ -80,6 +80,10 @@ int wo_x509_parse_spki(const uint8_t *cert_der, size_t cert_len, int *key_alg, const uint8_t **ec_x, const uint8_t **ec_y); int wo_x509_check_validity(const uint8_t *cert_der, size_t cert_len, const char now14[14]); +/* Match hostname against the cert's subjectAltName dNSNames (RFC 6125, single + * left-most wildcard). 1 match, 0 otherwise (no SAN => 0; no CN fallback). */ +int wo_x509_check_host(const uint8_t *cert_der, size_t cert_len, + const char *hostname, size_t hostlen); int wo_builtin_crypto(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg); diff --git a/runtime/src/tls.c b/runtime/src/tls.c index 6cfc5f4..99acd2a 100644 --- a/runtime/src/tls.c +++ b/runtime/src/tls.c @@ -443,6 +443,10 @@ int wo_tls_client_start_with(wo_tls_client *c, const uint8_t *ch_msg, return 0; } +void wo_tls_client_set_host(wo_tls_client *c, const char *host, size_t hostlen) { + c->host = host; c->hostlen = hostlen; +} + size_t wo_tls_client_take_output(wo_tls_client *c, uint8_t *out, size_t outcap) { size_t n = c->outn < outcap ? c->outn : 0; /* all-or-nothing */ if (n) { memcpy(out, c->out, n); c->outn = 0; } @@ -492,6 +496,10 @@ static int on_flight_msg(wo_tls_client *c, const uint8_t *msg, size_t mlen) { p += 3; if (p + clen > mlen || clen > sizeof c->leaf) return -1; memcpy(c->leaf, msg + p, clen); c->leaflen = clen; + /* hostname check (when a host was set): a leaf whose SAN does not match + * the target host is a refused connection, not a warning. */ + if (c->host && !wo_x509_check_host(c->leaf, c->leaflen, c->host, c->hostlen)) + return -1; return tr_add(c, msg, mlen) == 0 ? 0 : -1; } if (type == 0x0f) { /* CertificateVerify */ diff --git a/runtime/src/tls.h b/runtime/src/tls.h index 2f4214a..2e0255d 100644 --- a/runtime/src/tls.h +++ b/runtime/src/tls.h @@ -145,9 +145,17 @@ typedef struct { uint8_t leaf[WO_TLS_LEAF_MAX]; size_t leaflen; uint16_t cv_scheme; uint8_t out[1024]; size_t outn; /* bytes for the caller to send */ + const char *host; size_t hostlen; /* if set, leaf SAN is enforced */ int st; /* internal FSM state */ } wo_tls_client; +/* Enforce the leaf certificate's SAN against `host` during the handshake โ€” a + * connection whose certificate does not match is refused. MUST be called + * (after start) for a real connection; if left unset the driver skips the + * hostname check (offline testing only, and MITM-unsafe on the wire). The + * string must outlive the handshake (not copied). */ +void wo_tls_client_set_host(wo_tls_client *c, const char *host, size_t hostlen); + /* Start a handshake from a caller-built ClientHello handshake message and a * fixed X25519 private key (production passes fresh randomness; the KAT injects * the RFC's). Frames the ClientHello into a plaintext record in c->out for the diff --git a/runtime/test/test_crypto.c b/runtime/test/test_crypto.c index f3e4796..6eee667 100644 --- a/runtime/test/test_crypto.c +++ b/runtime/test/test_crypto.c @@ -420,5 +420,25 @@ int main(void) { "20250101000000") == 1); } + /* SAN / hostname verification (rv2 9 phase E completion, RFC 6125). */ + { + #define HOST(cert, h) wo_x509_check_host(cert, sizeof cert, h, strlen(h)) + /* leaf SANs: kat_rsa_leaf=leaf.example.com, kat_ec_leaf=leaf.example.org */ + T_CHECK(HOST(kat_rsa_leaf, "leaf.example.com") == 1); + T_CHECK(HOST(kat_rsa_leaf, "LEAF.Example.CoM") == 1); /* case-insensitive */ + T_CHECK(HOST(kat_rsa_leaf, "other.example.com") == 0); + T_CHECK(HOST(kat_rsa_leaf, "leaf.example.org") == 0); + T_CHECK(HOST(kat_ec_leaf, "leaf.example.org") == 1); + /* a CA cert here has no SAN -> refused for any hostname */ + T_CHECK(HOST(kat_rsa_ca, "wo-rsa-ca") == 0); + /* wildcard *.example.com matches one label, not zero or a sub-label */ + T_CHECK(HOST(kat_wild_leaf, "api.example.com") == 1); + T_CHECK(HOST(kat_wild_leaf, "API.example.com") == 1); + T_CHECK(HOST(kat_wild_leaf, "example.com") == 0); /* no label */ + T_CHECK(HOST(kat_wild_leaf, "a.b.example.com") == 0); /* extra label */ + T_CHECK(HOST(kat_wild_leaf, "api.example.org") == 0); + #undef HOST + } + return t_report("test_crypto"); } diff --git a/runtime/test/test_tls.c b/runtime/test/test_tls.c index 50ef113..d8fbe00 100644 --- a/runtime/test/test_tls.c +++ b/runtime/test/test_tls.c @@ -295,5 +295,19 @@ int main(void) { T_CHECK(wo_tls_client_push_record(&c, rfl, sizeof drv_rec_flight) == WO_TLS_FAILED); } + /* Hostname enforcement: with a host set, the RFC 8448 leaf (which carries + * no SAN) is refused at the Certificate step. */ + { + static wo_tls_client c; + uint8_t priv[32], ch[256]; + memcpy(priv, drv_client_priv, 32); memcpy(ch, drv_ch_msg, sizeof drv_ch_msg); + wo_tls_client_start_with(&c, ch, sizeof drv_ch_msg, priv); + wo_tls_client_set_host(&c, "api.anthropic.com", 17); + uint8_t rsh[128]; memcpy(rsh, drv_rec_sh, sizeof drv_rec_sh); + wo_tls_client_push_record(&c, rsh, sizeof drv_rec_sh); + uint8_t rfl[1024]; memcpy(rfl, drv_rec_flight, sizeof drv_rec_flight); + T_CHECK(wo_tls_client_push_record(&c, rfl, sizeof drv_rec_flight) == WO_TLS_FAILED); + } + return t_report("test_tls"); } diff --git a/runtime/test/x509_vectors.h b/runtime/test/x509_vectors.h index 7ff10ba..d4825bb 100644 --- a/runtime/test/x509_vectors.h +++ b/runtime/test/x509_vectors.h @@ -191,3 +191,34 @@ static const uint8_t kat_ec_leaf[] = { 0xc1,0x1c, }; + +/* wildcard-SAN leaf (*.example.com) for the hostname-match KAT */ +static const uint8_t kat_wild_leaf[] = { + 0x30,0x82,0x01,0x3a,0x30,0x81,0xe2,0xa0,0x03,0x02,0x01,0x02, + 0x02,0x14,0x7e,0x22,0xf7,0xef,0x7a,0x6b,0x37,0xb4,0x70,0xeb, + 0x2b,0xb6,0x91,0xae,0xa1,0xe5,0x34,0x8c,0x4b,0x3c,0x30,0x0a, + 0x06,0x08,0x2a,0x86,0x48,0xce,0x3d,0x04,0x03,0x02,0x30,0x0f, + 0x31,0x0d,0x30,0x0b,0x06,0x03,0x55,0x04,0x03,0x0c,0x04,0x77, + 0x69,0x6c,0x64,0x30,0x1e,0x17,0x0d,0x32,0x30,0x30,0x31,0x30, + 0x31,0x30,0x30,0x30,0x30,0x30,0x30,0x5a,0x17,0x0d,0x33,0x30, + 0x30,0x31,0x30,0x31,0x30,0x30,0x30,0x30,0x30,0x30,0x5a,0x30, + 0x0f,0x31,0x0d,0x30,0x0b,0x06,0x03,0x55,0x04,0x03,0x0c,0x04, + 0x77,0x69,0x6c,0x64,0x30,0x59,0x30,0x13,0x06,0x07,0x2a,0x86, + 0x48,0xce,0x3d,0x02,0x01,0x06,0x08,0x2a,0x86,0x48,0xce,0x3d, + 0x03,0x01,0x07,0x03,0x42,0x00,0x04,0xf5,0x4c,0xa6,0x77,0x22, + 0x89,0x33,0xab,0x6c,0x84,0xd0,0x0a,0x2c,0x16,0x68,0x1e,0x3f, + 0xb4,0x44,0xe9,0x69,0x71,0x2a,0x1b,0x79,0xd3,0xa9,0x40,0xcb, + 0xc3,0x4f,0xd8,0x29,0x7c,0x85,0xd3,0xf9,0x9e,0x9c,0x42,0x8c, + 0x99,0x2e,0xee,0x93,0x26,0xfe,0x9e,0xd0,0x9b,0x75,0x19,0x7a, + 0x1a,0xc9,0x2a,0x12,0x8d,0x9d,0x19,0xb9,0x43,0x31,0x0c,0xa3, + 0x1c,0x30,0x1a,0x30,0x18,0x06,0x03,0x55,0x1d,0x11,0x04,0x11, + 0x30,0x0f,0x82,0x0d,0x2a,0x2e,0x65,0x78,0x61,0x6d,0x70,0x6c, + 0x65,0x2e,0x63,0x6f,0x6d,0x30,0x0a,0x06,0x08,0x2a,0x86,0x48, + 0xce,0x3d,0x04,0x03,0x02,0x03,0x47,0x00,0x30,0x44,0x02,0x20, + 0x44,0x88,0xdd,0xd7,0x3b,0x8b,0xe8,0xaf,0xd5,0xc0,0xf0,0xc3, + 0x86,0xc2,0xf8,0xfa,0x6c,0x23,0x0c,0xdd,0x17,0xcc,0x13,0xb4, + 0xe7,0xa3,0x01,0x92,0xce,0xe3,0x9f,0xf6,0x02,0x20,0x11,0x8d, + 0x50,0xab,0xde,0x45,0xb0,0xf8,0x34,0x6f,0xbd,0xa6,0x28,0xd3, + 0x4f,0x23,0x67,0x58,0xb0,0x41,0x6e,0x31,0xcf,0x59,0xad,0x5b, + 0xef,0x49,0x24,0x3e,0x92,0xa3, +};