libsec: X.509 chain validation + RFC 6125 hostname matching
Adds the cryptographic pieces of an X.509 verifier for TLS
clients: cert-signed-by-cert at each chain hop (RSA or ECDSA),
an RFC 6125-compliant SAN/CN hostname matcher, and a chain
walk that terminates at a trust anchor.
New public API in libsec.h:
X509ecdsaverify verify an ECDSA-signed certificate
X509matchhostname RFC 6125 SAN/CN match (DNS + IPv4)
X509verifychain chain walk to a trust anchor
CertX509 gains a `Bytes *ext` capturing the raw
TBSCertificate extensions SEQUENCE, which decode_cert
previously parsed and discarded.
Two latent defects in the pre-existing verifier are fixed in
passing:
X509verify's digest buffer was sized SHA1dlen, overrun by
SHA-256/384/512-signed certs. Bumped to SHA2_512dlen.
asn1mpint went through is_bigint(), whose VBigInt branch
aliases the tree's bigintval without copying. Freeing
the alias left a dangling Bytes* in the tree that a later
freevalfields re-freed, crashing with "D2B called on
non-block". New asn1mpint reads bytes in place.
Hostname matcher enforces leftmost-label-only, one-label
wildcard coverage (RFC 6125 §6.4.3). IPv4 literals match
iPAddress SAN (§6.2.2); IPv6 literals are not yet handled.
CN fallback (§6.4.4) only when there are no DNS SAN entries
and the hostname is not an IP literal.
BUG: validity dates, basicConstraints CA:TRUE, keyUsage,
extKeyUsage, and nameConstraints are not checked. The
cryptographic chain and hostname binding — the MITM-relevant
half — are enforced by this patch. Validity dates land in
libsec-x509-validity-dates.
RFC 5280 §6.1 (chain signature validation), §4.2.1.6 (SAN);
6125 §6.4.3 (DNS wildcard rules), §6.2.2 (IPv4 match).
--- sys/include/libsec.h
+++ sys/include/libsec.h
@@ -403,6 +403,9 @@
char* rsapkcs1verify(RSApub *pk, int sigalg, uchar *msg, ulong msglen, uchar *sig, int siglen);
ECpub* X509toECpub(uchar *cert, int ncert, char *name, int nname, ECdomain *dom);
char* X509ecdsaverifydigest(uchar *sig, int siglen, uchar *edigest, int edigestlen, ECdomain *dom, ECpub *pub);
+char* X509ecdsaverify(uchar *cert, int ncert, ECdomain *dom, ECpub *pub);
+char* X509matchhostname(uchar *cert, int ncert, char *hostname);
+char* X509verifychain(PEMChain *chain, PEMChain *roots, char *hostname);
/*
* elgamal
--- sys/src/libsec/port/x509.c
+++ sys/src/libsec/port/x509.c
@@ -1573,6 +1573,7 @@
Bytes* publickey;
int signature_alg;
Bytes* signature;
+ Bytes* ext; /* raw extensions SEQUENCE (opaque); nil if cert has none */
} CertX509;
/* Algorithm object-ids */
@@ -1691,6 +1692,7 @@
free(c->subject);
freebytes(c->publickey);
freebytes(c->signature);
+ freebytes(c->ext);
free(c);
}
@@ -1827,6 +1829,7 @@
c->publickey = nil;
c->signature_alg = -1;
c->signature = nil;
+ c->ext = nil;
/* Certificate */
if(!is_seq(&ecert, &elcert) || elistlen(elcert) !=3)
@@ -1911,6 +1914,18 @@
goto errret;
c->publickey = makebytes(bits->data, bits->len);
+ /* optional TBSCertificate extensions: [3] EXPLICIT Extensions */
+ el = el->tl;
+ while(el != nil){
+ if(el->hd.tag.class == Context && el->hd.tag.num == 3
+ && el->hd.val.tag == VOctets){
+ c->ext = el->hd.val.u.octetsval;
+ el->hd.val.u.octetsval = nil; /* steal ownership */
+ break;
+ }
+ el = el->tl;
+ }
+
/*resume Certificate */
if(c->signature_alg < 0)
goto errret;
@@ -2107,19 +2122,25 @@
return nil;
}
+/*
+ * Convert an ASN.1 INTEGER Elem into a freshly-allocated mpint.
+ * We can't use is_bigint() here — in the VBigInt case it aliases
+ * pe->val.u.bigintval (no copy), so freeing the returned Bytes would
+ * double-free it when the surrounding ASN tree is later freed.
+ * We just read the bytes in place and leave tree ownership alone.
+ */
static mpint*
asn1mpint(Elem *e)
{
Bytes *b;
- mpint *mp;
- int v;
- if(is_int(e, &v))
- return itomp(v, nil);
- if(is_bigint(e, &b)) {
- mp = betomp(b->data, b->len, nil);
- freebytes(b);
- return mp;
+ if(e->tag.class != Universal || e->tag.num != INTEGER)
+ return nil;
+ if(e->val.tag == VInt)
+ return itomp(e->val.u.intval, nil);
+ if(e->val.tag == VBigInt){
+ b = e->val.u.bigintval;
+ return betomp(b->data, b->len, nil);
}
return nil;
}
@@ -2447,7 +2468,7 @@
char *e;
Bytes *b;
CertX509 *c;
- uchar digest[SHA1dlen];
+ uchar digest[SHA2_512dlen]; /* sized for any supported hash */
Elem *sigalg;
b = makebytes(cert, ncert);
@@ -2460,6 +2481,335 @@
e = verify_signature(c->signature, pk, digest, &sigalg);
freecert(c);
return e;
+}
+
+/* OID for the SubjectAltName X.509v3 extension (id-ce-subjectAltName, 2.5.29.17) */
+static Ints9 oid_subjectAltName = {4, 2, 5, 29, 17};
+
+/* digest length table, parallel to digestalg[] */
+static int digestlen[NUMALGS+1] = {
+ MD5dlen, MD5dlen, MD5dlen, MD5dlen, SHA1dlen, SHA1dlen,
+ SHA2_256dlen, SHA2_384dlen, SHA2_512dlen, SHA2_224dlen,
+ MD5dlen, SHA1dlen, SHA2_256dlen, SHA2_384dlen, SHA2_512dlen, SHA2_224dlen,
+ 0,
+ SHA1dlen, SHA2_256dlen, SHA2_384dlen, SHA2_512dlen,
+ 0,
+};
+
+/*
+ * Verify an X.509 certificate whose TBS is signed with ECDSA.
+ * dom/pub come from the issuer certificate (extract via X509toECpub).
+ */
+char*
+X509ecdsaverify(uchar *cert, int ncert, ECdomain *dom, ECpub *pub)
+{
+ char *e;
+ Bytes *b;
+ CertX509 *c;
+ uchar digest[SHA2_512dlen];
+
+ b = makebytes(cert, ncert);
+ c = decode_cert(b);
+ if(c == nil){
+ freebytes(b);
+ return "cannot decode cert";
+ }
+ if(c->signature_alg < 0 || digestalg[c->signature_alg] == nil || digestlen[c->signature_alg] == 0){
+ freecert(c);
+ freebytes(b);
+ return "unsupported signature algorithm";
+ }
+ digest_certinfo(b, digestalg[c->signature_alg], digest);
+ freebytes(b);
+ e = X509ecdsaverifydigest(c->signature->data, c->signature->len,
+ digest, digestlen[c->signature_alg], dom, pub);
+ freecert(c);
+ return e;
+}
+
+/*
+ * RFC 6125 §6.4.3: case-insensitive DNS-name match with leftmost-label
+ * wildcard. "*.example.com" matches "foo.example.com" but NOT
+ * "foo.bar.example.com" (one-label wildcard only) or "example.com".
+ * pat is the SAN/CN pattern; host is the caller's expected hostname.
+ * Both assumed to be lowercase-normalized on arrival.
+ */
+static int
+matchhostname(char *pat, int patlen, char *host)
+{
+ int hlen, plen;
+ char *star, *remainder;
+ int prefixlen, suffixlen, i;
+
+ hlen = strlen(host);
+ plen = patlen;
+
+ /* exact match (case-insensitive) */
+ if(plen == hlen && cistrncmp(pat, host, plen) == 0)
+ return 1;
+
+ /* look for a wildcard '*' — must be in the first label, alone */
+ star = memchr(pat, '*', plen);
+ if(star == nil)
+ return 0;
+ if(star != pat) /* leftmost-label-only wildcard */
+ return 0;
+ /* '*' must not span a dot — it covers exactly one label */
+ remainder = star + 1;
+ if(remainder > pat + plen || (remainder < pat + plen && *remainder != '.'))
+ return 0;
+ /* pat = "*" + remainder; host must have at least one label + remainder */
+ suffixlen = (pat + plen) - remainder;
+ if(hlen < suffixlen + 1) /* need at least 1 char + suffix */
+ return 0;
+ /* the leftmost host label must not contain a dot */
+ for(i = 0; i < hlen - suffixlen; i++){
+ if(host[i] == '.')
+ return 0;
+ }
+ prefixlen = hlen - suffixlen;
+ if(prefixlen < 1)
+ return 0;
+ return cistrncmp(remainder, host + prefixlen, suffixlen) == 0;
+}
+
+/*
+ * Check a certificate's SubjectAltName DNS entries (and fall back to CN
+ * per RFC 6125 §6.4.4 only if no DNS SAN is present) against the expected
+ * hostname. Returns nil on match, error string on failure.
+ */
+/*
+ * Parse a dotted-quad IPv4 literal like "1.2.3.4" into 4 octets.
+ * Returns 4 on success, 0 on parse failure (not a valid IPv4 literal).
+ */
+static int
+parseipv4(char *s, uchar out[4])
+{
+ int i, v;
+ char *p;
+
+ for(i = 0; i < 4; i++){
+ if(*s < '0' || *s > '9')
+ return 0;
+ v = strtol(s, &p, 10);
+ if(p == s || v < 0 || v > 255)
+ return 0;
+ out[i] = v;
+ s = p;
+ if(i < 3){
+ if(*s != '.')
+ return 0;
+ s++;
+ }
+ }
+ return *s == 0 ? 4 : 0;
+}
+
+char*
+X509matchhostname(uchar *cert, int ncert, char *hostname)
+{
+ Bytes *b;
+ CertX509 *c;
+ Elem eext, ealt;
+ Elist *el, *l;
+ Ints *oid;
+ Bytes *altoct, *name;
+ int havesan, matched, iplen;
+ uchar ipv4[4];
+ char *cn, *err;
+
+ if(hostname == nil || *hostname == 0)
+ return "no hostname to match against";
+
+ /* If hostname is an IPv4 literal, we match iPAddress SAN entries
+ * instead of dNSName entries (RFC 5280 §4.2.1.6, RFC 6125 §1.7.2). */
+ iplen = parseipv4(hostname, ipv4);
+
+ b = makebytes(cert, ncert);
+ c = decode_cert(b);
+ freebytes(b);
+ if(c == nil)
+ return "cannot decode cert";
+
+ err = "hostname does not match cert";
+ havesan = 0;
+ matched = 0;
+
+ if(c->ext != nil && decode(c->ext->data, c->ext->len, &eext) == ASN_OK){
+ if(is_seq(&eext, &el)){
+ for(; el != nil && !matched; el = el->tl){
+ if(!is_seq(&el->hd, &l) || elistlen(l) < 2)
+ continue;
+ if(!is_oid(&l->hd, &oid) || !ints_eq(oid, (Ints*)&oid_subjectAltName))
+ continue;
+ /* skip optional BOOLEAN critical flag */
+ l = l->tl;
+ if(l->hd.val.tag == VBool)
+ l = l->tl;
+ if(l == nil || !is_octetstring(&l->hd, &altoct))
+ continue;
+ if(decode(altoct->data, altoct->len, &ealt) != ASN_OK)
+ continue;
+ if(!is_seq(&ealt, &l)){
+ freevalfields(&ealt.val);
+ continue;
+ }
+ for(; l != nil; l = l->tl){
+ if(l->hd.tag.class != Context)
+ continue;
+ if(l->hd.val.tag != VOctets)
+ continue;
+ name = l->hd.val.u.octetsval;
+ /* GeneralName [7] iPAddress OCTET STRING — 4 octets for IPv4 */
+ if(l->hd.tag.num == 7 && iplen == 4){
+ havesan = 1;
+ if(name->len == 4 && memcmp(name->data, ipv4, 4) == 0){
+ matched = 1;
+ break;
+ }
+ continue;
+ }
+ /* GeneralName [2] dNSName IA5String — only when hostname
+ * is a textual name, not when it's an IP literal. */
+ if(l->hd.tag.num == 2 && iplen == 0){
+ havesan = 1;
+ if(matchhostname((char*)name->data, name->len, hostname)){
+ matched = 1;
+ break;
+ }
+ }
+ }
+ freevalfields(&ealt.val);
+ }
+ }
+ freevalfields(&eext.val);
+ }
+
+ if(!matched && !havesan && iplen == 0){
+ /* Fall back to subject CN (deprecated by RFC 6125 but still widespread).
+ * Only for textual hostnames — IP literals must have an iPAddress SAN
+ * per RFC 6125 §1.7.2; matching CN text against an IP would be unsafe. */
+ if(c->subject != nil){
+ cn = strchr(c->subject, ',');
+ if(cn != nil)
+ *cn = 0;
+ if(matchhostname(c->subject, strlen(c->subject), hostname))
+ matched = 1;
+ if(cn != nil)
+ *cn = ',';
+ }
+ }
+
+ if(matched)
+ err = nil;
+ freecert(c);
+ return err;
+}
+
+/*
+ * Verify that `cert` was signed by `signer` — extracts the signer's public
+ * key based on its publickey_alg and calls the appropriate algorithm-specific
+ * verify. Handles both RSA and ECDSA issuers. Returns nil on success.
+ */
+static char*
+cert_signedby(uchar *cert, int ncert, uchar *signer, int nsigner)
+{
+ Bytes *sb;
+ CertX509 *sc;
+ RSApub *rsa;
+ ECpub *ec;
+ ECdomain dom;
+ char *err;
+
+ sb = makebytes(signer, nsigner);
+ sc = decode_cert(sb);
+ freebytes(sb);
+ if(sc == nil)
+ return "cannot decode signer cert";
+
+ err = "unsupported signer key algorithm";
+ switch(sc->publickey_alg){
+ case ALG_rsaEncryption:
+ rsa = decode_rsapubkey(sc->publickey);
+ if(rsa == nil){
+ err = "cannot decode signer RSA pubkey";
+ break;
+ }
+ err = X509verify(cert, ncert, rsa);
+ rsapubfree(rsa);
+ break;
+ case ALG_ecPublicKey:
+ if(sc->curve < 0 || sc->curve >= NUMCURVES){
+ err = "unsupported signer EC curve";
+ break;
+ }
+ ecdominit(&dom, namedcurves[sc->curve]);
+ ec = ecdecodepub(&dom, sc->publickey->data, sc->publickey->len);
+ if(ec == nil){
+ ecdomfree(&dom);
+ err = "cannot decode signer EC pubkey";
+ break;
+ }
+ err = X509ecdsaverify(cert, ncert, &dom, ec);
+ ecpubfree(ec);
+ ecdomfree(&dom);
+ break;
+ }
+ freecert(sc);
+ return err;
+}
+
+/*
+ * Walk a certificate chain (leaf at head of PEMChain list, intermediates
+ * following). Verifies each cert's signature against the next cert in
+ * the chain, then verifies the top of the chain against one of the trust
+ * anchors in `roots`. Also calls X509matchhostname on the leaf when
+ * `hostname` is non-nil/empty.
+ *
+ * BUG: does not currently check validity dates, basicConstraints CA:TRUE,
+ * keyUsage/extKeyUsage, or nameConstraints. The cryptographic chain and
+ * hostname binding are enforced.
+ *
+ * Returns nil on success, error string on failure.
+ */
+char*
+X509verifychain(PEMChain *chain, PEMChain *roots, char *hostname)
+{
+ char *err;
+ PEMChain *cur, *r;
+
+ if(chain == nil || chain->pem == nil)
+ return "empty chain";
+
+ if(hostname != nil && *hostname != 0){
+ err = X509matchhostname(chain->pem, chain->pemlen, hostname);
+ if(err != nil)
+ return err;
+ }
+
+ /* verify each cert in the chain against the next cert's pubkey */
+ for(cur = chain; cur->next != nil; cur = cur->next){
+ if(cur->next->pem == nil)
+ return "truncated chain";
+ err = cert_signedby(cur->pem, cur->pemlen,
+ cur->next->pem, cur->next->pemlen);
+ if(err != nil)
+ return err;
+ }
+
+ /* terminate at a trust anchor: some root cert must have signed cur */
+ if(roots == nil)
+ return "no trust anchors configured";
+ err = "no trust anchor signed the top of the chain";
+ for(r = roots; r != nil; r = r->next){
+ if(r->pem == nil)
+ continue;
+ if(cert_signedby(cur->pem, cur->pemlen, r->pem, r->pemlen) == nil){
+ err = nil;
+ break;
+ }
+ }
+ return err;
}
/* ------- Elem constructors ---------- */
|