diff --git a/README.md b/README.md index e800c43..0847f9a 100644 --- a/README.md +++ b/README.md @@ -256,7 +256,7 @@ python3 tools/uci/test_http_local.py # HTTP GET against local listener python3 tools/uci/test_https_local.py # HTTPS GET (TLS 1.3 + ECDSA-P256) ``` -`test_https_local.py` is the end-to-end HTTPS demo (UCI backend only): it boots the U64E at 48 MHz turbo, connects to a local Python TLS listener using the test cert under `tools/https_e2e/certs/`, and confirms a full TLS 1.3 handshake + HTTP GET. With `DEBUG_CAPTURE=1`, each run writes a timestamped artifact directory under `$UCI_DEBUG_DIR` (default `/tmp/uci_https_debug/`) with raw 6510 bus trace, TLS state snapshot, and listener result. +`test_https_local.py` is the end-to-end HTTPS demo (UCI backend only): it boots the U64E at 48 MHz turbo, connects to a local Python TLS listener using the test cert under `tools/https_e2e/certs/`, and confirms a full TLS 1.3 handshake + HTTP GET. That cert is gitignored throwaway material — the directory is empty in a fresh clone and the pair is generated on first use, with no dependency beyond the standard library (`python3 tools/https_e2e/ensure_certs.py` mints it by hand). With `DEBUG_CAPTURE=1`, each run writes a timestamped artifact directory under `$UCI_DEBUG_DIR` (default `/tmp/uci_https_debug/`) with raw 6510 bus trace, TLS state snapshot, and listener result. Environment variables honored by `test_https_local.py`: diff --git a/tools/https_e2e/certs/README b/tools/https_e2e/certs/README index 7745e7b..c7a0dd8 100644 --- a/tools/https_e2e/certs/README +++ b/tools/https_e2e/certs/README @@ -6,6 +6,11 @@ These are self-signed certs used by the local TLS 1.3 listener against `www.foo.bar`. They are NOT trust-anchors for anything; do not deploy them anywhere real. +**This directory is empty in a fresh clone.** The cert files are +gitignored (see the repo `.gitignore`) because they are throwaway +material that should be minted locally, not shipped. Nothing is wrong +if `ls` shows only this README. + Two cert profiles are supported: server.pem / server.key -- P-256 (secp256r1 / prime256v1), @@ -16,23 +21,48 @@ Two cert profiles are supported: CN is `www.foo.bar` and SAN covers `foo.bar` + `www.foo.bar` for both. Validity is 10 years from generation. -The cert files themselves are gitignored (see the repo `.gitignore`); -they are generated on demand by the listener. - Auto-generation (default path) ------------------------------ -Both pairs are auto-generated lazily by `https_listener._ensure_certs_*()` -the first time the listener is started under each profile, using the -Python `cryptography` package. To force regeneration, delete the -files and start the listener once with the matching `cert_profile`. +Each pair is generated the first time something needs it, and reused +thereafter. Every in-tree consumer goes through +`tools/https_e2e/ensure_certs.py`, which delegates to +`tools/package/listener/gen_certs.py` so the in-tree tests and the +packaged listener mint identical material: + + - `https_listener.py` (via `_ensure_certs(cert_profile)`), and so + everything built on it -- `tests/test_vice_https_macos.py`, + `evil_listener.py`, `tools/uci/test_https_bad_finished.py` + - `tools/uci/test_https_local.py`, which inlines its own listener + (this one used to fail with `ERROR: cert/key not found` instead -- + issue #93) + - `tools/uci/test_https_local_p384.py`, same, P-384 profile + +Generation needs nothing beyond the Python standard library: PR #96 +reimplemented `gen_certs.py` in pure Python (curve arithmetic, a +minimal DER encoder, ECDSA) so the packaged listener has no +third-party dependency, and the P-384 profile rides on the same code. +No pip, no venv. + +Under `EXTERNAL_LISTENER=1` no repo cert is loaded or generated at +all, by design: the server is out of band. + +Minting a pair by hand +---------------------- + + python3 tools/https_e2e/ensure_certs.py # P-256 + python3 tools/https_e2e/ensure_certs.py --profile p384 # P-384 + python3 tools/https_e2e/ensure_certs.py --force # regenerate + +To regenerate, either pass `--force` or delete the files and let the +next run recreate them. Manual regeneration with openssl -------------------------------- -If you need to (re)create the P-384 pair without invoking the -listener (e.g. for debugging with `openssl s_client` directly), the -following openssl invocation produces the same cert: +If you need to (re)create a pair without Python (e.g. for debugging +with `openssl s_client` directly), this produces an equivalent P-384 +cert: cat > /tmp/p384_san.cnf <<'EOF' [req] @@ -56,8 +86,8 @@ following openssl invocation produces the same cert: -days 3650 -sha384 -config /tmp/p384_san.cnf The P-256 pair can be (re)created the same way with -`-name prime256v1` and `-sha256` — but the listener's auto-generator -is the canonical source. +`-name prime256v1` and `-sha256` -- but `ensure_certs.py` is the +canonical source. Selecting which cert the listener presents ------------------------------------------ diff --git a/tools/https_e2e/ensure_certs.py b/tools/https_e2e/ensure_certs.py new file mode 100644 index 0000000..81df7ce --- /dev/null +++ b/tools/https_e2e/ensure_certs.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Ensure the local test certs exist, generating them on demand. + +The certs under ``tools/https_e2e/certs/`` are gitignored — they are +throwaway self-signed test material, not something to ship in a repo. That +means a fresh clone has none, and every script that starts a local TLS +listener needs them. + +``https_listener.py`` has always generated its own pair, but +``tools/uci/test_https_local.py`` inlines its own listener and so never +crossed that path: it just printed ``ERROR: cert/key not found`` and exited +2, leaving the reader to discover that a generator existed somewhere else +entirely (issue #93). This module is the single entry point both now use. + +Generation is delegated to ``tools/package/listener/gen_certs.py`` rather +than duplicated, so the packaged listener and the in-tree tests produce +identical material: self-signed ECDSA, CN ``www.foo.bar``, SAN covering +``foo.bar`` and ``www.foo.bar``, 10 year validity. + +That generator is **pure Python stdlib** (PR #96): no ``cryptography``, no +pip, no venv on either path. P-384 was the one profile that still needed +the package, and #96's implementation extends to it — same algorithm, +different curve — so the in-tree tests now have no third-party dependency +for certs either. + +Two profiles, matching the listener's ``cert_profile`` selector: + + p256 (default) -> certs/server.pem + certs/server.key + p384 -> certs/server-p384.pem + certs/server-p384.key + +Usable directly, too: + + python3 tools/https_e2e/ensure_certs.py [--profile p256|p384] [--force] +""" +from __future__ import annotations + +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +CERTS_DIR = REPO_ROOT / "tools" / "https_e2e" / "certs" +GEN_DIR = REPO_ROOT / "tools" / "package" / "listener" + +CN = "www.foo.bar" +SANS = ["foo.bar", "www.foo.bar"] +PROFILES = ("p256", "p384") + +# Filenames per profile — must stay in step with gen_certs.CURVE_PROFILES. +_FILENAMES = { + "p256": ("server.pem", "server.key"), + "p384": ("server-p384.pem", "server-p384.key"), +} + + +def cert_paths(profile: str = "p256", + certs_dir: Path | None = None) -> tuple[Path, Path]: + """Return (cert_path, key_path) for *profile*. Does not generate.""" + if profile not in _FILENAMES: + raise ValueError(f"unknown cert profile {profile!r}; " + f"expected one of {PROFILES}") + base = Path(certs_dir) if certs_dir is not None else CERTS_DIR + cert_name, key_name = _FILENAMES[profile] + return base / cert_name, base / key_name + + +def ensure_certs(profile: str = "p256", + certs_dir: Path | None = None, + force: bool = False, + quiet: bool = False) -> tuple[Path, Path]: + """Return (cert_path, key_path), generating them if absent. + + Idempotent: an existing pair is returned untouched unless *force*. + Raises SystemExit with a one-line actionable message if generation is + impossible — never a bare traceback, since the usual cause is a missing + dependency rather than a bug. + """ + cert_path, key_path = cert_paths(profile, certs_dir) + + if cert_path.is_file() and key_path.is_file() and not force: + return cert_path, key_path + + if not quiet: + pretty = f"P-{profile[1:]}" # p256 -> P-256 + print(f"test certs not found in {cert_path.parent} — generating " + f"(self-signed {pretty}, CN={CN}); " + f"they are gitignored by design") + + if str(GEN_DIR) not in sys.path: + sys.path.insert(0, str(GEN_DIR)) + try: + from gen_certs import generate # noqa: PLC0415 + except ImportError as exc: + # The generator is stdlib-only, so this is a missing/broken file + # rather than a missing package. Say which file, in one line. + raise SystemExit( + f"cannot generate test certs: {exc} " + f"(expected the stdlib-only generator at " + f"{GEN_DIR / 'gen_certs.py'})") from exc + + try: + return generate(CN, SANS, cert_path.parent, force=force, + curve=profile) + except SystemExit: + raise + except Exception as exc: # noqa: BLE001 - surface as one readable line + raise SystemExit( + f"test cert generation failed: {type(exc).__name__}: {exc}") from exc + + +# Backwards-compatible alias for the P-256-only spelling. +def ensure_p256_certs(certs_dir: Path | None = None, force: bool = False, + quiet: bool = False) -> tuple[Path, Path]: + return ensure_certs("p256", certs_dir, force=force, quiet=quiet) + + +def main(argv: list[str] | None = None) -> int: + import argparse # noqa: PLC0415 - keep import cost off the library path + + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--profile", default="p256", choices=list(PROFILES), + help="cert profile to ensure (default: p256)") + p.add_argument("--force", action="store_true", + help="regenerate even if the pair already exists") + args = p.parse_args(argv) + + cert, key = ensure_certs(args.profile, force=args.force) + print(f"cert: {cert}\nkey: {key}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/https_e2e/https_listener.py b/tools/https_e2e/https_listener.py index 20289d1..cf42bea 100644 --- a/tools/https_e2e/https_listener.py +++ b/tools/https_e2e/https_listener.py @@ -7,10 +7,11 @@ The TLS layer uses a self-signed ECDSA certificate. Two cert profiles are available: - "p256" (default) -- P-256 / ecdsa-with-SHA256, generated lazily by - this module the first time the listener starts. - "p384" -- P-384 / ecdsa-with-SHA384, generated out-of-band - with openssl (see tools/https_e2e/certs/README). + "p256" (default) -- P-256 / ecdsa-with-SHA256 + "p384" -- P-384 / ecdsa-with-SHA384 + +Both are generated lazily on first use (see ensure_certs.py) into the +gitignored tools/https_e2e/certs/ directory, and reused thereafter. TLS 1.3 is required; older versions are rejected. @@ -42,11 +43,9 @@ # Certificate generation # --------------------------------------------------------------------------- -_CERTS_DIR = os.path.join(os.path.dirname(__file__), "certs") -_CERT_PATH = os.path.join(_CERTS_DIR, "server.pem") -_KEY_PATH = os.path.join(_CERTS_DIR, "server.key") -_CERT_PATH_P384 = os.path.join(_CERTS_DIR, "server-p384.pem") -_KEY_PATH_P384 = os.path.join(_CERTS_DIR, "server-p384.key") +# Cert/key filenames per profile live in ensure_certs.py — the module that +# also generates them — so the names are stated once. +_HERE = os.path.dirname(os.path.abspath(__file__)) _CERT_PROFILE_ENV = "HTTPS_LISTENER_CERT_PROFILE" _DEFAULT_CERT_PROFILE = "p256" @@ -68,123 +67,35 @@ def _resolve_cert_profile(cert_profile: str | None) -> str: return cert_profile -def _ensure_certs_p256() -> tuple[str, str]: - """Return (cert_path, key_path) for the P-256 profile. +def _ensure_certs(cert_profile: str) -> tuple[str, str]: + """Return (cert_path, key_path) for *cert_profile*, generating if absent. - Generates the cert pair on first use; subsequent calls reuse the - cached files in the certs/ directory. + Both profiles are generated by the shared helper in ensure_certs.py, + which delegates to tools/package/listener/gen_certs.py — so the in-tree + tests and the packaged listener mint identical material, and there is + one place to fix if the profile ever changes. This module used to + inline two near-identical copies of the cert builder. """ - if os.path.isfile(_CERT_PATH) and os.path.isfile(_KEY_PATH): - return _CERT_PATH, _KEY_PATH - - from cryptography import x509 - from cryptography.hazmat.primitives import hashes, serialization - from cryptography.hazmat.primitives.asymmetric import ec - from cryptography.x509.oid import NameOID - import datetime - - key = ec.generate_private_key(ec.SECP256R1()) - - subject = issuer = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, "www.foo.bar"), - ]) - - cert = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(issuer) - .public_key(key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.datetime.utcnow()) - .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=3650)) - .add_extension( - x509.SubjectAlternativeName([ - x509.DNSName("foo.bar"), - x509.DNSName("www.foo.bar"), - ]), - critical=False, - ) - .sign(key, hashes.SHA256()) - ) + if cert_profile not in _CERT_PROFILES: + # Should be unreachable thanks to _resolve_cert_profile(). + raise ValueError(f"unknown cert_profile {cert_profile!r}") - os.makedirs(_CERTS_DIR, exist_ok=True) + if _HERE not in sys.path: + sys.path.insert(0, _HERE) + from ensure_certs import ensure_certs # noqa: PLC0415 - with open(_KEY_PATH, "wb") as f: - f.write(key.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.TraditionalOpenSSL, - serialization.NoEncryption(), - )) + cert_path, key_path = ensure_certs(cert_profile) + return str(cert_path), str(key_path) - with open(_CERT_PATH, "wb") as f: - f.write(cert.public_bytes(serialization.Encoding.PEM)) - return _CERT_PATH, _KEY_PATH +def _ensure_certs_p256() -> tuple[str, str]: + """Return (cert_path, key_path) for the P-256 profile, generating if absent.""" + return _ensure_certs("p256") def _ensure_certs_p384() -> tuple[str, str]: - """Return (cert_path, key_path) for the P-384 profile. - - Generates the cert pair on first use; subsequent calls reuse the - cached files in the certs/ directory. Mirrors _ensure_certs_p256() - but uses SECP384R1 + SHA-384. - """ - if os.path.isfile(_CERT_PATH_P384) and os.path.isfile(_KEY_PATH_P384): - return _CERT_PATH_P384, _KEY_PATH_P384 - - from cryptography import x509 - from cryptography.hazmat.primitives import hashes, serialization - from cryptography.hazmat.primitives.asymmetric import ec - from cryptography.x509.oid import NameOID - import datetime - - key = ec.generate_private_key(ec.SECP384R1()) - - subject = issuer = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, "www.foo.bar"), - ]) - - cert = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(issuer) - .public_key(key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.datetime.utcnow()) - .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=3650)) - .add_extension( - x509.SubjectAlternativeName([ - x509.DNSName("foo.bar"), - x509.DNSName("www.foo.bar"), - ]), - critical=False, - ) - .sign(key, hashes.SHA384()) - ) - - os.makedirs(_CERTS_DIR, exist_ok=True) - - with open(_KEY_PATH_P384, "wb") as f: - f.write(key.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.TraditionalOpenSSL, - serialization.NoEncryption(), - )) - - with open(_CERT_PATH_P384, "wb") as f: - f.write(cert.public_bytes(serialization.Encoding.PEM)) - - return _CERT_PATH_P384, _KEY_PATH_P384 - - -def _ensure_certs(cert_profile: str) -> tuple[str, str]: - """Dispatch to the per-profile cert loader.""" - if cert_profile == "p256": - return _ensure_certs_p256() - if cert_profile == "p384": - return _ensure_certs_p384() - # Should be unreachable thanks to _resolve_cert_profile(). - raise ValueError(f"unknown cert_profile {cert_profile!r}") + """Return (cert_path, key_path) for the P-384 profile, generating if absent.""" + return _ensure_certs("p384") # --------------------------------------------------------------------------- diff --git a/tools/package/listener/gen_certs.py b/tools/package/listener/gen_certs.py index 7888c92..198ad0c 100755 --- a/tools/package/listener/gen_certs.py +++ b/tools/package/listener/gen_certs.py @@ -21,6 +21,7 @@ * key : ECDSA on NIST P-256 (secp256r1 / prime256v1) * sig : ecdsa-with-SHA256 * CN : www.foo.bar (overridable via --cn) + * files : server.pem / server.key * SAN : foo.bar, www.foo.bar (overridable via --san, repeatable) * valid : now-5min .. now+3650 days, UTCTime * exts : subjectAltName ONLY, non-critical @@ -32,6 +33,13 @@ hardware e2e — this cert is a test fixture, never a trust anchor, and must not be deployed anywhere real. +`--curve p384` mints the same shape on secp384r1 with ecdsa-with-SHA384, +into server-p384.{pem,key}. The packaged listener never asks for it; it +exists so the in-tree P-384 e2e tests (tools/https_e2e/ensure_certs.py) +have a generator, which previously meant `cryptography` and now means +none. P-384 is the same algorithm over different numbers — the arithmetic +above is parameterised by curve rather than duplicated. + Idempotent: refuses to overwrite existing files unless --force. Writes into ./certs/ relative to the current directory unless --out-dir is given. """ @@ -45,7 +53,14 @@ from pathlib import Path # --------------------------------------------------------------------------- -# NIST P-256 (secp256r1) domain parameters — SEC 2 / FIPS 186-4. +# Domain parameters — SEC 2 / FIPS 186-4. +# +# P-256 is what the packaged listener ships and all the constants below with +# bare names are its. P-384 exists for the in-tree e2e tests +# (tools/https_e2e/certs/server-p384.*), which used to need `cryptography` +# purely to mint a cert on a different curve — the same algorithm over +# different numbers. Carrying it here keeps the dependency at zero on both +# paths and leaves one generator to maintain rather than two. # --------------------------------------------------------------------------- P = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF A = P - 3 @@ -54,15 +69,28 @@ GX = 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296 GY = 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5 -# Affine point arithmetic. None is the point at infinity. This runs a handful -# of times per invocation (one keygen, one signature), so clarity beats speed. +P384_P = int("fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe" + "ffffffff0000000000000000ffffffff", 16) +P384_A = P384_P - 3 +P384_B = int("b3312fa7e23ee7e4988e056be3f82d19181d9c6efe8141120314088f5013875a" + "c656398d8a2ed19d2a85c8edd3ec2aef", 16) +P384_N = int("ffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf" + "581a0db248b0a77aecec196accc52973", 16) +P384_GX = int("aa87ca22be8b05378eb1c71ef320ad746e1d3b628ba79b9859f741e082542a38" + "5502f25dbf55296c3a545e3872760ab7", 16) +P384_GY = int("3617de4a96262c6f5d9e98bf9292dc29f8f41dbd289a147ce9da3113b5f0b8c0" + "0a60b1ce1d7e819d7a431d7c90ea0e5f", 16) + +# Affine point arithmetic, parameterised by curve. None is the point at +# infinity. This runs a handful of times per invocation (one keygen, one +# signature), so clarity beats speed. def _inv(x: int, m: int) -> int: return pow(x, m - 2, m) -def _add(p1, p2): +def _add(p1, p2, p: int = P, a: int = A): if p1 is None: return p2 if p2 is None: @@ -70,24 +98,24 @@ def _add(p1, p2): x1, y1 = p1 x2, y2 = p2 if x1 == x2: - if (y1 + y2) % P == 0: + if (y1 + y2) % p == 0: return None - lam = (3 * x1 * x1 + A) * _inv(2 * y1, P) % P + lam = (3 * x1 * x1 + a) * _inv(2 * y1, p) % p else: - lam = (y2 - y1) * _inv(x2 - x1, P) % P - x3 = (lam * lam - x1 - x2) % P - return (x3, (lam * (x1 - x3) - y1) % P) + lam = (y2 - y1) * _inv(x2 - x1, p) % p + x3 = (lam * lam - x1 - x2) % p + return (x3, (lam * (x1 - x3) - y1) % p) -def _mul(k: int, point): +def _mul(k: int, point, p: int = P, a: int = A): """Double-and-add. Not constant time — this is a test fixture minting a throwaway key on the operator's own machine, not a production signer.""" result = None addend = point while k: if k & 1: - result = _add(result, addend) - addend = _add(addend, addend) + result = _add(result, addend, p, a) + addend = _add(addend, addend, p, a) k >>= 1 return result @@ -165,11 +193,42 @@ def _explicit(num: int, body: bytes) -> bytes: OID_EC_PUBLIC_KEY = "1.2.840.10045.2.1" OID_PRIME256V1 = "1.2.840.10045.3.1.7" +OID_SECP384R1 = "1.3.132.0.34" OID_ECDSA_SHA256 = "1.2.840.10045.4.3.2" +OID_ECDSA_SHA384 = "1.2.840.10045.4.3.3" OID_COMMON_NAME = "2.5.4.3" OID_SUBJECT_ALT_NAME = "2.5.29.17" +class _Curve: + """Everything that differs between the two supported profiles.""" + + def __init__(self, name, p, a, n, gx, gy, size, curve_oid, sig_oid, + hasher, cert_name, key_name, label): + self.name = name # profile key: "p256" / "p384" + self.p, self.a, self.n = p, a, n + self.gx, self.gy = gx, gy + self.size = size # coordinate width in bytes + self.curve_oid = curve_oid + self.sig_oid = sig_oid + self.hasher = hasher # hashlib constructor + self.cert_name = cert_name + self.key_name = key_name + self.label = label # for the human-readable summary + + +CURVES = { + "p256": _Curve("p256", P, A, N, GX, GY, 32, + OID_PRIME256V1, OID_ECDSA_SHA256, hashlib.sha256, + "server.pem", "server.key", + "ECDSA P-256 (secp256r1), sig = ecdsa-with-SHA256"), + "p384": _Curve("p384", P384_P, P384_A, P384_N, P384_GX, P384_GY, 48, + OID_SECP384R1, OID_ECDSA_SHA384, hashlib.sha384, + "server-p384.pem", "server-p384.key", + "ECDSA P-384 (secp384r1), sig = ecdsa-with-SHA384"), +} + + def _pem(label: str, der: bytes) -> bytes: import base64 b64 = base64.b64encode(der).decode("ascii") @@ -178,28 +237,45 @@ def _pem(label: str, der: bytes) -> bytes: % (label, "\n".join(lines), label)).encode("ascii") -def _ecdsa_sign(digest: bytes, d: int) -> bytes: - """ECDSA-SHA256 over P-256. Returns the DER SEQUENCE{r,s}.""" - e = int.from_bytes(digest, "big") # SHA-256 and n are both 256 bits +def _ecdsa_sign(digest: bytes, d: int, curve) -> bytes: + """ECDSA over *curve*. Returns the DER SEQUENCE{r,s}. + + Each profile pairs its curve with the equal-width hash (P-256/SHA-256, + P-384/SHA-384), so the digest is exactly as wide as n and FIPS 186-4's + leftmost-bits truncation is a no-op. Pair them differently and this + needs the truncation put back. + """ + n = curve.n + e = int.from_bytes(digest, "big") while True: - k = secrets.randbelow(N - 1) + 1 - point = _mul(k, (GX, GY)) - r = point[0] % N + k = secrets.randbelow(n - 1) + 1 + point = _mul(k, (curve.gx, curve.gy), curve.p, curve.a) + r = point[0] % n if r == 0: continue - s = _inv(k, N) * (e + r * d) % N + s = _inv(k, n) * (e + r * d) % n if s == 0: continue return _seq(_int(r), _int(s)) def generate(cn: str, sans: list, out_dir: Path, - force: bool = False): - """Generate key + self-signed cert into out_dir. Returns (cert, key).""" + force: bool = False, curve: str = "p256"): + """Generate key + self-signed cert into out_dir. Returns (cert, key). + + *curve* selects a profile from CURVES; it also picks the filenames, so + a P-256 and a P-384 pair coexist in one directory. + """ + try: + crv = CURVES[curve] + except KeyError: + raise ValueError(f"unknown curve profile {curve!r}; " + f"expected one of {sorted(CURVES)}") from None + out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) - cert_path = out_dir / "server.pem" - key_path = out_dir / "server.key" + cert_path = out_dir / crv.cert_name + key_path = out_dir / crv.key_name if cert_path.exists() and key_path.exists() and not force: print(f"certs already present: {cert_path} / {key_path} " @@ -207,9 +283,10 @@ def generate(cn: str, sans: list, out_dir: Path, return cert_path, key_path # --- key --- - d = secrets.randbelow(N - 1) + 1 - qx, qy = _mul(d, (GX, GY)) - pub_point = b"\x04" + qx.to_bytes(32, "big") + qy.to_bytes(32, "big") + d = secrets.randbelow(crv.n - 1) + 1 + qx, qy = _mul(d, (crv.gx, crv.gy), crv.p, crv.a) + pub_point = (b"\x04" + qx.to_bytes(crv.size, "big") + + qy.to_bytes(crv.size, "big")) # --- names / validity --- name = _seq(_set(_seq(_oid(OID_COMMON_NAME), _utf8(cn)))) @@ -217,9 +294,9 @@ def generate(cn: str, sans: list, out_dir: Path, not_before = now - datetime.timedelta(minutes=5) not_after = now + datetime.timedelta(days=3650) - sig_alg = _seq(_oid(OID_ECDSA_SHA256)) + sig_alg = _seq(_oid(crv.sig_oid)) spki = _seq( - _seq(_oid(OID_EC_PUBLIC_KEY), _oid(OID_PRIME256V1)), + _seq(_oid(OID_EC_PUBLIC_KEY), _oid(crv.curve_oid)), _bitstring(pub_point), ) # GeneralNames: dNSName is [2] IMPLICIT IA5String, i.e. tag 0x82. @@ -239,15 +316,15 @@ def generate(cn: str, sans: list, out_dir: Path, extensions, ) - signature = _ecdsa_sign(hashlib.sha256(tbs).digest(), d) + signature = _ecdsa_sign(crv.hasher(tbs).digest(), d, crv) cert_der = _seq(tbs, sig_alg, _bitstring(signature)) # SEC1 / RFC 5915 ECPrivateKey — "EC PRIVATE KEY" PEM, which is what the # previous generator's TraditionalOpenSSL format produced. key_der = _seq( _int(1), - _octetstring(d.to_bytes(32, "big")), - _explicit(0, _oid(OID_PRIME256V1)), + _octetstring(d.to_bytes(crv.size, "big")), + _explicit(0, _oid(crv.curve_oid)), _explicit(1, _bitstring(pub_point)), ) @@ -262,7 +339,7 @@ def generate(cn: str, sans: list, out_dir: Path, print(f"wrote {key_path}") print(f" CN = {cn}") print(f" SAN = {', '.join(sans)}") - print(" key = ECDSA P-256 (secp256r1), sig = ecdsa-with-SHA256") + print(f" key = {crv.label}") print(" (generated with the Python stdlib only — no 'cryptography')") return cert_path, key_path @@ -281,12 +358,16 @@ def main(argv=None) -> int: "(default: ./certs)") p.add_argument("--force", action="store_true", help="overwrite existing cert/key") + p.add_argument("--curve", default="p256", choices=sorted(CURVES), + help="curve profile (default: p256, which is what the " + "listener serves). p384 writes " + "server-p384.{pem,key} so both pairs can coexist") args = p.parse_args(argv) sans = args.san if args.san else ["foo.bar", "www.foo.bar"] out_dir = Path(args.out_dir) if args.out_dir else Path.cwd() / "certs" - generate(args.cn, sans, out_dir, force=args.force) + generate(args.cn, sans, out_dir, force=args.force, curve=args.curve) return 0 diff --git a/tools/uci/bench_ecdsa_u64e.py b/tools/uci/bench_ecdsa_u64e.py index dee0e3d..64ddb53 100644 --- a/tools/uci/bench_ecdsa_u64e.py +++ b/tools/uci/bench_ecdsa_u64e.py @@ -358,9 +358,11 @@ def _keep_cycle(word: int) -> bool: def main() -> int: if not PRG_PATH.is_file(): print(f"ERROR: PRG not found at {PRG_PATH}", file=sys.stderr) + print("Run: make BACKEND=uci", file=sys.stderr) return 2 if not LABELS_PATH.is_file(): print(f"ERROR: labels.txt not found", file=sys.stderr) + print("Run: make BACKEND=uci", file=sys.stderr) return 2 if not VECTORS_PATH.is_file(): print(f"ERROR: vectors JSON not found at {VECTORS_PATH}." diff --git a/tools/uci/phase2_check.py b/tools/uci/phase2_check.py index fb66e0e..c21f49e 100644 --- a/tools/uci/phase2_check.py +++ b/tools/uci/phase2_check.py @@ -99,6 +99,7 @@ def main() -> int: return 2 if not LABELS_PATH.is_file(): print(f"ERROR: labels.txt not found at {LABELS_PATH}", file=sys.stderr) + print("Run: make BACKEND=uci", file=sys.stderr) return 2 try: diff --git a/tools/uci/phase3_tcp_echo.py b/tools/uci/phase3_tcp_echo.py index c1fd0f0..3eda53d 100644 --- a/tools/uci/phase3_tcp_echo.py +++ b/tools/uci/phase3_tcp_echo.py @@ -311,6 +311,7 @@ def main() -> int: return 2 if not LABELS_PATH.is_file(): print(f"ERROR: labels.txt not found at {LABELS_PATH}", file=sys.stderr) + print("Run: make BACKEND=uci", file=sys.stderr) return 2 labels = _load_labels() diff --git a/tools/uci/test_http_live.py b/tools/uci/test_http_live.py index 8086e01..ca8d764 100644 --- a/tools/uci/test_http_live.py +++ b/tools/uci/test_http_live.py @@ -201,9 +201,11 @@ def _decode_screen_ram(data: bytes) -> str: def main() -> int: if not PRG_PATH.is_file(): print(f"ERROR: PRG not found at {PRG_PATH}", file=sys.stderr) + print("Run: make BACKEND=uci", file=sys.stderr) return 2 if not LABELS_PATH.is_file(): print(f"ERROR: labels.txt not found", file=sys.stderr) + print("Run: make BACKEND=uci", file=sys.stderr) return 2 labels = _load_labels() diff --git a/tools/uci/test_http_local.py b/tools/uci/test_http_local.py index 4722a5f..043575a 100644 --- a/tools/uci/test_http_local.py +++ b/tools/uci/test_http_local.py @@ -292,9 +292,11 @@ def _decode_screen_ram(data: bytes) -> str: def main() -> int: if not PRG_PATH.is_file(): print(f"ERROR: PRG not found at {PRG_PATH}", file=sys.stderr) + print("Run: make BACKEND=uci", file=sys.stderr) return 2 if not LABELS_PATH.is_file(): print(f"ERROR: labels.txt not found", file=sys.stderr) + print("Run: make BACKEND=uci", file=sys.stderr) return 2 labels = _load_labels() diff --git a/tools/uci/test_https_local.py b/tools/uci/test_https_local.py index 16e6d94..80c68eb 100644 --- a/tools/uci/test_https_local.py +++ b/tools/uci/test_https_local.py @@ -136,6 +136,49 @@ def _keep_cycle(word: int) -> bool: CERT_PATH = REPO_ROOT / "tools" / "https_e2e" / "certs" / "server.pem" KEY_PATH = REPO_ROOT / "tools" / "https_e2e" / "certs" / "server.key" +# The cert/key are gitignored throwaway test material, so a fresh clone has +# none and this script used to die with "cert/key not found" (issue #93). +# They are generated on demand instead — see _ensure_certs_or_fail(), called +# from main() and skipped under EXTERNAL_LISTENER=1, which by contract loads +# no repo certs at all. CERT_PROFILE is what the P-384 sibling overrides. +CERT_PROFILE = "p256" + + +def _ensure_certs_or_fail() -> int: + """Generate the local test cert pair if absent. 0 on success, 2 on failure.""" + global CERT_PATH, KEY_PATH + + if CERT_PATH.is_file() and KEY_PATH.is_file(): + return 0 + + https_e2e_dir = str(REPO_ROOT / "tools" / "https_e2e") + if https_e2e_dir not in sys.path: + sys.path.insert(0, https_e2e_dir) + try: + from ensure_certs import cert_paths, ensure_certs # noqa: PLC0415 + except ImportError as exc: + print(f"ERROR: cert/key not found at {CERT_PATH} / {KEY_PATH}, and " + f"the generator could not be imported ({exc})", file=sys.stderr) + return 2 + + # Only generate into the canonical location. If someone has pointed + # CERT_PATH somewhere else, generating the default pair would not help, + # so say what is actually missing instead. + canonical = cert_paths(CERT_PROFILE) + if (CERT_PATH, KEY_PATH) != canonical: + print(f"ERROR: cert/key not found at {CERT_PATH} / {KEY_PATH}\n" + f" (these are not the generated {CERT_PROFILE} paths " + f"{canonical[0]} / {canonical[1]}, so they must be supplied)", + file=sys.stderr) + return 2 + + try: + CERT_PATH, KEY_PATH = ensure_certs(CERT_PROFILE) + except SystemExit as exc: # one-line actionable message, no traceback + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + return 0 + # NOTE: ROUTINE_ADDR and friends MUST sit in a region that does NOT # collide with the production CRYPTO_OVERLAY layout. Under # USE_X25519_SIBLING=1 the sibling X25519 rodata + bss buffers occupy @@ -1178,15 +1221,18 @@ def _process_debug_trace(cap_result, def main() -> int: if not PRG_PATH.is_file(): print(f"ERROR: PRG not found at {PRG_PATH}", file=sys.stderr) + print("Run: make BACKEND=uci", file=sys.stderr) return 2 if not LABELS_PATH.is_file(): print(f"ERROR: labels.txt not found", file=sys.stderr) + print("Run: make BACKEND=uci", file=sys.stderr) return 2 - if not EXTERNAL_LISTENER and (not CERT_PATH.is_file() - or not KEY_PATH.is_file()): - print(f"ERROR: cert/key not found at {CERT_PATH} / {KEY_PATH}", - file=sys.stderr) - return 2 + # EXTERNAL_LISTENER=1 serves TLS out of band and loads no repo cert, so + # do not generate one it will never use. + if not EXTERNAL_LISTENER: + rc = _ensure_certs_or_fail() + if rc: + return rc labels = _load_labels() required = [ diff --git a/tools/uci/test_https_local_p384.py b/tools/uci/test_https_local_p384.py index bcb8ff8..d208c25 100644 --- a/tools/uci/test_https_local_p384.py +++ b/tools/uci/test_https_local_p384.py @@ -86,6 +86,9 @@ _REPO_ROOT = Path(__file__).resolve().parents[2] test_https_local.CERT_PATH = _REPO_ROOT / "tools" / "https_e2e" / "certs" / "server-p384.pem" test_https_local.KEY_PATH = _REPO_ROOT / "tools" / "https_e2e" / "certs" / "server-p384.key" +# Both are gitignored and generated on demand; the profile tells the parent's +# _ensure_certs_or_fail() which pair to mint (issue #93). +test_https_local.CERT_PROFILE = "p384" # -------------------------------------------------------------------------- @@ -105,19 +108,9 @@ # adopted as the default in PR #... # -------------------------------------------------------------------------- -# Sanity check that the certs exist before delegating to main(). -if not test_https_local.CERT_PATH.is_file(): - print( - f"ERROR: P-384 cert not found at {test_https_local.CERT_PATH}", - file=sys.stderr, - ) - sys.exit(2) -if not test_https_local.KEY_PATH.is_file(): - print( - f"ERROR: P-384 key not found at {test_https_local.KEY_PATH}", - file=sys.stderr, - ) - sys.exit(2) +# No cert existence check here: the parent's main() generates the P-384 pair +# on demand from CERT_PROFILE above. To mint it by hand: +# python3 tools/https_e2e/ensure_certs.py --profile p384 def main() -> int: