Files
bastion-agent/bastion_agent/x509gate.py
T
2026-09-26 18:31:40 +00:00

424 lines
18 KiB
Python

"""The manifest gate's certificate and signature checks, in the standard library.
The gate used to shell out to `openssl` for three things: read the signing
cert's CN, check that the cert chains to FREEAGENT_CA_ROOT, and verify the
ECDSA P-256 / SHA-256 signature over the manifest payload. Windows ships no
`openssl`, so on a Windows machine the agent refused every manifest and never
ran — its bastion reported `missing-openssl` and the user's context store was
never indexed.
This module does exactly those three checks with nothing but the standard
library, so the gate runs wherever Python does:
* a minimal DER reader for the X.509 fields the gate needs (subject and
issuer names, validity, the EC public key, basicConstraints, and the
signed TBS bytes with their signature);
* ECDSA verification over NIST P-256 (verification only — no secret is ever
held here, so constant-time arithmetic is not a concern).
Only what the managed agent's certificates use is accepted: EC P-256 keys and
ecdsa-with-SHA256 signatures. Anything else is refused by name, never guessed.
"""
import base64
import datetime as _dt
import hashlib
import re
# ── P-256 (FIPS 186-4, D.1.2.3) ─────────────────────────────────────────────
_P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff
_A = _P - 3
_B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b
_N = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551
_G = (0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296,
0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5)
# OIDs, as their DER content bytes.
_OID_CN = bytes.fromhex("550403") # 2.5.4.3
_OID_EC_PUBKEY = bytes.fromhex("2a8648ce3d0201") # 1.2.840.10045.2.1
_OID_P256 = bytes.fromhex("2a8648ce3d030107") # 1.2.840.10045.3.1.7
_OID_ECDSA_SHA256 = bytes.fromhex("2a8648ce3d040302") # 1.2.840.10045.4.3.2
_OID_BASIC_CONSTRAINTS = bytes.fromhex("551d13") # 2.5.29.19
_OID_KEY_USAGE = bytes.fromhex("551d0f") # 2.5.29.15
# Critical extensions the gate may meet and still decide correctly: those it
# reads (above) and the ones Go's x509 writes on the managed install's cert.
_UNDERSTOOD_EXTENSIONS = frozenset({
_OID_BASIC_CONSTRAINTS, _OID_KEY_USAGE,
bytes.fromhex("551d25"), # extKeyUsage
bytes.fromhex("551d11"), # subjectAltName
bytes.fromhex("551d0e"), # subjectKeyIdentifier
bytes.fromhex("551d23"), # authorityKeyIdentifier
})
_KU_KEY_CERT_SIGN = 0x0400 # keyUsage bit 5, as a big-endian 16-bit field
# The directory-string types a name value may carry, and how each decodes.
_NAME_STRING_CODECS = {0x0c: "utf-8", 0x13: "ascii", 0x16: "ascii",
0x14: "latin-1", 0x1e: "utf-16-be"}
class CertError(Exception):
"""A certificate or signature the gate cannot accept; the message says why."""
# ── DER ─────────────────────────────────────────────────────────────────────
def _tlv(buf, off):
"""(tag, content_start, content_end, next_off) of the element at `off`."""
if off + 2 > len(buf):
raise CertError("truncated DER")
tag = buf[off]
ln = buf[off + 1]
off += 2
if ln & 0x80:
n = ln & 0x7f
if n == 0 or n > 4 or off + n > len(buf):
raise CertError("unsupported DER length")
ln = int.from_bytes(buf[off:off + n], "big")
# DER: the long form only for lengths >= 128, with no leading zero
# byte — the one encoding a length has, as openssl requires.
if ln < 0x80 or buf[off] == 0:
raise CertError("non-minimal DER length")
off += n
end = off + ln
if end > len(buf):
raise CertError("truncated DER")
return tag, off, end, end
def _children(buf, start, end):
out = []
off = start
while off < end:
tag, cs, ce, off = _tlv(buf, off)
out.append((tag, cs, ce))
return out
def _expect(el, tag, what):
if el[0] != tag:
raise CertError("%s: unexpected DER tag 0x%02x" % (what, el[0]))
return el
def _name_parts(buf, start, end):
"""[(oid_bytes, value_str)] of an X.501 Name (SEQUENCE OF SET OF ATV)."""
parts = []
for rdn in _children(buf, start, end):
_expect(rdn, 0x31, "name RDN")
for atv in _children(buf, rdn[1], rdn[2]):
_expect(atv, 0x30, "name attribute")
kids = _children(buf, atv[1], atv[2])
if len(kids) != 2 or kids[0][0] != 0x06:
raise CertError("malformed name attribute")
raw = bytes(buf[kids[1][1]:kids[1][2]])
codec = _NAME_STRING_CODECS.get(kids[1][0])
if codec is None:
raise CertError("name attribute has string type 0x%02x" % kids[1][0])
try:
val = raw.decode(codec)
except UnicodeDecodeError:
raise CertError("name attribute is not text")
parts.append((bytes(buf[kids[0][1]:kids[0][2]]), val))
return parts
def _time(buf, el):
raw = buf[el[1]:el[2]].decode("ascii", "replace")
if el[0] == 0x17: # UTCTime YYMMDDHHMMSSZ
m = re.fullmatch(r"(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z", raw)
if not m:
raise CertError("unsupported UTCTime %r" % raw)
y = int(m.group(1))
y += 2000 if y < 50 else 1900
rest = [int(g) for g in m.groups()[1:]]
elif el[0] == 0x18: # GeneralizedTime YYYYMMDDHHMMSSZ
m = re.fullmatch(r"(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z", raw)
if not m:
raise CertError("unsupported GeneralizedTime %r" % raw)
y = int(m.group(1))
rest = [int(g) for g in m.groups()[1:]]
else:
raise CertError("validity time has tag 0x%02x" % el[0])
return _dt.datetime(y, *rest, tzinfo=_dt.timezone.utc)
class Cert:
"""The fields of one X.509 certificate the gate reads."""
def __init__(self, der):
self.der = bytes(der)
buf = self.der
top = _tlv(buf, 0)
if top[0] != 0x30 or top[3] != len(buf):
raise CertError("not a single DER certificate")
parts = _children(buf, top[1], top[2])
if len(parts) != 3:
raise CertError("a certificate is exactly tbsCertificate, algorithm, signature")
tbs, sig_alg, sig_val = parts
_expect(tbs, 0x30, "tbsCertificate")
# The signed bytes are the WHOLE tbsCertificate element, header included.
tbs_hdr_start = top[1]
self.tbs_bytes = buf[tbs_hdr_start:tbs[2]]
self.sig_alg = self._alg_oid(buf, sig_alg)
_expect(sig_val, 0x03, "signatureValue")
if sig_val[2] - sig_val[1] < 2 or buf[sig_val[1]] != 0:
raise CertError("signatureValue is empty or has unused bits")
self.signature = buf[sig_val[1] + 1:sig_val[2]]
f = _children(buf, tbs[1], tbs[2])
i = 0
if f and f[0][0] == 0xa0: # [0] EXPLICIT version
i = 1
# serial, signature alg, issuer, validity, subject, spki, then optional
# [1]/[2] unique ids and [3] extensions.
if len(f) < i + 6:
raise CertError("tbsCertificate is missing fields")
self.tbs_sig_alg = self._alg_oid(buf, f[i + 1])
issuer, validity, subject, spki = f[i + 2], f[i + 3], f[i + 4], f[i + 5]
_expect(issuer, 0x30, "issuer")
_expect(subject, 0x30, "subject")
self.issuer_der = buf[issuer[1]:issuer[2]]
self.subject_der = buf[subject[1]:subject[2]]
self.subject = _name_parts(buf, subject[1], subject[2])
v = _children(buf, validity[1], validity[2])
if len(v) != 2:
raise CertError("malformed validity")
self.not_before, self.not_after = _time(buf, v[0]), _time(buf, v[1])
self.public_key = self._ec_point(buf, spki)
self.is_ca = False
self.key_usage = None # None = no keyUsage extension
seen_ext = set()
for ext_wrap in f[i + 6:]:
if ext_wrap[0] != 0xa3:
continue
exts = _children(buf, ext_wrap[1], ext_wrap[2])
if len(exts) != 1:
raise CertError("malformed extensions")
for ext in _children(buf, exts[0][1], exts[0][2]):
k = _children(buf, ext[1], ext[2])
if len(k) not in (2, 3) or k[0][0] != 0x06 or k[-1][0] != 0x04:
raise CertError("malformed extension")
oid = bytes(buf[k[0][1]:k[0][2]])
if oid in seen_ext:
raise CertError("duplicate extension")
seen_ext.add(oid)
critical = len(k) == 3 and k[1][0] == 0x01 and buf[k[1][1]:k[1][2]] != b"\x00"
octets = k[-1]
if oid == _OID_BASIC_CONSTRAINTS:
bc = _tlv(buf, octets[1])
inner = _children(buf, bc[1], bc[2])
self.is_ca = bool(inner and inner[0][0] == 0x01
and buf[inner[0][1]:inner[0][2]] != b"\x00")
elif oid == _OID_KEY_USAGE:
ku = _tlv(buf, octets[1])
bits_ = bytes(buf[ku[1]:ku[2]])
self.key_usage = int.from_bytes(bits_[1:3].ljust(2, b"\x00"), "big") if bits_ else 0
elif critical and oid not in _UNDERSTOOD_EXTENSIONS:
# openssl refuses a certificate carrying a critical
# extension it does not understand; so does the gate.
raise CertError("certificate has an unknown critical extension")
@staticmethod
def _alg_oid(buf, el):
_expect(el, 0x30, "algorithm")
k = _children(buf, el[1], el[2])
if not k or k[0][0] != 0x06:
raise CertError("malformed algorithm identifier")
return bytes(buf[k[0][1]:k[0][2]])
@staticmethod
def _ec_point(buf, spki):
_expect(spki, 0x30, "subjectPublicKeyInfo")
alg, bits = (_children(buf, spki[1], spki[2]) + [None, None])[:2]
if bits is None:
raise CertError("malformed subjectPublicKeyInfo")
a = _children(buf, alg[1], alg[2])
if (len(a) != 2 or buf[a[0][1]:a[0][2]] != _OID_EC_PUBKEY
or buf[a[1][1]:a[1][2]] != _OID_P256):
raise CertError("signing key is not an EC P-256 key — the gate verifies "
"ecdsa-p256-sha256 only")
_expect(bits, 0x03, "public key")
if bits[2] - bits[1] < 1 or buf[bits[1]] != 0:
raise CertError("public key BITSTRING is empty or has unused bits")
pt = buf[bits[1] + 1:bits[2]]
if len(pt) != 65 or pt[0] != 0x04:
raise CertError("EC public key is not an uncompressed P-256 point")
q = (int.from_bytes(pt[1:33], "big"), int.from_bytes(pt[33:], "big"))
if not _on_curve(q):
raise CertError("EC public key is not on P-256")
return q
def cn(self):
cns = [v for oid, v in self.subject if oid == _OID_CN]
if len(cns) != 1:
# exactly one CN: a second one is how a cert could carry the
# owner's name next to its real one
raise CertError("signing cert must carry exactly one CN, has %d" % len(cns))
return cns[0]
def valid_at(self, when):
return self.not_before <= when <= self.not_after
def load_pem_certs(text):
"""Every CERTIFICATE block in a PEM text, as Cert objects (in order)."""
if isinstance(text, bytes):
text = text.decode("ascii", "replace")
blocks = re.findall(r"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", text, re.S)
if not blocks:
raise CertError("no PEM certificate found")
out = []
for b in blocks:
try:
out.append(Cert(base64.b64decode("".join(b.split()), validate=True)))
except CertError:
raise
except (ValueError, TypeError, IndexError, OverflowError) as exc:
raise CertError("unreadable PEM certificate: %s" % exc)
return out
def load_root_file(text):
"""The trust anchors in a CA file: every certificate the gate can use.
One the gate cannot use (another key type, say) is skipped, not fatal —
openssl would skip it too; an empty result is refused."""
if isinstance(text, bytes):
text = text.decode("ascii", "replace")
blocks = re.findall(r"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", text, re.S)
roots = []
for b in blocks:
try:
roots.append(Cert(base64.b64decode("".join(b.split()), validate=True)))
except (CertError, ValueError, TypeError, IndexError, OverflowError):
continue
if not roots:
raise CertError("FREEAGENT_CA_ROOT holds no usable P-256 certificate")
return roots
def load_cert_file(path):
with open(path, "rb") as fh:
certs = load_pem_certs(fh.read())
return certs[0]
# ── P-256 arithmetic (affine; verification only) ────────────────────────────
def _on_curve(pt):
x, y = pt
if not (0 <= x < _P and 0 <= y < _P):
return False
return (y * y - (x * x * x + _A * x + _B)) % _P == 0
def _add(p1, p2):
if p1 is None:
return p2
if p2 is None:
return p1
x1, y1 = p1
x2, y2 = p2
if x1 == x2:
if (y1 + y2) % _P == 0:
return None
lam = (3 * x1 * x1 + _A) * pow(2 * y1, -1, _P) % _P
else:
lam = (y2 - y1) * pow(x2 - x1, -1, _P) % _P
x3 = (lam * lam - x1 - x2) % _P
return (x3, (lam * (x1 - x3) - y1) % _P)
def _mul(k, pt):
acc = None
while k:
if k & 1:
acc = _add(acc, pt)
pt = _add(pt, pt)
k >>= 1
return acc
def _der_sig(sig):
top = _tlv(sig, 0)
if top[0] != 0x30 or top[3] != len(sig):
raise CertError("signature is not a DER ECDSA signature")
k = _children(sig, top[1], top[2])
if len(k) != 2 or k[0][0] != 0x02 or k[1][0] != 0x02:
raise CertError("signature is not a DER ECDSA signature")
vals = []
for _, cs, ce in k:
raw = sig[cs:ce]
# DER INTEGER: non-empty, positive here, and minimal — no leading
# zero byte unless the next byte's top bit needs it. openssl refuses
# a padded signature; so does the gate (one encoding per signature).
if not raw or raw[0] & 0x80 or (len(raw) > 1 and raw[0] == 0 and not raw[1] & 0x80):
raise CertError("signature integers are not minimal DER")
vals.append(int.from_bytes(raw, "big"))
return vals[0], vals[1]
def ecdsa_p256_sha256_verify(pub, message, der_signature):
"""True iff `der_signature` is a valid ECDSA P-256 / SHA-256 signature by
`pub` over `message`. Malformed input is simply False."""
try:
r, s = _der_sig(bytes(der_signature))
except CertError:
return False
if not (1 <= r < _N and 1 <= s < _N) or not _on_curve(pub):
return False
e = int.from_bytes(hashlib.sha256(message).digest(), "big")
w = pow(s, -1, _N)
pt = _add(_mul(e * w % _N, _G), _mul(r * w % _N, pub))
return pt is not None and pt[0] % _N == r
# ── the chain ───────────────────────────────────────────────────────────────
def _signed_by(child, issuer):
if child.sig_alg != _OID_ECDSA_SHA256 or child.tbs_sig_alg != _OID_ECDSA_SHA256:
raise CertError("certificate is not signed with ecdsa-with-SHA256")
return ecdsa_p256_sha256_verify(issuer.public_key, child.tbs_bytes, child.signature)
def verify_chain(leaf, roots, now=None):
"""Raise CertError unless `leaf` is one of `roots` or is signed directly by
one of them. For the managed install — whose cert IS its root — this is
the answer `openssl verify -CAfile root leaf` gave. For a separate issuer
it enforces validity, CA basicConstraints and keyUsage keyCertSign, but it
treats every cert in the file as a trust anchor and does not build longer
chains or check pathLen / AKID: a one-level PKI only."""
now = now or _dt.datetime.now(_dt.timezone.utc)
if not leaf.valid_at(now):
raise CertError("signing cert is outside its validity window (%s — %s)"
% (leaf.not_before.isoformat(), leaf.not_after.isoformat()))
for root in roots:
if root.der == leaf.der:
# The managed install's cert is its own root. A trust anchor is
# trusted as given; its self-signature is still required to hold.
if not _signed_by(leaf, leaf):
raise CertError("self-signed root does not verify")
return root
why = "signing cert does not chain to FREEAGENT_CA_ROOT"
for root in roots:
if root.subject_der != leaf.issuer_der:
continue
# Every same-named root is tried: a name is not an identity.
if not root.valid_at(now):
why = "the CA root is outside its validity window"
continue
if not root.is_ca:
why = "the issuing certificate is not a CA"
continue
if root.key_usage is not None and not root.key_usage & _KU_KEY_CERT_SIGN:
why = "the issuing certificate may not sign certificates (keyUsage)"
continue
try:
if _signed_by(leaf, root):
return root
except CertError as exc:
why = str(exc)
continue
why = "signing cert's signature does not verify against the CA root"
raise CertError(why)