Plan 9 from Bell Labs’s /usr/web/sources/contrib/mospak/tls-modern-client/libsec-x509-chain-hostname.patch

Copyright © 2021 Plan 9 Foundation.
Distributed under the MIT License.
Download the Plan 9 distribution.


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

Bell Labs OSI certified Powered by Plan 9

(Return to Plan 9 Home Page)

Copyright © 2021 Plan 9 Foundation. All Rights Reserved.
Comments to webmaster@9p.io.