feat(tls): SAN/hostname verification + driver enforcement (rv2 9 phase E/F3c)

- wo_x509_check_host: match a hostname against the cert subjectAltName
  dNSNames (RFC 6125) — case-insensitive, single left-most wildcard that
  covers exactly one label; no SAN => refused; no legacy CN fallback.
  Completes the phase-E deferred hostname check (walks the [3] extensions)
- wo_tls_client_set_host + driver enforcement: with a host set, a leaf
  whose SAN does not match is refused at the Certificate step (MITM
  defense); unset skips the check (offline testing only, documented unsafe)
- KAT: exact/case-insensitive/mismatch, no-SAN refused, wildcard one-label
  (not zero, not sub-label) via a wildcard-SAN cert; driver refuses the
  RFC 8448 leaf (no SAN) once a host is set. test_crypto 95, test_tls 91,
  ASan/UBSan clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 319ce8bfcf608030f958b36ab2c8f62fd76e1740)
This commit is contained in:
shoney.arickathil 2026-09-08 18:20:51 +02:00
parent d7e7eff6b5
commit d7a6888931
7 changed files with 171 additions and 3 deletions

View file

@ -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) {

View file

@ -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);

View file

@ -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 */

View file

@ -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

View file

@ -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");
}

View file

@ -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");
}

View file

@ -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,
};