diff --git a/CMakeLists.txt b/CMakeLists.txt index 64cf599..015e984 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -55,6 +55,7 @@ add_executable(mithril src/certid.cpp src/pubkey.cpp src/bigint.cpp + src/ecc.cpp src/keyweak.cpp src/keycorpus.cpp src/keys.cpp @@ -102,6 +103,7 @@ add_executable(mithril_unit src/certid.cpp src/pubkey.cpp src/bigint.cpp + src/ecc.cpp src/keyweak.cpp src/keycorpus.cpp src/keys.cpp diff --git a/README.md b/README.md index 092fdb2..bf24d57 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **IoT static scanner for secrets, SBOM, CVEs and more.** -mithril analyzes the *contents* of firmware and IoT software, whether a single file or an unpacked rootfs. It looks for embedded secrets and keys, a software bill of materials (SBOM), known CVEs affecting those components, open-source licenses, boot-security posture (U-Boot env, device tree, FIT/AVB/UEFI verified boot), and weak or leaked public keys (keys whose private half is public, ROCA, undersized RSA). It reports each finding with its evidence and a confidence, and it speaks clean JSON so scripts and LLM agents can drive it as easily as people can. +mithril analyzes the *contents* of firmware and IoT software, whether a single file or an unpacked rootfs. It looks for embedded secrets and keys, a software bill of materials (SBOM), known CVEs affecting those components, open-source licenses, boot-security posture (U-Boot env, device tree, FIT/AVB/UEFI verified boot), and weak or leaked public keys (keys whose private half is public, ROCA, undersized RSA, near-zero EC scalars). It reports each finding with its evidence and a confidence, and it speaks clean JSON so scripts and LLM agents can drive it as easily as people can. A scan makes no network calls. Secrets, SBOM, and license analysis are fully offline, and the CVE pass reads a local vulnerability mirror you refresh out of band. @@ -20,7 +20,7 @@ It has no build-time dependency on moria. Point it at any file or directory. - **Firmware-aware SBOM.** Beyond package databases (dpkg/opkg/apk/rpm), it recovers components from ELF version banners, versioned libc filenames, and the kernel banner, so it finds openssl/busybox/uClibc even in a stripped image with no package manager. Emitted as CycloneDX and SPDX. - **CVEs it owns end to end.** A local OSV + NVD/CPE mirror plus a curated, version- and kconfig-gated kernel-CVE checklist (high-signal, not exhaustive: an empty kernel result means none of the curated set is in range, not that the kernel is clean). Every match records how sure it is (exact version vs affected range vs CPE), stays release-precise where `/etc/os-release` allows, and is annotated with CISA KEV and EPSS. - **Honest by construction.** Secrets ride a deterministic offline ladder: `pattern` (shape), then `structural` (a parsed JWT or PEM key), then `validated` (a recomputed checksum, such as GitHub tokens and crypt hashes). mithril asserts well-formed, never "live," and an empty result is a real answer rather than a tool failure. -- **Weak and leaked keys, proven offline.** For every public key it recovers (certs, SSH host/authorized keys, embedded keys) it checks whether the private half is obtainable: a fingerprint match against a compiled-in corpus of hardcoded/default keys whose private half is public (rapid7 ssh-badkeys, the Vagrant insecure key), the ROCA fingerprint (CVE-2017-15361), undersized/broken RSA, and factoring recoveries that hand you a factor outright (Fermat close primes, batch-GCD shared primes across the image, Wiener small-`d`) via a small in-tree bignum. A hit means impersonation, login, or forgery, not just a weak cipher suite. +- **Weak and leaked keys, proven offline.** For every public key it recovers (certs, SSH host/authorized keys, embedded keys) it checks whether the private half is obtainable: a fingerprint match against a compiled-in corpus of hardcoded/default keys whose private half is public (rapid7 ssh-badkeys, the Vagrant insecure key, the compromised eMudhra intermediate CAs), the ROCA fingerprint (CVE-2017-15361), undersized/broken RSA, factoring recoveries that hand you a factor outright (Fermat close primes, batch-GCD shared primes across the image, Wiener small-`d`), and the EC analog: a near-zero EC private scalar recovered by a bounded `d*G` search (the October 2026 eMudhra P-384 compromise) — all via a small in-tree bignum. A hit means impersonation, login, or forgery, not just a weak cipher suite. - **Fully offline and reproducible.** No network at scan time; the CVE mirror is refreshed by an explicit, separate step. Air-gappable. - **Safe on hostile input.** Untrusted bytes go through one bounds-checked reader. Parsers degrade to a clean error rather than crash, and the CVE index is memory-mapped so a scan never parses the whole thing. @@ -50,7 +50,7 @@ mithril --licenses # licenses only mithril --license-paths # licenses, with each license's file locations listed mithril --boot # boot-security intel: U-Boot env, device tree, FIT/AVB, UEFI Secure Boot mithril --boot bios.bin # UEFI Secure Boot posture from a raw AMI/EDK2 BIOS (docs/boot-security.md) -mithril --keys # public-key weakness: leaked/default keys, ROCA, weak RSA, factoring +mithril --keys # public-key weakness: leaked/default keys, ROCA, weak RSA, factoring, EC small-scalar mithril --rules my.json # add user-defined rules (docs/user-rules.md) mithril --fetch-db # FIRST RUN: download the CVE mirror (a networked command) mithril --fetch-db --with-kernel-feed # also fetch the optional full kernel.org CVE feed diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index 83d7c12..52ac1b0 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -18,6 +18,11 @@ Public keys whose private half is public, fingerprinted into `src/keycorpus_corp - **HashiCorp Vagrant insecure key** — `data/keycorpus/vagrant/vagrant.pub`. The default "insecure" key Vagrant ships as an authorized key; its private half is published in the vagrant repo. License: MIT (HashiCorp). Source: (`keys/`). +- **eMudhra compromised intermediate CAs (October 2026)** — `data/keycorpus/emudhra/*.crt`. The eight + eMudhra P-384 intermediate CA certificates whose private keys were generated with a broken RNG (tiny + scalars 4–237) and are trivially recoverable. These are the real, public, Certificate-Transparency-logged + certificates; their SHA-256 fingerprints were verified against the Mozilla incident bug before vendoring. + Public certificates only (no private keys). Source: Mozilla bug 2083449 / CT logs. ## Boot-key corpus — `data/bootkeys/` diff --git a/data/keycorpus/emudhra/EMDVTLSCAG3B.crt b/data/keycorpus/emudhra/EMDVTLSCAG3B.crt new file mode 100644 index 0000000..d200e0d Binary files /dev/null and b/data/keycorpus/emudhra/EMDVTLSCAG3B.crt differ diff --git a/data/keycorpus/emudhra/EMDVTLSCAG3C.crt b/data/keycorpus/emudhra/EMDVTLSCAG3C.crt new file mode 100644 index 0000000..8fe2520 Binary files /dev/null and b/data/keycorpus/emudhra/EMDVTLSCAG3C.crt differ diff --git a/data/keycorpus/emudhra/EMEVTLSCAG3B.crt b/data/keycorpus/emudhra/EMEVTLSCAG3B.crt new file mode 100644 index 0000000..77d8e3e Binary files /dev/null and b/data/keycorpus/emudhra/EMEVTLSCAG3B.crt differ diff --git a/data/keycorpus/emudhra/EMEVTLSCAG3C.crt b/data/keycorpus/emudhra/EMEVTLSCAG3C.crt new file mode 100644 index 0000000..09cff56 Binary files /dev/null and b/data/keycorpus/emudhra/EMEVTLSCAG3C.crt differ diff --git a/data/keycorpus/emudhra/EMOVCSCAG3B.crt b/data/keycorpus/emudhra/EMOVCSCAG3B.crt new file mode 100644 index 0000000..e5bd32b Binary files /dev/null and b/data/keycorpus/emudhra/EMOVCSCAG3B.crt differ diff --git a/data/keycorpus/emudhra/EMOVCSCAG3C.crt b/data/keycorpus/emudhra/EMOVCSCAG3C.crt new file mode 100644 index 0000000..73d39b2 Binary files /dev/null and b/data/keycorpus/emudhra/EMOVCSCAG3C.crt differ diff --git a/data/keycorpus/emudhra/EMOVTLSCAG3B.crt b/data/keycorpus/emudhra/EMOVTLSCAG3B.crt new file mode 100644 index 0000000..ded8f51 Binary files /dev/null and b/data/keycorpus/emudhra/EMOVTLSCAG3B.crt differ diff --git a/data/keycorpus/emudhra/EMOVTLSCAG3C.crt b/data/keycorpus/emudhra/EMOVTLSCAG3C.crt new file mode 100644 index 0000000..3e4312b Binary files /dev/null and b/data/keycorpus/emudhra/EMOVTLSCAG3C.crt differ diff --git a/docs/key-weakness.md b/docs/key-weakness.md index c590677..46092d5 100644 --- a/docs/key-weakness.md +++ b/docs/key-weakness.md @@ -12,9 +12,10 @@ Two failure classes, following the standard taxonomy (see the research survey th Detection is a fingerprint lookup against a curated corpus. - **Generation** — the private key is computable from the public key because of bad randomness or structured primes. ROCA (CVE-2017-15361), structural weaknesses (undersized modulus, broken exponent), - and the factoring recoveries that actually produce a factor: Fermat close primes, batch-GCD shared - primes, and Wiener small-`d`. The factoring recoveries use a small in-tree bignum (`bigint.{hpp,cpp}`), - the same zero-dep choice as SHA-256/CRC32/DEFLATE. + the factoring recoveries that actually produce a factor (Fermat close primes, batch-GCD shared primes, + Wiener small-`d`), and the EC analog: a near-zero EC private scalar recovered by a bounded `d*G` search + (the October 2026 eMudhra intermediate-CA compromise). The factoring recoveries and the EC arithmetic use + a small in-tree bignum (`bigint.{hpp,cpp}`), the same zero-dep choice as SHA-256/CRC32/DEFLATE. ## What it recovers keys from @@ -28,10 +29,12 @@ Two failure classes, following the standard taxonomy (see the research survey th - **Private keys** — the public half (modulus `n`, exponent `e`) is pulled from PEM `PRIVATE KEY` blocks (PKCS#1 and PKCS#8 RSA), so an embedded private key is checked for weakness too. -For an RSA key it keeps the modulus `n` (big-endian, leading zeros stripped) and the exponent `e`; every -generation check keys on `n`. Non-RSA keys are still recorded so a disclosure (corpus) match still fires. -All DER parsing is a bounded ASN.1 TLV walk (shared shape with `certid.cpp`/`derkey.cpp`); base64 and the -SSH wire decode are bounds-checked. Keys with no issue are **not reported** (the pass stays high-signal). +For an RSA key it keeps the modulus `n` (big-endian, leading zeros stripped) and the exponent `e`; the RSA +generation checks key on `n`. For an EC key on a recognized curve it keeps the affine public point +(`ec_x`/`ec_y`) so the EC small-scalar check can run. Keys of other types are still recorded so a +disclosure (corpus) match still fires. All DER parsing is a bounded ASN.1 TLV walk (shared shape with +`certid.cpp`/`derkey.cpp`); base64 and the SSH wire decode are bounds-checked. Keys with no issue are +**not reported** (the pass stays high-signal). ## The three checks @@ -55,6 +58,10 @@ The corpus is generated by `tools/gen_keycorpus.py` from the vendored **public** - `rapid7/ssh-badkeys` `host/` + `authorized/` `.pub` files (hardcoded appliance keys; F5 BIG-IP, ExaGrid, Ceragon, Barracuda, Cisco, Comtrend, ..., many with a CVE). - The HashiCorp Vagrant insecure key (private key published in the vagrant repo). +- The 8 eMudhra intermediate CA certificates with compromised keys (October 2026), keyed by `fp_der` + (SHA-256[0:32] of the cert DER). The vendored `.crt` files are the real public CT-logged certificates; + their SHA-256 fingerprints were verified against Mozilla bug 2083449. This names the specific certs when + a trust store bundles them; the general EC small-scalar check (below) recovers the key in any wrapper. Regenerate after changing the vendored sources; `tools/gen_keycorpus.py --check` (wired into `run.sh`) fails if `src/keycorpus_corpus.inc` is stale. See `THIRD_PARTY.md` for attribution. @@ -110,6 +117,31 @@ sub, mul, bit-by-bit divmod, Euclid GCD, digit-by-digit isqrt, perfect-square te The per-key factoring checks (Fermat, Wiener) are guarded to `512 ≤ bits ≤ 16384` so an oversized modulus cannot make a scan superlinearly slow. +### 5. EC small private scalar (`ec-small-scalar`, high, `validated`) + +`src/ecc.cpp`. The EC analog of the factoring recoveries: the one real-world EC generation failure is a +**catastrophically small private scalar** — the key is generated as `Q = d*G` for a tiny integer `d`, so +`d` is recovered by a bounded search (`d = 1, 2, 3, ...`, comparing `d*G` to the public point `Q`). This is +the **October 2026 eMudhra** intermediate-CA compromise: eight P-384 CA certs whose private scalars were +`4, 56, 66, 115, 155, 156, 230, 237` (3–8 bits instead of ~384). The vendored eMudhra certs are the test +anchor; the search recovers each published scalar exactly. + +- **Curves:** NIST P-256 / P-384 / P-521 and secp256k1 (short-Weierstrass, `a = p-3` for the NIST curves, + `a = 0` for secp256k1). Domain parameters are compiled in and unit-tested (`G` on curve, known `d*G`). +- **Arithmetic:** the in-tree `BigUint`, with **Barrett reduction** (precompute `mu = floor(2^(2k)/p)` once + per key) so each modular multiply is two multiplications, not the bit-by-bit divmod — the small-scalar + walk would be minutes per key otherwise. The walk accumulates `d*G` in Jacobian coordinates (no modular + inverse in the hot loop) and compares to `Q` by cross-multiplication. +- **Bound:** `kEcSmallScalarLimit = 2^12` per key (~17× the eMudhra max of 237). A legitimate scalar needs + ~2^128 for any security, so a hit within this bound is proof of a broken key, never a lucky guess. A + healthy EC key walks the full range (a miss) cheaply (~20 ms on P-384, Release). A per-file step budget + (`kEcFileStepBudget`) gives a normal trust store full reach while bounding a pathological many-EC-cert + file; every key keeps at least a `kEcMinScan=512` floor (still > 2× the eMudhra max). Only a well-formed + point (coordinates no larger than the field) is analyzed, which also bounds the reducer's input. +- **Reach / future work:** this reaches only the "broken RNG produced a near-zero scalar" floor. A + baby-step/giant-step search (`O(sqrt(limit))`) would extend the reach cheaply; a fast per-curve reduction + (Solinas) would make a much larger bound affordable. Both deferred. + ## Output Human mode prints a severity-sorted, deduped list (like the boot pass): `high`/`med`/`info` tag, the @@ -138,6 +170,20 @@ issue produce no finding. `fermat-factorable`, a small-`d`/large-`e` key → `wiener-weak` (and asserts the large `e` is not misreported as `rsa-broken-exponent`), and a shared-prime pair across two files → `shared-prime` on both. +## Testing (EC small-scalar) + +- `tests/unit/unit_tests.cpp::test_ecc` — curve lookup (incl. the OpenSSH/alias names), `G` on curve, + known-answer `d*G` recovery on all four curves (the P-384 `d=4` vector is the real eMudhra + `EM OV TLS CA - G3C` point), a below-the-scalar bound that must miss, a tampered off-curve point, and an + end-to-end DER-SPKI → `scan_keys` → `ec-small-scalar`. +- `tests/test_keys.py` — synthetic `Q = d*G` SPKIs for `d ∈ {1,5,255}` on each curve (recovered `d` + asserted), healthy openssl EC keys silent (false-positive guard), and the **8 real eMudhra + intermediates** each flagged `ec-small-scalar` with the exact published scalar and named by the corpus. +- Validated at scale: a raw `--keys` sweep over the full local firmware corpus (2868 samples) produced zero + `ec-small-scalar` findings and zero crashes (false-positive / robustness check); the EC path is also in + the `scan_keys` fuzz target. Note the raw-image sweep only sees certs in uncompressed regions; the + intended flow unpacks the rootfs first (`moria -e` → mithril), where each cert is its own file. + ## Remaining (deferred): Debian OpenSSL blocklist The one Phase-2 item still deferred is the **Debian OpenSSL weak-key** set (CVE-2008-0166): a fingerprint diff --git a/fuzz/fuzz_secrets.cpp b/fuzz/fuzz_secrets.cpp index 60abc09..9d5eeb9 100644 --- a/fuzz/fuzz_secrets.cpp +++ b/fuzz/fuzz_secrets.cpp @@ -6,7 +6,9 @@ // Build (from the mithril dir): // clang++ -std=c++20 -O1 -g -fsanitize=fuzzer,address -I src \ // src/ahocorasick.cpp src/validators.cpp src/rules_builtin.cpp src/engine.cpp \ -// src/sbom.cpp fuzz/fuzz_secrets.cpp -o fuzz/fuzz_secrets +// src/sbom.cpp src/pubkey.cpp src/certid.cpp src/bigint.cpp src/ecc.cpp \ +// src/keyweak.cpp src/keycorpus.cpp src/keys.cpp src/sha256.cpp \ +// fuzz/fuzz_secrets.cpp -o fuzz/fuzz_secrets // Run: ./fuzz/fuzz_secrets -max_total_time=60 #include #include diff --git a/mithril.1 b/mithril.1 index 574a580..1864563 100644 --- a/mithril.1 +++ b/mithril.1 @@ -78,8 +78,11 @@ Identify open-source licenses. Boot-security intel: U-Boot environment, device tree, and FIT/AVB/UEFI verified-boot posture. .TP .B \-\-keys -Public-key weakness: keys whose private half is public (hardcoded/default keys), -the ROCA fingerprint (CVE-2017-15361), and undersized or broken RSA keys. +Public-key weakness: keys whose private half is public (hardcoded/default keys, +the compromised eMudhra intermediate CAs), the ROCA fingerprint (CVE-2017-15361), +undersized or broken RSA keys, RSA factoring recoveries (Fermat/batch-GCD/Wiener), +and near-zero EC private scalars recovered by a bounded search (the Oct 2026 +eMudhra P-384 compromise). .TP .BR \-A ", " \-\-all Run every pass. This is the default when no pass is named. diff --git a/src/ecc.cpp b/src/ecc.cpp new file mode 100644 index 0000000..c71eece --- /dev/null +++ b/src/ecc.cpp @@ -0,0 +1,220 @@ +// ecc.cpp — minimal in-tree elliptic-curve arithmetic. See ecc.hpp. +#include "ecc.hpp" + +#include + +namespace ft { + +namespace { + +// hex (big-endian) -> BigUint. Authoring-time constants only; skips non-hex. +BigUint from_hex(const char* h) { + std::vector bytes; + int hi = -1; + for (const char* q = h; *q; ++q) { + int v; + if (*q >= '0' && *q <= '9') v = *q - '0'; + else if (*q >= 'a' && *q <= 'f') v = *q - 'a' + 10; + else if (*q >= 'A' && *q <= 'F') v = *q - 'A' + 10; + else continue; + if (hi < 0) hi = v; + else { bytes.push_back(uint8_t((hi << 4) | v)); hi = -1; } + } + return BigUint::from_be(bytes); +} + +// The curves mithril's pubkey extractor can name. a = p-3 for the NIST curves, +// a = 0 for secp256k1. Standard domain parameters (SEC 2 / FIPS 186-4). +const std::array& curves() { + static const std::array tbl = {{ + {"secp256r1", 32, + from_hex("ffffffff00000001000000000000000000000000ffffffffffffffffffffffff"), + from_hex("ffffffff00000001000000000000000000000000fffffffffffffffffffffffc"), + from_hex("5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b"), + from_hex("6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296"), + from_hex("4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5")}, + {"secp384r1", 48, + from_hex("fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe" + "ffffffff0000000000000000ffffffff"), + from_hex("fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe" + "ffffffff0000000000000000fffffffc"), + from_hex("b3312fa7e23ee7e4988e056be3f82d19181d9c6efe8141120314088f5013875a" + "c656398d8a2ed19d2a85c8edd3ec2aef"), + from_hex("aa87ca22be8b05378eb1c71ef320ad746e1d3b628ba79b9859f741e082542a38" + "5502f25dbf55296c3a545e3872760ab7"), + from_hex("3617de4a96262c6f5d9e98bf9292dc29f8f41dbd289a147ce9da3113b5f0b8c0" + "0a60b1ce1d7e819d7a431d7c90ea0e5f")}, + {"secp521r1", 66, + from_hex("01ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), + from_hex("01ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + "fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc"), + from_hex("0051953eb9618e1c9a1f929a21a0b68540eea2da725b99b315f3b8b489918ef1" + "09e156193951ec7e937b1652c0bd3bb1bf073573df883d2c34f1ef451fd46b503f00"), + from_hex("00c6858e06b70404e9cd9e3ecb662395b4429c648139053fb521f828af606b4d" + "3dbaa14b5e77efe75928fe1dc127a2ffa8de3348b3c1856a429bf97e7e31c2e5bd66"), + from_hex("011839296a789a3bc0045c8a5fb42c7d1bd998f54449579b446817afbd17273e" + "662c97ee72995ef42640c550b9013fad0761353c7086a272c24088be94769fd16650")}, + {"secp256k1", 32, + from_hex("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"), + from_hex("0000000000000000000000000000000000000000000000000000000000000000"), + from_hex("0000000000000000000000000000000000000000000000000000000000000007"), + from_hex("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"), + from_hex("483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8")}, + }}; + return tbl; +} + +// ---- field arithmetic mod p (operands kept reduced in [0,p)) ---------------- +// +// The small-scalar search does ~10 modular multiplications per point addition over +// the whole search bound, so the reduction must not be the bit-by-bit divmod in +// BigUint (that turned a single healthy-key miss into minutes). Barrett reduction +// precomputes mu = floor(2^(2k) / p) once per curve, then reduces a product with +// two multiplications and at most a few subtractions — no division in the hot loop +// and no change to the shared BigUint. +struct Field { + const BigUint& p; + size_t k; // bit length of p + BigUint mu; // floor(2^(2k) / p) + + explicit Field(const BigUint& modulus) : p(modulus), k(modulus.bit_length()) { + BigUint r2 = BigUint(1).shl_bits(2 * k); // 2^(2k) + BigUint q, rem; + r2.divmod(p, q, rem); // one divmod, at construction + mu = q; + } + + // Reduce x (x < 2^(2k), i.e. any product of two field elements) into [0,p). + BigUint reduce(const BigUint& x) const { + if (BigUint::cmp(x, p) < 0) return x; + // Barrett is valid only for x < 2^(2k); anything larger (a malformed + // oversized coordinate reaching here) falls back to exact division so the + // correction loop below can never spin. Callers keep inputs in range. + if (x.bit_length() > 2 * k) return x % p; + BigUint q1 = x.shr_bits(k - 1); + BigUint q3 = (q1 * mu).shr_bits(k + 1); + BigUint r = x - q3 * p; // x - q3*p, within a few p of the result + for (int i = 0; i < 6 && BigUint::cmp(r, p) >= 0; ++i) r = r - p; // ~2 expected + if (BigUint::cmp(r, p) >= 0) r = r % p; // guaranteed exit (never expected) + return r; + } + + BigUint add(const BigUint& x, const BigUint& y) const { + BigUint r = x + y; + if (BigUint::cmp(r, p) >= 0) r = r - p; // x,y < p -> r < 2p + return r; + } + BigUint sub(const BigUint& x, const BigUint& y) const { + return (BigUint::cmp(x, y) >= 0) ? x - y : (x + p) - y; // x,y < p + } + BigUint mul(const BigUint& x, const BigUint& y) const { return reduce(x * y); } + BigUint sqr(const BigUint& x) const { return reduce(x * x); } +}; + +// Jacobian point (X:Y:Z) representing affine (X/Z^2, Y/Z^3); Z==0 is infinity. +struct Jac { + BigUint X, Y, Z; + bool inf() const { return Z.is_zero(); } +}; + +const BigUint kTwo(2), kThree(3), kFour(4), kEight(8); + +// Point doubling (dbl-2007-bl: works for any curve coefficient a). +Jac dbl(const Field& f, const BigUint& a, const Jac& P) { + if (P.inf() || P.Y.is_zero()) return {BigUint(), BigUint(), BigUint()}; + BigUint XX = f.sqr(P.X); + BigUint YY = f.sqr(P.Y); + BigUint YYYY = f.sqr(YY); + BigUint ZZ = f.sqr(P.Z); + BigUint S = f.mul(kTwo, f.sub(f.sub(f.sqr(f.add(P.X, YY)), XX), YYYY)); + BigUint M = f.add(f.mul(kThree, XX), f.mul(a, f.sqr(ZZ))); + BigUint T = f.sub(f.sqr(M), f.mul(kTwo, S)); + Jac R; + R.X = T; + R.Y = f.sub(f.mul(M, f.sub(S, T)), f.mul(kEight, YYYY)); + R.Z = f.sub(f.sub(f.sqr(f.add(P.Y, P.Z)), YY), ZZ); + return R; +} + +// Mixed addition P(Jacobian) + Q(affine x2,y2) (madd-2007-bl); falls back to +// doubling when P == Q, returns infinity when P == -Q. +Jac madd(const Field& f, const BigUint& a, const Jac& P, const BigUint& x2, const BigUint& y2) { + if (P.inf()) return {x2, y2, BigUint(1)}; + BigUint Z1Z1 = f.sqr(P.Z); + BigUint U2 = f.mul(x2, Z1Z1); + BigUint S2 = f.mul(f.mul(y2, P.Z), Z1Z1); + BigUint H = f.sub(U2, P.X); + BigUint rr = f.sub(S2, P.Y); + if (H.is_zero()) { + if (rr.is_zero()) return dbl(f, a, P); + return {BigUint(), BigUint(), BigUint()}; // P == -Q + } + BigUint HH = f.sqr(H); + BigUint I = f.mul(kFour, HH); + BigUint J = f.mul(H, I); + BigUint r = f.mul(kTwo, rr); + BigUint V = f.mul(P.X, I); + Jac R; + R.X = f.sub(f.sub(f.sqr(r), J), f.mul(kTwo, V)); + R.Y = f.sub(f.mul(r, f.sub(V, R.X)), f.mul(kTwo, f.mul(P.Y, J))); + R.Z = f.sub(f.sub(f.sqr(f.add(P.Z, H)), Z1Z1), HH); + return R; +} + +// True if Jacobian P equals affine (x,y) — cross-multiplication, no inverse. +bool jac_eq_affine(const Field& f, const Jac& P, const BigUint& x, const BigUint& y) { + if (P.inf()) return false; // Q is a real affine point + BigUint Z2 = f.sqr(P.Z); + BigUint Z3 = f.mul(Z2, P.Z); + return f.mul(x, Z2) == P.X && f.mul(y, Z3) == P.Y; +} + +} // namespace + +const EcCurve* ec_curve_by_name(const std::string& name) { + std::string n = name; + // Accept the OpenSSH wire names too. + if (n == "nistp256") n = "secp256r1"; + else if (n == "nistp384") n = "secp384r1"; + else if (n == "nistp521") n = "secp521r1"; + else if (n == "prime256v1") n = "secp256r1"; + for (const auto& c : curves()) + if (c.name == n) return &c; + return nullptr; +} + +namespace { +bool on_curve(const Field& f, const BigUint& a, const BigUint& b, const BigUint& x, + const BigUint& y) { + BigUint lhs = f.sqr(y); // y^2 + BigUint rhs = f.add(f.add(f.mul(f.sqr(x), x), f.mul(a, x)), b); // x^3 + a x + b + return lhs == rhs; +} +} // namespace + +bool ec_point_on_curve(const EcCurve& c, std::span x_be, + std::span y_be) { + Field f(c.p); + return on_curve(f, c.a, c.b, f.reduce(BigUint::from_be(x_be)), + f.reduce(BigUint::from_be(y_be))); +} + +bool ec_recover_small_scalar(const EcCurve& c, std::span x_be, + std::span y_be, uint64_t limit, uint64_t& d_out) { + if (limit == 0) return false; + Field f(c.p); // precomputes the Barrett modulus once for this key + BigUint qx = f.reduce(BigUint::from_be(x_be)); + BigUint qy = f.reduce(BigUint::from_be(y_be)); + // Not on the curve -> no scalar can produce it; skip the (then meaningless) walk. + if (!on_curve(f, c.a, c.b, qx, qy)) return false; + + Jac R{BigUint(), BigUint(), BigUint()}; // infinity (0*G) + for (uint64_t d = 1; d <= limit; ++d) { + R = madd(f, c.a, R, c.gx, c.gy); // R = d*G + if (jac_eq_affine(f, R, qx, qy)) { d_out = d; return true; } + } + return false; +} + +} // namespace ft diff --git a/src/ecc.hpp b/src/ecc.hpp new file mode 100644 index 0000000..cec0fd0 --- /dev/null +++ b/src/ecc.hpp @@ -0,0 +1,54 @@ +// ecc.hpp — minimal in-tree elliptic-curve arithmetic for the key-weakness pass. +// +// The EC analog of keyweak.hpp's RSA checks. keyweak answers "is this RSA key's +// private half obtainable" (ROCA / Fermat / Wiener / undersized modulus); this +// module answers the same question for a short-Weierstrass EC public key, where +// the dominant real-world failure is a catastrophically small private scalar — +// the key is generated as d*G for a tiny integer d, so d is recoverable by a +// bounded search d = 1,2,3,... comparing d*G to the public point Q. This is the +// October 2026 eMudhra intermediate-CA compromise (P-384 scalars 4..237), and +// the general "broken RNG produced a near-zero scalar" class. +// +// All arithmetic is over the in-tree BigUint (zero dependency, like sha256/ +// inflate/bigint). The small-scalar search accumulates Q candidates in Jacobian +// coordinates so no modular inverse runs in the hot loop; the point comparison is +// a cross-multiplication. Only the ops the checks need — no general ECC. +#pragma once + +#include +#include +#include + +#include "bigint.hpp" + +namespace ft { + +// A named short-Weierstrass curve y^2 = x^3 + a*x + b over GF(p), generator G. +struct EcCurve { + std::string name; // "secp384r1" / "secp256r1" / "secp521r1" / "secp256k1" + size_t field_bytes; // size of one coordinate (48 for P-384, ...) + BigUint p, a, b, gx, gy; +}; + +// Look up a curve by the name pubkey.cpp records (secp256r1/secp384r1/secp521r1/ +// secp256k1, or the OpenSSH wire names nistp256/384/521). Returns nullptr if the +// curve is not one we carry parameters for. +const EcCurve* ec_curve_by_name(const std::string& name); + +// True if the affine point (x,y) satisfies the curve equation mod p. x and y are +// big-endian, already reduced into [0,p). A point that is not on the curve is a +// malformed key, and the small-scalar search is not meaningful for it. +bool ec_point_on_curve(const EcCurve& c, std::span x_be, + std::span y_be); + +// Bounded small-scalar recovery: if the public point Q = (x,y) equals d*G for some +// 1 <= d <= limit, set d_out and return true. A false return means "not found +// within the bound", never "secure". Work is O(limit) point additions, each a few +// field multiplications (no modular inverse), so limit caps the cost; the caller +// keeps limit modest (a legitimate 384-bit scalar is astronomically larger than +// any feasible bound, so a hit is proof of a broken key, not a lucky guess). +bool ec_recover_small_scalar(const EcCurve& c, std::span x_be, + std::span y_be, uint64_t limit, + uint64_t& d_out); + +} // namespace ft diff --git a/src/keycorpus_corpus.inc b/src/keycorpus_corpus.inc index bd77750..4227a69 100644 --- a/src/keycorpus_corpus.inc +++ b/src/keycorpus_corpus.inc @@ -3,6 +3,14 @@ // Regenerate after changing the vendored sources. inline constexpr KnownLeakedKey kLeakedKeys[] = { + {"42c4accd12c4f59597925e1a08a7a901", "eMudhra EM DV TLS CA - G3B intermediate (compromised; recoverable P-384 key)", "Oct 2026 eMudhra CA compromise; Mozilla bug 2083449", ""}, + {"0164d7ceae5bb909c272af151ff44b11", "eMudhra EM DV TLS CA - G3C intermediate (compromised; recoverable P-384 key)", "Oct 2026 eMudhra CA compromise; Mozilla bug 2083449", ""}, + {"e7568fe6e12331f4fd7de61da4c7559d", "eMudhra EM EV TLS CA - G3B intermediate (compromised; recoverable P-384 key)", "Oct 2026 eMudhra CA compromise; Mozilla bug 2083449", ""}, + {"432ec25f0d28de9006d97bf752f17bbb", "eMudhra EM EV TLS CA - G3C intermediate (compromised; recoverable P-384 key)", "Oct 2026 eMudhra CA compromise; Mozilla bug 2083449", ""}, + {"9e39de2881273acc225a149c72ba3e0f", "eMudhra EM OV CS CA - G3B intermediate (compromised; recoverable P-384 key)", "Oct 2026 eMudhra CA compromise; Mozilla bug 2083449", ""}, + {"4b2460a466f95fd40113bcdcca2ceb29", "eMudhra EM OV CS CA - G3C intermediate (compromised; recoverable P-384 key)", "Oct 2026 eMudhra CA compromise; Mozilla bug 2083449", ""}, + {"98b896625c0c230adeec3eb2cf6aad50", "eMudhra EM OV TLS CA - G3B intermediate (compromised; recoverable P-384 key)", "Oct 2026 eMudhra CA compromise; Mozilla bug 2083449", ""}, + {"cb6e15165ac802e9b3f993f16c25d41f", "eMudhra EM OV TLS CA - G3C intermediate (compromised; recoverable P-384 key)", "Oct 2026 eMudhra CA compromise; Mozilla bug 2083449", ""}, {"fc92e9eb3eae184dc13dcb3bd114286f", "array networks vapv vxag hardcoded authorized SSH key (DSA)", "rapid7/ssh-badkeys", "authorized"}, {"c210cfdf18ca0447adb5b0ee79cc6d1a", "barracuda load balancer vm hardcoded authorized SSH key (DSA)", "rapid7/ssh-badkeys; CVE-2014-8428", "authorized"}, {"67eab85e14b0598eead0120354f475bf", "ceragon fibeair cve 2015 0936 hardcoded authorized SSH key (RSA)", "rapid7/ssh-badkeys; CVE-2015-0936", "authorized"}, diff --git a/src/keys.cpp b/src/keys.cpp index 31de500..d51ecb3 100644 --- a/src/keys.cpp +++ b/src/keys.cpp @@ -3,6 +3,7 @@ #include +#include "ecc.hpp" #include "keycorpus.hpp" #include "keyweak.hpp" #include "pubkey.hpp" @@ -11,6 +12,22 @@ namespace ft { namespace { +// Upper bound for the EC small-scalar search per key. The observed real-world +// failure (eMudhra, Oct 2026) produced <= 8-bit scalars; 2^12 is a ~17x margin +// over that. A scalar needs ~2^128 for any security, so this reaches only the +// "broken RNG" floor; extending the reach cheaply (baby-step/giant-step is +// O(sqrt(limit))) is future work. +constexpr uint64_t kEcSmallScalarLimit = 1u << 12; + +// A healthy EC key's search walks the full range (a miss), so a file holding many +// EC certs (a bundled CA trust store) could spend time proving negatives. The +// budget below covers a normal trust store (~50 keys) at full reach; once spent, +// later keys fall back to the floor, which still covers the known class (eMudhra +// max scalar 237, so 512 keeps a >2x margin for every key in the file). This +// bounds a pathological many-EC-cert file instead of letting it run unbounded. +constexpr uint64_t kEcFileStepBudget = 200000; +constexpr uint64_t kEcMinScan = 512; + size_t bit_width_u64(uint64_t v) { size_t b = 0; while (v) { ++b; v >>= 1; } @@ -45,6 +62,8 @@ std::vector scan_keys(const std::string& path, std::span // DER, or the same host key in two files' worth of one buffer). std::unordered_set seen; + uint64_t ec_budget = kEcFileStepBudget; // shared EC search budget for this file + for (const auto& k : keys) { std::string ident = k.fp_ssh + "|" + k.fp_der + "|" + k.fp_modn; if (!seen.insert(ident).second) continue; @@ -119,6 +138,33 @@ std::vector scan_keys(const std::string& path, std::span if (out_rsa) out_rsa->push_back(RsaKeyInfo{k.offset, k.n, k.e, k.bits}); } + + // 6. EC small-scalar: a public point Q = d*G for a catastrophically small + // private scalar d (broken RNG / near-zero key generation — the Oct 2026 + // eMudhra intermediate-CA compromise, P-384 scalars 4..237). A bounded + // search recovers d; a legitimate scalar is astronomically larger than + // the bound, so a hit is proof of a recoverable private key. + if (k.algo == "EC" && !k.ec_x.empty() && !k.ec_y.empty()) { + const EcCurve* c = ec_curve_by_name(k.curve); + // Only a well-formed affine point (coordinates exactly the field size) + // is analyzed; this also bounds the field arithmetic (a malformed + // oversized coordinate never reaches the reducer). + if (c && k.ec_x.size() <= c->field_bytes && k.ec_y.size() <= c->field_bytes) { + uint64_t lim = ec_budget >= kEcSmallScalarLimit ? kEcSmallScalarLimit + : (ec_budget > kEcMinScan ? ec_budget : kEcMinScan); + ec_budget -= (ec_budget > lim) ? lim : ec_budget; + uint64_t d = 0; + if (ec_recover_small_scalar(*c, k.ec_x, k.ec_y, lim, d)) { + out.push_back(mk("ec-small-scalar", "high", Confidence::Validated, kind, + "EC private scalar recovered by small-scalar search: d=" + + std::to_string(d) + " on " + c->name + + " (a real private key is ~" + std::to_string(k.bits) + + "-bit); the private key is trivially recoverable", + "CWE-320 (weak key generation; near-zero EC private scalar)", + k.offset)); + } + } + } } return out; } diff --git a/src/pubkey.cpp b/src/pubkey.cpp index 9ec37df..d74591f 100644 --- a/src/pubkey.cpp +++ b/src/pubkey.cpp @@ -137,6 +137,19 @@ bool parse_spki(std::span d, const Der& spki, PublicKey& k) { k.algo = "EC"; auto params = der_at(d, oid->end); if (params && params->tag == 0x06) k.curve = curve_from_oid(d, *params); + // The BIT STRING holds the EC point: 1 unused-bits byte, then the point. + // Capture an uncompressed point (0x04 || X || Y, |X| == |Y|) so the + // key-weakness pass can run EC checks (small-scalar, on-curve). + size_t pstart = bitstr->body + 1, pend = bitstr->end; + if (pend > pstart && d[pstart] == 0x04) { + size_t plen = pend - pstart - 1; // drop the 0x04 tag + if (plen >= 2 && (plen & 1) == 0) { + size_t flen = plen / 2; + k.ec_x.assign(d.begin() + pstart + 1, d.begin() + pstart + 1 + flen); + k.ec_y.assign(d.begin() + pstart + 1 + flen, d.begin() + pend); + k.bits = flen * 8; + } + } return true; } if (oid_eq(d, *oid, OID_DSA)) { @@ -316,6 +329,14 @@ std::optional parse_ssh_blob(std::span blob, size_t of k.algo = "EC"; if (fields.size() >= 2) k.curve.assign(reinterpret_cast(fields[1].data()), fields[1].size()); + // fields[2] is the EC point Q (0x04 || X || Y for an uncompressed point). + if (fields.size() >= 3 && fields[2].size() >= 3 && fields[2][0] == 0x04 && + ((fields[2].size() - 1) & 1) == 0) { + size_t flen = (fields[2].size() - 1) / 2; + k.ec_x.assign(fields[2].begin() + 1, fields[2].begin() + 1 + flen); + k.ec_y.assign(fields[2].begin() + 1 + flen, fields[2].end()); + k.bits = flen * 8; + } } else { return std::nullopt; } diff --git a/src/pubkey.hpp b/src/pubkey.hpp index c1a2b24..3341eec 100644 --- a/src/pubkey.hpp +++ b/src/pubkey.hpp @@ -39,6 +39,10 @@ struct PublicKey { std::vector e_be; // RSA public exponent, big-endian, minimal (may be large) uint64_t e = 0; // RSA public exponent as u64 (0 if it does not fit / non-RSA) + // EC public point Q (affine, big-endian), set for an uncompressed EC key on a + // curve we recognize. Empty for non-EC keys or an unparsed/compressed point. + std::vector ec_x, ec_y; + KeyOrigin origin = KeyOrigin::PublicKeyInfo; size_t offset = 0; // byte offset of the key within its file diff --git a/tests/test_keys.py b/tests/test_keys.py index 3d18883..6a0abe9 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -103,6 +103,70 @@ def _next_prime(n): return n +# --- EC small-scalar fixtures (the Oct 2026 eMudhra failure class) ----------- +# Minimal pure-Python EC over the four curves mithril carries, to synthesize a +# public key Q = d*G for a tiny private scalar d and wrap it in a DER SPKI. No key +# material is committed; the points are computed at test time. +EC_CURVES = { + # name: (p, a, b, Gx, Gy, curve_oid_der_hex, field_bytes) + "prime256v1": (0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff, None, + 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b, + 0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296, + 0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5, "06082a8648ce3d030107", 32), + "secp384r1": (0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffeffffffff0000000000000000ffffffff, None, + 0xb3312fa7e23ee7e4988e056be3f82d19181d9c6efe8141120314088f5013875ac656398d8a2ed19d2a85c8edd3ec2aef, + 0xaa87ca22be8b05378eb1c71ef320ad746e1d3b628ba79b9859f741e082542a385502f25dbf55296c3a545e3872760ab7, + 0x3617de4a96262c6f5d9e98bf9292dc29f8f41dbd289a147ce9da3113b5f0b8c00a60b1ce1d7e819d7a431d7c90ea0e5f, "06052b81040022", 48), + "secp521r1": ((1 << 521) - 1, None, + 0x0051953eb9618e1c9a1f929a21a0b68540eea2da725b99b315f3b8b489918ef109e156193951ec7e937b1652c0bd3bb1bf073573df883d2c34f1ef451fd46b503f00, + 0x00c6858e06b70404e9cd9e3ecb662395b4429c648139053fb521f828af606b4d3dbaa14b5e77efe75928fe1dc127a2ffa8de3348b3c1856a429bf97e7e31c2e5bd66, + 0x011839296a789a3bc0045c8a5fb42c7d1bd998f54449579b446817afbd17273e662c97ee72995ef42640c550b9013fad0761353c7086a272c24088be94769fd16650, "06052b81040023", 66), + "secp256k1": (0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f, 0, 7, + 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798, + 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8, "06052b8104000a", 32), +} + + +def ec_mul(name, k): + p, a, b, gx, gy, _oid, _fb = EC_CURVES[name] + if a is None: + a = p - 3 + def inv(x): + return pow(x, p - 2, p) + def add(P, Q): + if P is None: + return Q + if Q is None: + return P + x1, y1 = P + x2, y2 = Q + if x1 == x2 and (y1 + y2) % p == 0: + return None + lam = ((3 * x1 * x1 + a) * inv(2 * y1) if P == Q else (y2 - y1) * inv((x2 - x1) % p)) % p + x3 = (lam * lam - x1 - x2) % p + return (x3, (lam * (x1 - x3) - y1) % p) + R = None + P = (gx, gy) + while k: + if k & 1: + R = add(R, P) + P = add(P, P) + k >>= 1 + return R + + +def ec_spki_der(name, d): + """DER SubjectPublicKeyInfo for Q = d*G on `name`.""" + p, a, b, gx, gy, oid, fb = EC_CURVES[name] + x, y = ec_mul(name, d) + point = b"\x04" + x.to_bytes(fb, "big") + y.to_bytes(fb, "big") + algid = b"\x30" + der_len(9 + len(bytes.fromhex(oid))) + \ + bytes.fromhex("06072a8648ce3d0201") + bytes.fromhex(oid) + bitstr = b"\x03" + der_len(len(point) + 1) + b"\x00" + point + body = algid + bitstr + return b"\x30" + der_len(len(body)) + body + + def make_roca_modulus(): """N with n mod p inside <65537 mod p> for every ROCA prime, ~2048-bit.""" from math import prod @@ -178,8 +242,41 @@ def main(): open(os.path.join(tree, "root", "gcd_b_pub.pem"), "w").write( rsa_pub_pem(sp * _gen_prime(512))) + # --- GENERATION (EC small-scalar: the eMudhra Oct 2026 failure class) --- + # Q = d*G for a tiny d on each carried curve; the search must recover d. + os.makedirs(os.path.join(tree, "etc", "ssl", "ec")) + ec_expected = {} # filename -> (curve, d) + for name in EC_CURVES: + for dd in (1, 5, 255): + fn = f"ec_{name}_d{dd}.der" + open(os.path.join(tree, "etc", "ssl", "ec", fn), "wb").write(ec_spki_der(name, dd)) + ec_expected[fn] = (name, dd) + + # --- Real eMudhra intermediates (vendored, public): all 8 are weak P-384 + # keys, each must be flagged ec-small-scalar AND named via the corpus. + emudhra_dir = os.path.join(CORPUS, "emudhra") + emudhra_scalars = { # the published private scalars (verified by recovery) + "EMOVTLSCAG3C": 4, "EMEVTLSCAG3C": 56, "EMOVCSCAG3B": 66, "EMEVTLSCAG3B": 115, + "EMOVTLSCAG3B": 155, "EMDVTLSCAG3B": 156, "EMDVTLSCAG3C": 230, "EMOVCSCAG3C": 237, + } + have_emudhra = os.path.isdir(emudhra_dir) + if have_emudhra: + os.makedirs(os.path.join(tree, "etc", "ssl", "emudhra")) + for cn in os.listdir(emudhra_dir): + if cn.endswith(".crt"): + shutil.copy(os.path.join(emudhra_dir, cn), + os.path.join(tree, "etc", "ssl", "emudhra", cn)) + # --- STRUCTURAL + false-positive fixtures (openssl) --- if have_openssl: + # Healthy EC keys (real random scalars) -> must be silent (FP guard). + for cname in ("prime256v1", "secp384r1"): + subprocess.run(["openssl", "ecparam", "-name", cname, "-genkey", "-noout", + "-out", "ec_priv.pem"], cwd=d, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.run(["openssl", "ec", "-in", "ec_priv.pem", "-pubout", + "-out", os.path.join(tree, "etc", "ssl", "ec", f"healthy_{cname}.pem")], + cwd=d, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def genrsa(bits, path): p = subprocess.run(["openssl", "genrsa", str(bits)], cwd=d, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) @@ -293,6 +390,49 @@ def flagged(path_end, typ): print("FAIL: weak key inside an EFI_SIGNATURE_LIST not flagged (unwrap failed)") fails += 1 + # --- EC small-scalar assertions --- + import re as _re + ess = by_type.get("ec-small-scalar", []) + def recovered_d(h): + m = _re.search(r"d=(\d+)", h.get("evidence", "")) + return int(m.group(1)) if m else None + # Synthetic Q = d*G on each curve: recovered d must match, tier validated. + for fn, (cname, dd) in ec_expected.items(): + hit = next((h for h in ess if h["path"].endswith(fn)), None) + if not hit: + print(f"FAIL: {fn} ({cname} d={dd}) not flagged ec-small-scalar") + fails += 1 + elif recovered_d(hit) != dd: + print(f"FAIL: {fn} recovered d={recovered_d(hit)} != {dd}") + fails += 1 + elif hit.get("confidence_tier") != "validated": + print(f"FAIL: {fn} ec-small-scalar tier {hit.get('confidence_tier')} != validated") + fails += 1 + # Healthy EC keys (openssl) must be silent — the false-positive guard. + if have_openssl: + for bad in ("healthy_prime256v1.pem", "healthy_secp384r1.pem"): + hits = [h for h in kw if h["path"].endswith(bad)] + if hits: + print(f"FAIL: EC false positive on {bad}: {[h['type'] for h in hits]}") + fails += len(hits) + # Real eMudhra intermediates: every one flagged, with the right scalar, and + # also named by the leaked-key corpus (identity + mechanism). + if have_emudhra: + leaked = by_type.get("leaked-key", []) + for cn, scalar in emudhra_scalars.items(): + fn = cn + ".crt" + hit = next((h for h in ess if h["path"].endswith(fn)), None) + if not hit: + print(f"FAIL: eMudhra {cn} not flagged ec-small-scalar") + fails += 1 + elif recovered_d(hit) != scalar: + print(f"FAIL: eMudhra {cn} recovered d={recovered_d(hit)} != {scalar}") + fails += 1 + if not any(h["path"].endswith(fn) and "emudhra" in h["label"].lower() + for h in leaked): + print(f"FAIL: eMudhra {cn} not named by the leaked-key corpus") + fails += 1 + # --- crash safety: truncations + mutations in the tree, must not crash --- fuzz = os.path.join(tree, "fuzz") os.mkdir(fuzz) @@ -314,8 +454,10 @@ def flagged(path_end, typ): if fails == 0: n_leaked = len(leaked_expected) extra = "" if have_openssl else " (openssl absent: structural/FP skipped)" + em = ", 8 real eMudhra P-384 keys recovered" if have_emudhra else "" print(f"PASS: {n_leaked} leaked/default keys matched (host+authorized impact), " - f"ROCA + Fermat + Wiener + batch-GCD recoveries detected, structural bands + " + f"ROCA + Fermat + Wiener + batch-GCD recoveries detected, EC small-scalar " + f"across 4 curves (synthetic{em}) + EC false-positive guard, structural bands + " f"false-positive guard, crash-safe over truncations/mutations{extra}") return 0 return 1 diff --git a/tests/unit/unit_tests.cpp b/tests/unit/unit_tests.cpp index 07d95bc..c9f6b12 100644 --- a/tests/unit/unit_tests.cpp +++ b/tests/unit/unit_tests.cpp @@ -19,6 +19,7 @@ #include "certid.hpp" #include "keycorpus.hpp" #include "keys.hpp" +#include "ecc.hpp" #include "keyweak.hpp" #include "pubkey.hpp" #include "component.hpp" @@ -1906,6 +1907,89 @@ static void test_keyweak() { } } +static void test_ecc() { + using namespace ft; + auto hx = [](const char* h) { + std::vector v; + for (const char* p = h; p[0] && p[1]; p += 2) + v.push_back(uint8_t((std::stoi(std::string(p, 2), nullptr, 16)))); + return v; + }; + + // Curve lookup, including the OpenSSH/alias names. + CHECK(ec_curve_by_name("secp384r1") && ec_curve_by_name("secp384r1")->field_bytes == 48); + CHECK(ec_curve_by_name("nistp256") && ec_curve_by_name("nistp256")->name == "secp256r1"); + CHECK(ec_curve_by_name("prime256v1") && ec_curve_by_name("prime256v1")->name == "secp256r1"); + CHECK(ec_curve_by_name("secp521r1") && ec_curve_by_name("secp256k1")); + CHECK(ec_curve_by_name("brainpoolP256r1") == nullptr); // unknown -> nullptr, no crash + + // Known-answer small-scalar recovery, one per curve. The P-384 d=4 point is the + // REAL eMudhra "EM OV TLS CA - G3C" intermediate (Oct 2026 compromise); its + // public point is exactly 4*G, so the search must recover d=4. + struct Kat { const char* curve; uint64_t d; const char* x; const char* y; }; + const Kat kats[] = { + {"secp256r1", 2, + "7cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc47669978", + "07775510db8ed040293d9ac69f7430dbba7dade63ce982299e04b79d227873d1"}, + {"secp384r1", 4, // eMudhra EM OV TLS CA - G3C public point + "138251cd52ac9298c1c8aad977321deb97e709bd0b4ca0aca55dc8ad51dcfc9d1589a1597e3a5120e1efd631c63e1835", + "cacae29869a62e1631e8a28181ab56616dc45d918abc09f3ab0e63cf792aa4dced7387be37bba569549f1c02b270ed67"}, + {"secp521r1", 2, + "00433c219024277e7e682fcb288148c282747403279b1ccc06352c6e5505d769be97b3b204da6ef55507aa104a3a35c5af41cf2fa364d60fd967f43e3933ba6d783d", + "00f4bb8cc7f86db26700a7f3eceeeed3f0b5c6b5107c4da97740ab21a29906c42dbbb3e377de9f251f6b93937fa99a3248f4eafcbe95edc0f4f71be356d661f41b02"}, + {"secp256k1", 255, + "1b38903a43f7f114ed4500b4eac7083fdefece1cf29c63528d563446f972c180", + "4036edc931a60ae889353f77fd53de4a2708b26b6f5da72ad3394119daf408f9"}, + }; + for (const auto& kt : kats) { + const EcCurve* c = ec_curve_by_name(kt.curve); + CHECK(c != nullptr); + auto x = hx(kt.x), y = hx(kt.y); + CHECK(ec_point_on_curve(*c, x, y)); + uint64_t d = 0; + CHECK(ec_recover_small_scalar(*c, x, y, 1u << 12, d) && d == kt.d); + // A limit below the true scalar must NOT find it (bounded, returns false). + uint64_t d2 = 0; + if (kt.d > 1) CHECK(!ec_recover_small_scalar(*c, x, y, kt.d - 1, d2)); + } + + // Tampered point (y+1) is off-curve -> on_curve false and no scalar recovered. + { + const EcCurve* c = ec_curve_by_name("secp256r1"); + auto x = hx("7cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc47669978"); + auto y = hx("07775510db8ed040293d9ac69f7430dbba7dade63ce982299e04b79d227873d2"); // ...d1 -> d2 + CHECK(!ec_point_on_curve(*c, x, y)); + uint64_t d = 0; + CHECK(!ec_recover_small_scalar(*c, x, y, 1u << 12, d)); + } + + // End to end through scan_keys: a DER SPKI holding a small-scalar P-256 point + // (the d=2 KAT) is parsed as EC with the point captured and flagged + // ec-small-scalar. + { + // SPKI = SEQ{ SEQ{ ecPublicKey, prime256v1 }, BIT STRING 00||04||X||Y }. + std::vector der; + auto xk = hx("7cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc47669978"); + auto yk = hx("07775510db8ed040293d9ac69f7430dbba7dade63ce982299e04b79d227873d1"); + std::vector pt = {0x04}; + pt.insert(pt.end(), xk.begin(), xk.end()); + pt.insert(pt.end(), yk.begin(), yk.end()); + // algId = SEQ{ OID ecPublicKey (1.2.840.10045.2.1), OID prime256v1 } + const uint8_t algid[] = {0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, + 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07}; + std::vector bits = {0x03, uint8_t(pt.size() + 1), 0x00}; + bits.insert(bits.end(), pt.begin(), pt.end()); + std::vector body(algid, algid + sizeof(algid)); + body.insert(body.end(), bits.begin(), bits.end()); + der = {0x30, uint8_t(body.size())}; + der.insert(der.end(), body.begin(), body.end()); + auto pks = extract_public_keys(der); + CHECK(!pks.empty() && pks[0].algo == "EC" && pks[0].curve == "secp256r1" && + pks[0].ec_x.size() == 32); + CHECK(has_type(scan_keys("k.der", der), "ec-small-scalar")); + } +} + int main() { test_ahocorasick(); test_entropy(); @@ -1950,6 +2034,7 @@ int main() { test_boot(); test_bigint(); test_keyweak(); + test_ecc(); std::printf("unit: %d checks, %d failures\n", g_checks, g_fails); return g_fails ? 1 : 0; } diff --git a/tools/gen_keycorpus.py b/tools/gen_keycorpus.py index 96dba06..47ae115 100644 --- a/tools/gen_keycorpus.py +++ b/tools/gen_keycorpus.py @@ -17,6 +17,10 @@ vagrant/vagrant.pub HashiCorp Vagrant insecure key (private key published in the vagrant repo); shipped as an authorized key on countless dev boxes and some appliances. + emudhra/*.crt Real public CT-logged eMudhra intermediate CA certs whose + P-384 keys were generated with a broken RNG (tiny private + scalars 4..237, recoverable); the Oct 2026 CA compromise. + Fingerprinted by fp_der (SHA-256[0:32] of the cert DER). Two fingerprints per key (see keycorpus.hpp): fp_ssh SHA-256[0:32] of the raw OpenSSH public-key blob (exact-key match). @@ -123,6 +127,29 @@ def add(f, label, ref, use): if modn: add(fp(modn), label + " [modulus]", ref, use) + # eMudhra intermediate CAs with compromised keys (October 2026). These P-384 + # certificates were generated with a broken RNG that produced tiny private + # scalars (4..237), so the private key is trivially recoverable from the public + # cert. The vendored .crt files are the real, public CT-logged certificates + # (SHA-256 fingerprints verified against Mozilla bug 2083449). Fingerprinted by + # fp_der (SHA-256[0:32] of the cert DER) so an exact match on a bundled trust + # store is named here; the general ec-small-scalar check catches the key in any + # cert wrapper (e.g. the Sectigo cross-signs) independently. + EMUDHRA_CN = { + "EMEVTLSCAG3B": "EM EV TLS CA - G3B", "EMOVTLSCAG3B": "EM OV TLS CA - G3B", + "EMDVTLSCAG3B": "EM DV TLS CA - G3B", "EMEVTLSCAG3C": "EM EV TLS CA - G3C", + "EMOVTLSCAG3C": "EM OV TLS CA - G3C", "EMDVTLSCAG3C": "EM DV TLS CA - G3C", + "EMOVCSCAG3B": "EM OV CS CA - G3B", "EMOVCSCAG3C": "EM OV CS CA - G3C", + } + ed = os.path.join(DATA, "emudhra") + for name in sorted(os.listdir(ed)) if os.path.isdir(ed) else []: + if not name.endswith(".crt"): + continue + der = open(os.path.join(ed, name), "rb").read() + cn = EMUDHRA_CN.get(name[:-4], name[:-4]) + label = "eMudhra %s intermediate (compromised; recoverable P-384 key)" % cn + add(fp(der), label, "Oct 2026 eMudhra CA compromise; Mozilla bug 2083449", "") + # Vagrant insecure key (authorized) vd = os.path.join(DATA, "vagrant") for name in sorted(os.listdir(vd)) if os.path.isdir(vd) else []: