From 5f6b72fb2f645869d5ad92b1ee7a71003e9b922c Mon Sep 17 00:00:00 2001 From: Khalid Saifullah Khan Date: Fri, 23 Oct 2026 03:32:10 +0530 Subject: [PATCH 1/2] fix: report distrusted CA keys on any Go version (CRYPTO-REVIEW-004 P0) eMudhra's eight P-384 intermediate CAs (Oct 2026) have private keys below 256. v0.4.0 rejected chains through them only because Go 1.27.3 drops them inside crypto/x509, and the report said just "no valid chains built". - refdata: hand-curated distrust list (distrust.json), matched by SPKI and certificate SHA-256; first incident is eMudhra (8 keys, 16 certificates, verified against the published certificates; a test re-derives each key from its private value). - pki: Paths builds signature-valid paths without policy filtering; Validate takes the list, keeps the path through a listed key and names it (StoreResult.Listed) instead of losing it. - eval: CERT.DISTRUSTED-KEY (critical) on served chains and on host, file and imported certificates; a finding, not a gate: every other rule still runs. eval/13. - Root stores and CT log list refreshed from CCADB/Google (2026-10-23): Mozilla removed emSign ECC Root CA - G3, ECC C3 and Root CA - C1. --- CHANGELOG.md | 19 ++ docs/methodology.md | 18 ++ .../CRYPTO-REVIEW-004-emudhra-weak-ca-keys.md | 228 ++++++++++++++++++ docs/reference-data.md | 10 + internal/eval/certificate.go | 26 +- internal/eval/certificate_test.go | 26 ++ internal/eval/eval.go | 5 +- internal/eval/eval_t1_test.go | 64 ++++- internal/eval/passtext.go | 1 + internal/eval/pkiview.go | 15 +- internal/eval/rules_t1.go | 98 ++++++++ internal/pki/distrust.go | 178 ++++++++++++++ internal/pki/distrust_test.go | 125 ++++++++++ internal/pki/oracle_test.go | 2 +- internal/pki/pki_test.go | 26 +- internal/pki/pkitest/pkitest.go | 30 +++ internal/pki/trust.go | 44 +++- internal/refdata/ctlogs_gen.go | 110 +++++---- internal/refdata/dhprimes_gen.go | 4 +- internal/refdata/distrust.go | 89 +++++++ internal/refdata/distrust.json | 36 +++ internal/refdata/distrust_test.go | 119 +++++++++ internal/refdata/refdata.go | 2 +- internal/refdata/refdata_test.go | 2 +- internal/refdata/roots_gen.go | 89 ++++--- internal/server/trust_test.go | 2 +- internal/store/priority.go | 13 +- internal/x509view/x509view_test.go | 4 +- 28 files changed, 1258 insertions(+), 127 deletions(-) create mode 100644 docs/product/CRYPTO-REVIEW-004-emudhra-weak-ca-keys.md create mode 100644 internal/pki/distrust.go create mode 100644 internal/pki/distrust_test.go create mode 100644 internal/refdata/distrust.go create mode 100644 internal/refdata/distrust.json create mode 100644 internal/refdata/distrust_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aebe1a..86d9638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,25 @@ the CBOM/report/eval schema versions, the database) can change in a minor releas ## [Unreleased] +### Security + +- **Distrusted CA keys are reported, on any Go version** + ([CRYPTO-REVIEW-004](docs/product/CRYPTO-REVIEW-004-emudhra-weak-ca-keys.md)). A new bundled distrust list is + matched by key (SPKI SHA-256) and by certificate fingerprint. Its first entry is the eight eMudhra intermediate CAs + of October 2026, whose P-384 private keys are below 256, with their Sectigo cross-signs. The new rule + `CERT.DISTRUSTED-KEY` (critical) fires on served chains and on certificates found on hosts, in files and in + imports. +- **A chain through a distrusted key is still evaluated.** 0.4.0 rejected these chains only because Go 1.27.3 + drops them inside `crypto/x509`, and the report showed just "x509: no valid chains built". The evaluation now + builds signature-valid paths itself. Each store's result keeps the path, names the listed certificate and its + position, and says why the store does not trust it. Every other rule still runs. Evaluation version `eval/13`. + +### Changed + +- Root stores refreshed from CCADB (2026-10-23). Mozilla has removed emSign ECC Root CA - G3, emSign ECC Root CA - + C3 and emSign Root CA - C1. Chrome still lists G3 until Chrome Root Store v43 (about 2026-10-27). CT log list + refreshed: Google's logs are now named like "Google Argon2026h2". + ## [0.4.0] - 2026-10-22 ### Security diff --git a/docs/methodology.md b/docs/methodology.md index 35a4441..92855c4 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -102,6 +102,24 @@ chain. For internal endpoints, add the private CA to the system's CA bundle (or result is re-evaluated at once and reads "trusted by your CA bundle". Private chains are exempt from the public lifetime limit and the CT requirement. The readiness level is unaffected by trust. +### Rules and changes in eval/13 (CRYPTO-REVIEW-004) + +| Rule | Severity | Fires when | Source | +|---|---|---|---| +| `CERT.DISTRUSTED-KEY` | critical (high for a CA root programs blocked without a key compromise) | a certificate on the served chain, or a root a path reaches, has a key or fingerprint on the bundled distrust list; also raised on certificates found on hosts, in files and in imports | CA/B Forum BR §4.9.1.2, RFC 5280, and the incident's own sources | + +- **A distrusted key is a finding, not a gate.** The chain is still validated and every other rule still runs. Each + store's result keeps the signature-valid path through the listed key, names the listed certificate and its + position, and says why the store does not trust it ("the path goes through "EM OV TLS CA - G3C" whose private key + is public …"). `CERT.UNTRUSTED` and `CERT.PARTIAL-TRUST` fire as usual alongside it. +- **Independent of the Go version.** Go 1.27.3 and later drop paths through a few hardcoded keys inside + `crypto/x509` without saying why. When that happens the evaluation builds the paths itself (names chain, each + signature verifies, served certificates as the only intermediates) and reports the listed key on them. A build + with an older Go gives the same result. +- **The list** is matched by the SHA-256 of the SubjectPublicKeyInfo, which covers every certificate for the key + (cross-signs, reissues), and by certificate SHA-256 for certificates known only by fingerprint (host collectors). + First entry: the eight eMudhra intermediate CAs of October 2026, whose P-384 private keys are below 256. + ### Rules and changes in eval/12 (PRD-015) | Rule | Severity | Fires when | Source | diff --git a/docs/product/CRYPTO-REVIEW-004-emudhra-weak-ca-keys.md b/docs/product/CRYPTO-REVIEW-004-emudhra-weak-ca-keys.md new file mode 100644 index 0000000..2812dc8 --- /dev/null +++ b/docs/product/CRYPTO-REVIEW-004-emudhra-weak-ca-keys.md @@ -0,0 +1,228 @@ +# CRYPTO-REVIEW-004: eMudhra weak CA keys and what CipherHorizon can detect + +Date: 2026-10-23 · Status: P0 built on branch `fix/distrusted-keys` (items 1–5; release 0.4.1 not cut). P1–P3 open. + +P0 as built, and where it differs from the plan below: +- Refdata refreshed 2026-10-23. Mozilla removed three emSign roots; Chrome still has G3 until v43. Re-run after 27 Oct. +- The distrust list is hand-curated JSON (`internal/refdata/distrust.json`), not a generated `_gen.go`. The P3 importer + will write to it. The eight eMudhra keys and 16 certificates were checked against the published certificates + (parsed, re-hashed, private keys recovered by brute force), and a test re-derives each key from its private value. +- The path builder (`pki.Paths`) feeds the trust result only when `x509.Verify` fails or a returned path has a listed key. + Verify stays the policy check for everything else. This is narrower than D6 (full replacement), on purpose for a patch release. +- Superfish, eDellRoot and DSDTestProvider stay matched by name in `CERT.COMPROMISED-ROOT`. No key hash for them was + verified, and guessing one would be worse than the name match. +- Host-collected certificates match by certificate fingerprint. Collectors do not record the key hash yet (P2). +Trigger: IFF letter of 2026-10-22 on the compromise of eMudhra CA keys. + +## 1. The incident in brief + +- Eight eMudhra **intermediate CAs** had **ECDSA P-384 private keys that were integers below 256** + (4, 56, 66, 115, 155, 156, 230, 237). A P-384 key should be a random number near 2^384. + Anyone can recover such a key by computing 1·G, 2·G, … and comparing with the public key. + That takes at most 255 point additions. +- Root cause (eMudhra, Bugzilla 2083449 comment 11): a key-ceremony script on 30 Apr 2025 tested a + "wrap/unwrap" step. That created a second, weak key set in the HSM, and those keys were used by mistake. + eMudhra says its RSA keys and the Entrust-generated "A / A-1" series are not affected. +- Warning sign missed: in May 2025 the CRLs of these CAs had invalid signatures (Bug 1970728). +- Hierarchy: six TLS CAs under *emSign Root TLS CA - G3*, two code-signing CAs under *emSign Root CS CA - G3*. + All chain to **emSign ECC Root CA - G3**. All eight were **cross-signed by Sectigo "AAA Certificate Services"**, + which Microsoft and Apple still trust. +- No leaf certificates under them were ever logged to CT. The danger is **forgery**: anyone can now + mint a certificate for any name that chains to a still-trusted root. The exposed party is + **every client (relying party) whose trust store or blocklist is not updated.** Servers are not the exposed party. +- Response: Mozilla OneCRL (17 Oct) and removal of the G3 root (NSS 3.131.1); Chrome CRLSet and root removal + in Chrome Root Store v43 (around 27 Oct); Apple (19 Oct) and Microsoft Disallowed store (20 Oct); + **Go 1.27.3 / 1.26.10** hardcode the eight key hashes into `crypto/x509`; certifi, curl, distro bundles updated. + +### Indicators (verified) + +The key hashes below are SHA-256 of the SubjectPublicKeyInfo. **We reproduced all eight locally** by +building P-384 keys from d = 4…237 (scratch test, 2026-10-23). The key-to-CA mapping is therefore exact. + +| d | CA (subject CN) | SPKI SHA-256 | Cert SHA-256 (emSign-issued) | +|---|---|---|---| +| 4 | EM OV TLS CA - G3C | 63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8 | CB6E1516…7C3F | +| 56 | EM EV TLS CA - G3C | 5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0 | 432EC25F…61F3 | +| 66 | EM OV CS CA - G3B | ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6 | 9E39DE28…67B4 | +| 115 | EM EV TLS CA - G3B | e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41 | E7568FE6…3838 | +| 155 | EM OV TLS CA - G3B | 43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b | 98B89662…C25B | +| 156 | EM DV TLS CA - G3B | ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105 | 42C4ACCD…1493 | +| 230 | EM DV TLS CA - G3C | 49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f | 0164D7CE…4D19 | +| 237 | EM OV CS CA - G3C | 92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30 | 4B2460A4…30B1 | + +The full certificate fingerprints, serials and the eight Sectigo cross-sign fingerprints are in +Bugzilla 2083449 comments 0 and 2. Match on the **SPKI hash**: one hash covers the emSign certificate, +the Sectigo cross-sign and any re-issue. +emSign ECC Root CA - G3 cert SHA-256: 86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b. + +Sources: IFF (internetfreedom.in, 2026-10-22); emudhra.we-have-a-problem.com; Mozilla Security Blog +2026-10-20; Bugzilla 2083449 / 1970728 / 2084101; scotthelme.co.uk; fenko.nz; Cloudflare blog; CERT-In CIAD-2026-0050. + +## 2. Would CipherHorizon detect it today? + +| Scenario | v0.4.0 (Go 1.27.3) | v0.3.0 and older, or a source build with Go < 1.27.3 / 1.26.10 | +|---|---|---| +| TLS server sends a leaf chaining through one of the eight CAs | **Rejected, but for the wrong stated reason.** Go silently drops the chain. The report says "x509: no valid chains built" (CERT.UNTRUSTED), with no mention of compromise. | **False "trusted".** Our bundled Mozilla and Chrome stores (CCADB, 2026-10-02) still include emSign ECC Root CA - G3 with no constraint. | +| Same, via the Sectigo AAA cross-sign | Not trusted (AAA is not in our Mozilla/Chrome sets). Same unexplained message. | Not trusted, unexplained. | +| One of the eight CAs found in a host cert store, PEM file or CBOM import | **Not detected.** Host certificates are only checked for expiry, size, hash and PQ dates. A P-384 key passes. | Same | +| Host trust store still trusts emSign ECC Root CA - G3, or the host has no block for the eight keys | **Not detected.** We do not read the Windows Disallowed store and have no distrust list. | Same | +| Another CA, server or SSH host key with a tiny EC private key (the general weak-key class) | **Not detected.** The only key check is bit length. | Same | +| Software on a host that embeds an unpatched trust bundle (old Go binary, certifi, Java) | Not detected | Same | + +Verified on 2026-10-23: a test chain through a d=4 P-384 intermediate passes `x509.Verify` on Go 1.27.0 +and fails on Go 1.27.3 with "no valid chains built". The machine's default Go here is 1.27.0. go.mod pins +`toolchain go1.27.3` (commit da10257, in v0.4.0). A build with `GOTOOLCHAIN=local` would silently lose the block. + +**Bottom line:** v0.4.0 is protected by accident (through the Go toolchain), not by design. It cannot explain +the finding, and it cannot detect this class of weakness in general. Our "stores" are **not up to date**: +the bundled root lists predate the incident, the signature DB holds collection recipes only (no keys or CAs), +and the only "compromised" list is three root names matched by CN (Superfish, eDellRoot, DSDTestProvider). + +## 3. Cryptographer's assessment: what a scanner can and cannot do + +1. **Known-bad lists (exact match).** These are cheap and certain, but only for keys someone has already found. + Matching on SPKI hash beats matching on certificate fingerprint or CN. +2. **Structural weak-key tests.** These find the *next* eMudhra before anyone publishes a list: + - **EC small scalar.** Test whether Q = ±k·G for |k| < 2^32 using baby-step giant-step: a table of about + 2^16 x-coordinates per curve, then at most 2^16 giant steps per key. Comparing x-coordinates also covers + d = n − k. The table should cost single-digit MB and per-key cost should be milliseconds (both unmeasured). + This would have caught all eight keys with a margin of 2^24. + Also checks for P-256, P-521 and the SSH ECDSA host-key curves. Ed25519/X25519 clamp and hash the scalar, + so a small seed does not give a small scalar. For those, only list matching applies. + - **RSA:** ROCA fingerprint (Infineon); Fermat test for p ≈ q; trial division by small primes; + **batch GCD across every RSA modulus in the estate** (shared-prime keys). Batch GCD is where an estate + tool beats a single-host scanner. Plus a Debian-2008 / badkeys-style blocklist, if a licence-clear + source exists (this was deferred in PRD-004 REQ-TCRT-6 for that reason). + - **Key reuse:** the same SPKI under different subjects or issuers across the estate. + - **What no test can show:** whether a properly random-looking key was in fact generated with low entropy + (for example a seeded PRNG). Those keys look random to any test on the key alone. That is an audit or + ceremony question, not a scanner question. Report it as a limit and do not imply coverage. +3. **Where the risk sits.** For CA compromises the action is on the **client side**: is this host, + runtime or appliance still trusting the bad keys? CipherHorizon's host collector is unusual here. It can + answer "which machines in my estate still accept forged eMudhra certificates?" A TLS server scanner alone cannot. + +## 4. Plan + +Ordered by value over effort. P0 is this week, while the incident is live. + +### Principles (apply to every item) +- **Evaluate, never gate.** CipherHorizon is an evaluator, not a TLS client. A distrusted key, a revoked + certificate, a compromised root or a weak key is a **finding on a fully evaluated chain**. It must not stop + path building, hide the anchor, or suppress other findings: protocols, ciphers, key exchange, the other + certificate checks and CBOM output all still run. When several problems apply, report all of them with + their own evidence. Today that already holds per rule, but the trust verdict breaks it: Go 1.27.3's + `x509.Verify` silently drops chains with a listed key, and there is no switch to turn that off. +- **Classes, not incidents.** eMudhra is one entry in a list and one fixture in a test corpus. Every mechanism + below is generic: any distrusted key, any revoked CA, any weak key, any host that still trusts any of them. + Rule names and texts describe the class. The incident name, date and sources come from the data entry. +- **Two separate questions per chain:** (1) *cryptographic path*: is there a signature-valid path from the + leaf to a root in store X? (2) *policy and status*: what do the root programs, distrust lists, revocation + and key tests say about each certificate on that path? (1) is computed once, without policy filtering. + (2) is a set of findings layered on top. + +### P0: correct, current, and not gated (small) +1. **Refresh refdata** from CCADB now, and again after Chrome Root Store v43 (around 27 Oct). Re-run the eval + goldens. Note: once a root leaves a store, its chains become "no path to a root" in that store. Item 3 + keeps the reason visible. +2. **Distrust list in refdata** (`distrust_gen.go`), generic schema: + `{spkiSHA256 | certSHA256, subject, kind: key-compromise | ca-distrust | weak-key | test-key, scope: tls | code | all, + since, sources[], incident}`. Seed it with the eight eMudhra keys, the Superfish, eDellRoot and + DSDTestProvider roots (moving those from CN to key matching), and the other entries in Go's + `distrustedSPKIHashes`. Growing it is a data change, not a code change (see P3). +3. **Own path builder for evaluation** (`pki.BuildPaths`). Walk the served certificates plus the store roots by + name, AKI/SKI and `CheckSignatureFrom`. Record every complete signature-valid path, with no distrust + filtering. Then apply validity, EKU, name constraints and root-program constraints as **annotations** on + each path. Keep `x509.Verify` as a cross-check: if it rejects a path our builder accepts, the report says + which policy rejected it, never the raw "no valid chains built". +4. **Rule CERT.DISTRUSTED-KEY (Critical).** It fires for any certificate (served chain, host store, file, + import, trust anchor) whose key or fingerprint is on the list. The finding names the entry's incident, + date and sources, and the position (leaf, intermediate n, root). It also says whether a path to a trusted + root exists anyway: "chains to *emSign ECC Root CA - G3* (Mozilla, Chrome snapshot 2026-10-02) through a CA + whose private key is public". CERT.UNTRUSTED and CERT.PARTIAL-TRUST still fire as they apply. They are no + longer suppressed by the compromise finding. +5. **Build guard:** a unit test that a d=4 P-384 intermediate yields CERT.DISTRUSTED-KEY **and** a complete + path in the report, on any Go version. Keep `toolchain go1.27.3` or later anyway (clients built with it are safer). +6. **Patch release 0.4.1** with items 1–5, plus a release note. + +### P1: detect the class (medium) +Each P1 test is a finding on the key wherever it appears. A weak key never removes the certificate from +path building or from any other check. + +6. **EC small-scalar test** (BSGS, |k| < 2^32) on every EC key we see: served chain, host stores, files, + imports, SSH host keys, anchors. New rule KEY.EC-SMALL-SCALAR (Critical). When it fires, the finding gives + the recovered k as proof (k is public by then). Use `filippo.io/nistec` or + the standard-library equivalent. Add a benchmark, and cache results by SPKI hash. +7. **RSA weak-key tests:** ROCA fingerprint, Fermat (bounded iterations), small-factor trial division. + Rules KEY.RSA-ROCA, KEY.RSA-CLOSE-PRIMES, KEY.RSA-SMALL-FACTOR. +8. **Estate batch GCD and key reuse** across all stored RSA moduli and SPKIs. Run it as a background job on + the estate. Rules KEY.RSA-SHARED-PRIME and KEY.REUSED. +9. **Self-test corpus** in `internal/pki/pkitest`: d=4 chains, ROCA-shaped moduli, shared-prime pairs, + all with positive and negative goldens. + +### P1b: online revocation checks (medium; lifts the PRD-004 REQ-TREV-5 deferral) +Why now: eMudhra revoked all eight CAs on its root CRLs on 18 Oct (reason cACompromise), and Sectigo revoked +its cross-signs. A CRL check would have flagged them a day after disclosure, for any key weakness and any +Go version. It also catches a warning sign we missed: in May 2025 these CAs published CRLs with **invalid +signatures** (Bug 1970728). A scanner that checks CRL signatures would have reported that 17 months early. + +Design rules (a cryptographer's view): +- **Check each certificate against its *issuer's* revocation data, up the whole chain.** The CRL or OCSP + for a forged leaf is signed by the compromised key itself, so the attacker controls it. Only the parent's + CRL for the intermediate (signed by the uncompromised root) can be trusted. +- **CRL first, OCSP second.** CA/B Forum made OCSP optional and CRLs mandatory (SC-063). Let's Encrypt + has turned OCSP off. Browsers use pushed lists (CRLite, CRLSet, OneCRL) rather than live OCSP. + OCSP also tells the CA which site is being scanned; a CRL does not. +- **Validate the CRL fully:** signature by the issuer (or an indirect CRL issuer that is allowed to sign + it), issuer name and AKI match, `thisUpdate`/`nextUpdate` freshness, IDP scope, and delta CRLs when used. + New findings: REV.REVOKED (Critical, with reason and time), REV.CRL-BAD-SIGNATURE (High), + REV.CRL-STALE (Medium), REV.UNREACHABLE (shown as *unknown*, never as a pass). +- **Revocation is a status, not a gate.** A revoked certificate stays on the path. The trust verdict, the + other certificate findings and the rest of the scan are all reported. The finding says which certificate + is revoked, by whom, when and why (for example cACompromise vs superseded). A cACompromise revocation + also raises the severity of every certificate below it on the path. +- **Hostile input and SSRF.** CRL and OCSP URLs come from target-supplied certificates, so a server could + make our scanner fetch internal addresses. Allow `http` only. Block loopback, link-local + (169.254.0.0/16, including cloud metadata), private and ULA addresses unless the operator allows them. + Cap body size (e.g. 20 MB), time and redirects. The CRL parser gets bounds and a fuzz target + (REQ-TNFR-1). +- **Egress is opt-in** ("Online revocation checks", off by default, per PRD-004 D1). Cache by URL until + `nextUpdate`. Offline estates can import CRL files, and the CI feed (P3) can ship OneCRL and CRLite-derived + entries as an offline list. +- Apply the same check to CA certificates found in host stores (P2): "this host trusts a revoked CA". + +### P2: client-side exposure across the estate (medium; the differentiator) +All driven by the distrust list. Each list entry can carry "fixed in" versions, so a new incident needs no code. + +10. **Windows:** read the `Disallowed` store (wincerts currently skips it). Report per host, for every list + entry, whether the key is blocked and whether its root still sits in Root or AuthRoot. +11. **Linux and macOS:** flag trust-store files that contain any distrusted root or CA, and `ca-certificates` + packages older than an entry's fixed versions (signature-DB recipes plus a knowledge-base table). +12. **Embedded bundles:** read Go `buildinfo`, and detect certifi, Java cacerts, Node and Python runtimes. + Compare them against each entry's fixed versions. Report "this binary may still trust ". +13. **Estate view:** "Hosts exposed to a distrusted CA or key", filterable by entry (eMudhra Oct 2026, + Superfish, …), with a remediation checklist built from the entry. + +### P3: keep the stores current (process) +14. **Scheduled CI job** (weekly) that regenerates refdata from CCADB and the CT log list and opens a PR + when anything changes. Distrust sources the generator imports and diffs: Mozilla OneCRL (public Remote + Settings JSON), Go's `distrustedSPKIHashes`, Microsoft's public disallowed-certificate CTL, and CCADB + revocation and incident data. A new entry in any source becomes a PR, so the next incident reaches users + in a release without anyone hand-writing a rule. +15. Turn the 180-day staleness flag into a **finding** when the bundled store is older than N days + (proposed: 30). Show the snapshot date next to every trust verdict. +16. Licence check on the badkeys blocklists (owner decision; this unblocks the Debian list deferred in PRD-004). + +### Out of scope, stated as limits +- Low-entropy keys that look random (see §3). Ceremony quality is an audit question. +- CCA India (licensed hierarchy) roots are not in Mozilla or Chrome. A "CCA India" store could be added as a + separate, optional store if Indian customers need it (owner decision). + +## 5. Owner decisions +- D1: Ship P0 as **0.4.1** now? (recommended: yes) +- D2: Distrust list sources: hand-curated only, or also auto-import OneCRL? (recommended: both; OneCRL through the CI PR) +- D3: Use badkeys data, subject to its licence? (needs a licence read) +- D4: Add an optional CCA India store? +- D5: Online revocation checks (P1b): off by default with opt-in? (recommended: yes, CRL first) +- D6: Replace `x509.Verify` as the source of the trust verdict with our own path builder, keeping Verify as a + cross-check (P0 item 3)? (recommended: yes; it is the only way to keep evaluating chains the Go standard + library refuses) diff --git a/docs/reference-data.md b/docs/reference-data.md index 4261dba..ef03035 100644 --- a/docs/reference-data.md +++ b/docs/reference-data.md @@ -22,6 +22,7 @@ check was not available instead of falling back to something else. | CT log list | `https://www.gstatic.com/ct/log_list/v3/log_list.json` | Published by Google for CT clients and shipped inside Chromium under its BSD licence; a factual list of log identifiers and names | passed | | Published Diffie-Hellman groups | the texts of RFC 2409, RFC 3526, RFC 5114 and RFC 7919 | IETF Trust legal provisions; the values are factual parameters published for implementation | passed | | Certificate lifetime limits | CA/Browser Forum Ballot SC-081v3 (Baseline Requirements §6.3.2) | a four-row factual schedule, transcribed by hand | passed | +| Distrusted CA keys | `internal/refdata/distrust.json`, compiled by hand from root-program incident reports (each incident cites its Bugzilla bug and announcements) | certificate and key SHA-256 values, factual identifiers; no text is copied | passed | ### Attribution @@ -43,6 +44,15 @@ not *Included*. A root program's constraints are kept with the root and applied A CA that a program has removed entirely is simply absent from its store. Together, the constraints and the absences are the distrust list of REQ-TTRS-6. +**Distrusted CA keys.** Keys no certification path should rely on: CA keys whose private half is public or +recoverable (`key-compromise`) and CA certificates root programs have blocked (`ca-distrust`). Each incident has +an id, a title, the date it became public, a summary and its sources; each entry names the CA and gives its SPKI +SHA-256 and the SHA-256 of every certificate known to carry the key. `TestDistrustListIsWellFormed` checks the +shape, and `TestEmudhraKeysAreSmallScalars` re-derives the eMudhra keys from their published private values +rather than trusting the copied hashes. Evaluation reports a listed key as `CERT.DISTRUSTED-KEY` and keeps +evaluating the chain (see methodology, eval/13). The file is edited by hand: add an incident, run +`go test ./internal/refdata/`, and set `version` to the date. + **DH groups.** The primes are read from the RFC texts, checked to be prime and of the size their heading says, and fingerprinted the way the scanner fingerprints a server's prime (SHA-256 over the big-endian bytes, leading zeros trimmed). Fourteen of the sixteen fingerprints are pinned in a test against OpenSSL 3.5's built-in named diff --git a/internal/eval/certificate.go b/internal/eval/certificate.go index 86c05a5..a1485e5 100644 --- a/internal/eval/certificate.go +++ b/internal/eval/certificate.go @@ -5,6 +5,9 @@ import ( "sort" "strings" "time" + + "github.com/offsetsecurity/cipherhorizon/internal/pki" + "github.com/offsetsecurity/cipherhorizon/internal/refdata" ) // CertificateFacts is what a certificate says about itself: enough to judge it, wherever it @@ -19,6 +22,10 @@ type CertificateFacts struct { SigName string // the signature algorithm's name, e.g. SHA256-RSA IsCA bool SelfSigned bool + // SHA256 and SPKISHA256 identify the certificate and its key (lower-case hex; either may be + // empty), for the distrust list. + SHA256 string + SPKISHA256 string } // quantumVulnerableKeys are the public-key algorithms NIST IR 8547 lists as vulnerable to a @@ -51,6 +58,23 @@ func EvaluateCertificate(f CertificateFacts, now time.Time) []Finding { Evidence: Observed, Citations: cites, Remediation: remed}) } + if l, ok := refdata.Distrust().LookupHashes(strings.ToLower(f.SHA256), strings.ToLower(f.SPKISHA256)); ok { + sev := Critical + if l.Kind == pki.KindCADistrust { + sev = High + } + detail := fmt.Sprintf("%s is %q %s", label, l.Name, l.Phrase()) + if f.IsCA { + detail += "; a machine that trusts it, or a CA bundle that carries it, accepts certificates anyone can issue under it" + } + if len(l.Sources) > 0 { + detail += "; see " + l.Sources[0] + } + add("CERT.DISTRUSTED-KEY", "Certificate uses a distrusted CA key", sev, []Citation{citCAKeyCompromise, citRFC5280}, + "Remove this certificate from every trust store and CA bundle that holds it, replace anything issued under it, and update the software on this machine so it blocks the key.", + detail) + } + if !f.NotAfter.IsZero() { switch { case f.NotAfter.Before(now): @@ -110,5 +134,5 @@ func EvaluateCertificate(f CertificateFacts, now time.Time) []Finding { // CertificateRuleIDs lists the rule IDs EvaluateCertificate can produce. func CertificateRuleIDs() []string { - return []string{"CERT.EXPIRED", "CERT.EXPIRING", "CERT.NOT-YET-VALID", "CERT.WEAK-KEY", "CERT.WEAK-SIGNATURE", "CERT.PQ-OUTLIVES-DEPRECATION"} + return []string{"CERT.DISTRUSTED-KEY", "CERT.EXPIRED", "CERT.EXPIRING", "CERT.NOT-YET-VALID", "CERT.WEAK-KEY", "CERT.WEAK-SIGNATURE", "CERT.PQ-OUTLIVES-DEPRECATION"} } diff --git a/internal/eval/certificate_test.go b/internal/eval/certificate_test.go index aa5a887..ca0b0e1 100644 --- a/internal/eval/certificate_test.go +++ b/internal/eval/certificate_test.go @@ -156,9 +156,35 @@ func TestEveryCertificateFindingIsCited(t *testing.T) { collect(f) f.NotAfter = days(4) collect(f) + // A CA on the distrust list (eMudhra "EM OV TLS CA - G3C", by key). + collect(CertificateFacts{Subject: "CN=EM OV TLS CA - G3C", NotBefore: days(-30), NotAfter: days(300), KeyAlgorithm: "ECDSA", KeyBits: 384, + SigName: "ECDSA-SHA384", IsCA: true, SPKISHA256: "63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8"}) for _, id := range CertificateRuleIDs() { if !seen[id] { t.Errorf("rule %s never fired in the citation check", id) } } } + +// A certificate found on a host is known by its fingerprint (collectors do not +// record the key hash): the Sectigo cross-sign of "EM OV TLS CA - G3C" must +// still match, and a CA gets the note about trust stores. +func TestDistrustedKeyByFingerprint(t *testing.T) { + f := CertificateFacts{Subject: "CN=EM OV TLS CA - G3C", NotBefore: days(-30), NotAfter: days(300), KeyAlgorithm: "ECDSA", KeyBits: 384, + SigName: "SHA384-RSA", IsCA: true, SHA256: "7485B51C0ED192878FA1D2F948FB529AE1842CC5086C4B67F7BE5B2039DE9489"} + var got *Finding + for _, fd := range EvaluateCertificate(f, certNow) { + if fd.ID == "CERT.DISTRUSTED-KEY" { + got = &fd + } + } + if got == nil || got.Severity != Critical || !strings.Contains(got.Detail, "whose private key is public") || !strings.Contains(got.Detail, "a machine that trusts it") { + t.Fatalf("finding %+v", got) + } + f.SHA256 = strings.Repeat("ab", 32) + for _, fd := range EvaluateCertificate(f, certNow) { + if fd.ID == "CERT.DISTRUSTED-KEY" { + t.Errorf("an unlisted certificate matched: %+v", fd) + } + } +} diff --git a/internal/eval/eval.go b/internal/eval/eval.go index 3a18861..3d0aeee 100644 --- a/internal/eval/eval.go +++ b/internal/eval/eval.go @@ -18,7 +18,7 @@ import ( // Version identifies the rule set that produced an Evaluation. Stored evaluations // keep the version they were made with, so history shows what was concluded then. -const Version = "eval/12" +const Version = "eval/13" // eval/7 (PRD-008): a post-quantum signature scheme proved accepted by the signature stage counts // toward L4, and SSH endpoints are judged into the same shape (Protocol "ssh"). @@ -174,6 +174,9 @@ func EvaluateWith(rep *tlsprobe.Report, in Input) Evaluation { if in.Stores == nil { in.Stores = refdata.Stores() } + if in.Distrust == nil { + in.Distrust = refdata.Distrust() + } c := newContext(rep, in.Now) c.in = in c.legacy = rep.SchemaVersion == "" || rep.SchemaVersion == tlsprobe.SchemaVersion1 diff --git a/internal/eval/eval_t1_test.go b/internal/eval/eval_t1_test.go index 517f489..81e157e 100644 --- a/internal/eval/eval_t1_test.go +++ b/internal/eval/eval_t1_test.go @@ -1,6 +1,7 @@ package eval_test import ( + "crypto/elliptic" "crypto/rand" "crypto/x509" "encoding/base64" @@ -567,7 +568,7 @@ func TestSectionsAndFixFirst(t *testing.T) { t.Errorf("%s has no section", fd.ID) } } - if len(ev.ReferenceData) != 5 { + if len(ev.ReferenceData) != 6 { t.Errorf("reference data %+v", ev.ReferenceData) } @@ -653,6 +654,7 @@ func t1Firing(t *testing.T) []eval.Evaluation { r.HSTSHeader = tlsprobe.ParseHSTS(h) out = append(out, evalAt(r, epoch, f.stores)) } + out = append(out, weakKeyEval(t)) at, st := osslAt(t), osslStore(t) for _, c := range []struct { leaf, staple string @@ -720,3 +722,63 @@ func TestCompromisedRootIsItsOwnFinding(t *testing.T) { t.Errorf("also untrusted or incomplete: %v", t1IDs(ev)) } } + +// weakKeyEval is a chain through a CA whose P-384 private key is 4 (the key of +// eMudhra's "EM OV TLS CA - G3C"), under a synthetic root both stores hold, +// evaluated against the bundled distrust list. +func weakKeyEval(t *testing.T) eval.Evaluation { + t.Helper() + f := newPKI(t) + weak := f.root.IntermediateWithKey(t, "Weak CA", pkitest.CAOpts{}, pkitest.SmallScalarKey(t, elliptic.P384(), 4)) + rep := v2(chainResult("tls-default", weak.Leaf(t, pkitest.LeafOpts{SCTs: sctOK}), weak.Cert)) + return evalAt(rep, epoch, f.stores) +} + +// TestDistrustedKeyIsAFindingNotAGate: a listed key on the path raises +// CERT.DISTRUSTED-KEY, the stores report the path they found through it and +// why they do not trust it, and every other rule still runs on the endpoint. +func TestDistrustedKeyIsAFindingNotAGate(t *testing.T) { + ev := weakKeyEval(t) + var dk []eval.Finding + for _, fd := range ev.Findings { + if fd.ID == "CERT.DISTRUSTED-KEY" { + dk = append(dk, fd) + } + } + if len(dk) != 1 || dk[0].Severity != eval.Critical { + t.Fatalf("CERT.DISTRUSTED-KEY: %+v", dk) + } + for _, want := range []string{`served certificate 1 "EM OV TLS CA - G3C" whose private key is public`, "eMudhra intermediate CA private keys recoverable, 2026-10-17", + "a signature-valid path through it reaches", "in Mozilla", "in Chrome", "https://bugzilla.mozilla.org/show_bug.cgi?id=2083449"} { + if !strings.Contains(dk[0].Detail, want) { + t.Errorf("detail lacks %q: %s", want, dk[0].Detail) + } + } + if len(ev.PKI) != 1 { + t.Fatalf("%d chains", len(ev.PKI)) + } + for _, r := range ev.PKI[0].Trust { + if r.Trusted || len(r.Path) != 3 || len(r.Listed) != 1 || r.Listed[0].Position != 1 || !strings.Contains(r.Distrust, "whose private key is public") || r.Error != "" { + t.Errorf("%s: %+v", r.Store, r) + } + } + untrusted := false + for _, fd := range ev.Findings { + if fd.ID == "CERT.UNTRUSTED" && strings.Contains(fd.Detail, "the path goes through") { + untrusted = true + } + } + if !untrusted { + t.Error("CERT.UNTRUSTED should fire and say why") + } + // Evaluation carried on: the whole rule set was checked, not just the certificate. + checked := map[string]bool{} + for _, c := range ev.Checks { + checked[c.ID] = true + } + for _, id := range []string{"PROTO.TLS10", "CERT.EXPIRING", "CERT.LIFETIME", "CT.NO-SCTS", "KX.DH-SMALL"} { + if !checked[id] { + t.Errorf("%s was not checked once the key was found", id) + } + } +} diff --git a/internal/eval/passtext.go b/internal/eval/passtext.go index ee22141..a57eca9 100644 --- a/internal/eval/passtext.go +++ b/internal/eval/passtext.go @@ -28,6 +28,7 @@ var passText = map[string]string{ "KX.DH-COMPOSITE": "The Diffie-Hellman modulus is a prime", "KX.DH-NOT-SAFE-PRIME": "The Diffie-Hellman prime is a safe prime or a published group", "CERT.COMPROMISED-ROOT": "The chain does not end at a root whose private key is public", + "CERT.DISTRUSTED-KEY": "No certificate on the chain uses a key on the distrust list", "CERT.EXPIRED": "The certificate has not expired", "CERT.NOT-YET-VALID": "The certificate is already valid", diff --git a/internal/eval/pkiview.go b/internal/eval/pkiview.go index 0a7862a..1aefbec 100644 --- a/internal/eval/pkiview.go +++ b/internal/eval/pkiview.go @@ -29,6 +29,10 @@ type Input struct { // SSHBaseline is the id of the baseline an SSH server is judged against; empty means // BaselineDefault. SSHBaseline string + // Distrust lists keys and certificates no path should rely on; nil means the bundled + // list (refdata.Distrust). A listed key is reported on the path it sits on, never used + // to stop evaluating it. + Distrust *pki.Distrust } // ChainView is the evaluation's reading of one served chain: how it was @@ -208,7 +212,7 @@ func (c *ctx) analyseChains() { expiredOnly := true for _, st := range stores { - r := pki.Validate(s, st, c.now, times) + r := pki.Validate(s, st, c.now, times, c.in.Distrust) ch.view.Trust = append(ch.view.Trust, r) switch { case r.Trusted: @@ -228,7 +232,7 @@ func (c *ctx) analyseChains() { } } if c.in.Anchors != nil { - r := pki.Validate(s, c.in.Anchors, c.now, times) + r := pki.Validate(s, c.in.Anchors, c.now, times, c.in.Distrust) r.Store = "your CA bundle" ch.view.Private = &r ch.private = r.Trusted @@ -412,9 +416,12 @@ func (ch *chain) trustSummary() string { } } if p := ch.view.Private; p != nil { - if p.Trusted { + switch { + case p.Trusted: parts = append(parts, "your CA bundle: trusted") - } else { + case p.Distrust != "": + parts = append(parts, "your CA bundle: "+p.Distrust) + default: parts = append(parts, "your CA bundle: "+p.Error) } } diff --git a/internal/eval/rules_t1.go b/internal/eval/rules_t1.go index 764811a..7d6ccb1 100644 --- a/internal/eval/rules_t1.go +++ b/internal/eval/rules_t1.go @@ -2,6 +2,7 @@ package eval import ( "fmt" + "slices" "strings" "github.com/offsetsecurity/cipherhorizon/internal/pki" @@ -29,8 +30,100 @@ var ( citSP80056A = Citation{Title: "NIST SP 800-56A Rev. 3: Pair-Wise Key-Establishment Using Discrete Logarithm Cryptography (domain parameters, section 5.5)", URL: "https://csrc.nist.gov/pubs/sp/800/56/a/r3/final"} citDHSubgroup = cve("CVE-2016-0701", "OpenSSL: DH small-subgroup key recovery with primes that are not safe primes") citSuperfish = cve("CVE-2015-2077", "Superfish: a root certificate installed with its private key") + // citCAKeyCompromise is the rule a compromised CA key falls under; each finding also names the + // incident's own sources from the distrust list. + citCAKeyCompromise = Citation{Title: "CA/Browser Forum Baseline Requirements §4.9.1.2: revoking a subordinate CA whose private key is compromised", + URL: "https://cabforum.org/working-groups/server/baseline-requirements/requirements/"} ) +// distrustedKeyHits is CERT.DISTRUSTED-KEY: every served certificate whose key or fingerprint is +// on the distrust list, wherever it sits on the chain, plus a listed root that a path reaches +// without the server sending it. The finding sits beside the trust verdict, never instead of it: +// the chain is still validated, and each store's result says whether a signature-valid path +// exists through the listed key. +func distrustedKeyHits(c *ctx) []hit { + dl := c.in.Distrust + if dl.Len() == 0 { + return nil + } + views := map[int]*chain{} + for i, ch := range c.chains { + views[i] = ch + } + var hits []hit + for i, cr := range c.rep.Certificates { + if cr.NoSNI || len(cr.Chain) == 0 { + continue + } + label := certLabel(cr, cr.Chain[0]) + seen := map[string]bool{} + report := func(where string, l pki.Listed, sha string) { + if seen[sha] { + return + } + seen[sha] = true + sev := Critical + if l.Kind == pki.KindCADistrust { + sev = High + } + detail := fmt.Sprintf("%s: %s %q %s", label, where, l.Name, l.Phrase()) + if ch := views[i]; ch != nil { + if reach := ch.pathsThrough(sha); reach != "" { + detail += "; a signature-valid path through it reaches " + reach + } + } + if len(l.Sources) > 0 { + detail += "; see " + l.Sources[0] + } + hits = append(hits, hit{detail: detail, severity: sev}) + } + for pos, ci := range cr.Chain { + sha := strings.ToLower(ci.SHA256) + if l, ok := dl.LookupHashes(sha, strings.ToLower(ci.SPKISHA256)); ok { + where := fmt.Sprintf("served certificate %d", pos) + if pos == 0 { + where = "the leaf" + } + report(where, l, sha) + } + } + if ch := views[i]; ch != nil { + for _, r := range ch.storeResults() { + for _, on := range r.Listed { + report("the path in "+r.Store+" goes through", on.Listed, on.SHA256) + } + } + } + } + return hits +} + +// storeResults are the chain's results for every store, the operator's bundle included. +func (ch *chain) storeResults() []pki.StoreResult { + out := append([]pki.StoreResult(nil), ch.view.Trust...) + if ch.view.Private != nil { + out = append(out, *ch.view.Private) + } + return out +} + +// pathsThrough names, per store, the root a validated path through the certificate sha reaches. +func (ch *chain) pathsThrough(sha string) string { + var parts []string + for _, r := range ch.storeResults() { + if len(r.Path) == 0 || !slices.Contains(r.Path, sha) { + continue + } + root := r.Path[len(r.Path)-1] + name := root[:16] + if _, n, ok := refdata.RootName(root); ok { + name = n + } + parts = append(parts, fmt.Sprintf("%q in %s", name, r.Store)) + } + return strings.Join(parts, ", ") +} + // compromisedRoots are roots whose private keys were published with the software that installed // them, matched by the issuer's common name: anyone can issue a certificate under them. var compromisedRoots = map[string]string{ @@ -244,6 +337,11 @@ var t1Rules = []rule{ } return "", "", false }), + {id: "CERT.DISTRUSTED-KEY", title: "A certificate on the chain uses a distrusted CA key", category: "certificate", + severity: Critical, evidence: Observed, citations: []Citation{citCAKeyCompromise, citRFC5280}, + remediation: "Replace the certificate with one from a CA whose key is intact. Remove the listed CA certificates from every trust store and CA bundle you manage, and update clients (browsers, operating-system trust stores, language runtimes, appliances) so they block the key. " + + "Anyone can issue certificates under a key whose private half is public, so a client that still accepts it can be intercepted.", + check: distrustedKeyHits}, chainRule("CERT.WEAK-EXPONENT", "RSA public exponent is smaller than 65537", "certificate", Low, []Citation{citCABF}, "Reissue with a new key whose public exponent is 65537.", diff --git a/internal/pki/distrust.go b/internal/pki/distrust.go new file mode 100644 index 0000000..fc3a383 --- /dev/null +++ b/internal/pki/distrust.go @@ -0,0 +1,178 @@ +package pki + +import ( + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "fmt" +) + +// Kinds of distrust-list entry. +const ( + // KindKeyCompromise: the private key is public or recoverable, so anyone can sign as this CA. + KindKeyCompromise = "key-compromise" + // KindCADistrust: root programs have blocked the certificate, though its key is not known to be public. + KindCADistrust = "ca-distrust" +) + +// Listed is what a distrust list says about one key: what it is, why it is listed and where that +// is published. An entry is a fact about the key, not a verdict on a path: evaluation reports it +// and carries on. +type Listed struct { + Name string `json:"name"` // the CA's name, for display + Kind string `json:"kind"` // KindKeyCompromise | KindCADistrust + Incident string `json:"incident"` // incident id, e.g. "emudhra-2026-10" + Title string `json:"title"` // the incident's title + Since string `json:"since"` // YYYY-MM-DD the problem became public + Summary string `json:"summary"` + Sources []string `json:"sources"` +} + +// Phrase describes the entry in a clause: "whose private key is public (title, since)". +func (l Listed) Phrase() string { + what := "whose private key is public" + if l.Kind == KindCADistrust { + what = "which root programs have blocked" + } + return fmt.Sprintf("%s (%s, %s)", what, l.Title, l.Since) +} + +// Distrust is a list of keys and certificates no path should rely on, matched by the SHA-256 of +// the SubjectPublicKeyInfo (one entry covers every certificate for that key: the original, its +// cross-signs and any reissue) or of the certificate's DER. A nil *Distrust matches nothing. +type Distrust struct { + bySPKI map[string]Listed + byCert map[string]Listed +} + +// NewDistrust returns an empty list. +func NewDistrust() *Distrust { + return &Distrust{bySPKI: map[string]Listed{}, byCert: map[string]Listed{}} +} + +// Add lists a key (spki, lower-case hex, may be empty) and certificates (lower-case hex SHA-256 of +// DER) under l. +func (d *Distrust) Add(spki string, certs []string, l Listed) { + if spki != "" { + d.bySPKI[spki] = l + } + for _, c := range certs { + d.byCert[c] = l + } +} + +// Len is the number of distinct keys and certificates listed. +func (d *Distrust) Len() int { + if d == nil { + return 0 + } + return len(d.bySPKI) + len(d.byCert) +} + +// LookupHashes matches by certificate SHA-256 and/or SPKI SHA-256 (lower-case hex; either may be +// empty), for certificates known only by their facts. +func (d *Distrust) LookupHashes(certSHA, spkiSHA string) (Listed, bool) { + if d == nil { + return Listed{}, false + } + if spkiSHA != "" { + if l, ok := d.bySPKI[spkiSHA]; ok { + return l, true + } + } + if certSHA != "" { + if l, ok := d.byCert[certSHA]; ok { + return l, true + } + } + return Listed{}, false +} + +// Lookup matches a parsed certificate. +func (d *Distrust) Lookup(c *x509.Certificate) (Listed, bool) { + if d == nil || c == nil { + return Listed{}, false + } + return d.LookupHashes(Fingerprint(c), SPKIFingerprint(c)) +} + +// SPKIFingerprint is the lower-case hex SHA-256 of a certificate's SubjectPublicKeyInfo. +func SPKIFingerprint(c *x509.Certificate) string { + sum := sha256.Sum256(c.RawSubjectPublicKeyInfo) + return hex.EncodeToString(sum[:]) +} + +// ListedOn is one listed certificate found on a path. +type ListedOn struct { + Position int `json:"position"` // 0 = leaf, counting up the path + SHA256 string `json:"sha256"` + Listed +} + +// ListedOnPath returns every listed certificate on a path (leaf first). +func (d *Distrust) ListedOnPath(path []*x509.Certificate) []ListedOn { + var out []ListedOn + for i, c := range path { + if l, ok := d.Lookup(c); ok { + out = append(out, ListedOn{Position: i, SHA256: Fingerprint(c), Listed: l}) + } + } + return out +} + +// Limits on path building over hostile input: a served chain is at most MaxServedCerts long, and +// these keep a crafted set of cross-signs from exploding the search. +const ( + maxPathLen = 8 + maxPaths = 16 +) + +// Paths returns every path from the served leaf to a root in store along which each signature +// verifies under its issuer's key and the names chain, leaf first, root last. The served +// certificates are the only intermediates (no AIA fetching). No validity period, key usage, +// name constraint, root-program constraint or distrust list is applied: Paths answers "is there +// a cryptographic path?", and evaluation reports the rest as findings on that path. It exists +// because crypto/x509 silently drops paths through keys it has hardcoded as distrusted, which +// would hide the very path an evaluation has to show. +func Paths(s *Served, store *Store) [][]*x509.Certificate { + if s == nil || store == nil || s.Leaf() == nil { + return nil + } + inters := s.Intermediates() + var out [][]*x509.Certificate + var walk func(path []*x509.Certificate) + walk = func(path []*x509.Certificate) { + if len(out) >= maxPaths { + return + } + cur := path[len(path)-1] + if _, ok := store.bySHA[Fingerprint(cur)]; ok { + out = append(out, append([]*x509.Certificate(nil), path...)) + return + } + if len(path) >= maxPathLen { + return + } + for _, r := range store.roots { + if !onPath(path, r.cert) && Issued(r.cert, cur) { + walk(append(path, r.cert)) + } + } + for _, c := range inters { + if !onPath(path, c) && Issued(c, cur) { + walk(append(path, c)) + } + } + } + walk([]*x509.Certificate{s.Leaf()}) + return out +} + +func onPath(path []*x509.Certificate, c *x509.Certificate) bool { + for _, p := range path { + if p.Equal(c) { + return true + } + } + return false +} diff --git a/internal/pki/distrust_test.go b/internal/pki/distrust_test.go new file mode 100644 index 0000000..e669bfd --- /dev/null +++ b/internal/pki/distrust_test.go @@ -0,0 +1,125 @@ +package pki_test + +import ( + "crypto/elliptic" + "strings" + "testing" + + "github.com/offsetsecurity/cipherhorizon/internal/pki" + "github.com/offsetsecurity/cipherhorizon/internal/pki/pkitest" +) + +// emudhraG3C is the SPKI SHA-256 of "EM OV TLS CA - G3C", whose P-384 private key is 4. +const emudhraG3C = "63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8" + +var compromised = pki.Listed{Name: "Weak CA", Kind: pki.KindKeyCompromise, Incident: "test-incident", + Title: "test CA keys recoverable", Since: "2026-10-17", Sources: []string{"https://example.test/"}} + +// weakChain is root -> intermediate with private key 4 on P-384 -> leaf: the +// shape of the eMudhra compromise, with our own root. +func weakChain(t *testing.T) (root, inter *pkitest.CA) { + t.Helper() + root = pkitest.NewRoot(t, "Root", pkitest.CAOpts{}) + inter = root.IntermediateWithKey(t, "Weak CA", pkitest.CAOpts{}, pkitest.SmallScalarKey(t, elliptic.P384(), 4)) + return root, inter +} + +// TestSmallScalarKeyIsTheRealKey pins the fixture to the incident: a P-384 key +// with private scalar 4 has exactly the public key of the real eMudhra CA. +func TestSmallScalarKeyIsTheRealKey(t *testing.T) { + _, inter := weakChain(t) + if got := pki.SPKIFingerprint(inter.Cert); got != emudhraG3C { + t.Fatalf("d=4 on P-384 gives SPKI %s, want %s", got, emudhraG3C) + } +} + +// TestDistrustedKeyIsReportedNotDropped is the P0 build guard: on any Go +// version (Go 1.27.3+ silently drops the path, older ones accept it) the +// result says the store does not trust the chain, says why, and keeps the +// whole signature-valid path with the listed certificate's position. +func TestDistrustedKeyIsReportedNotDropped(t *testing.T) { + root, inter := weakChain(t) + leaf := inter.Leaf(t, pkitest.LeafOpts{}) + dl := pki.NewDistrust() + dl.Add(emudhraG3C, nil, compromised) + + for _, constrained := range []bool{true, false} { + st := store(t, "S", constrained, rootOf(root)) + r := pki.Validate(analyze(t, leaf, inter.Cert), st, epoch, nil, dl) + if r.Trusted { + t.Errorf("constrained=%v: a path through a listed key was trusted: %+v", constrained, r) + } + if !strings.Contains(r.Distrust, `"Weak CA" whose private key is public (test CA keys recoverable, 2026-10-17)`) { + t.Errorf("constrained=%v: reason %q", constrained, r.Distrust) + } + if len(r.Path) != 3 || r.Path[1] != pki.Fingerprint(inter.Cert) || r.Path[2] != pki.Fingerprint(root.Cert) { + t.Errorf("constrained=%v: path lost: %v", constrained, r.Path) + } + if len(r.Listed) != 1 || r.Listed[0].Position != 1 || r.Listed[0].Incident != "test-incident" { + t.Errorf("constrained=%v: listed %+v", constrained, r.Listed) + } + if r.Error != "" { + t.Errorf("constrained=%v: the raw verifier error leaked: %q", constrained, r.Error) + } + } + + // The same chain with no list: whatever crypto/x509 decides, nothing is invented. + r := pki.Validate(analyze(t, leaf, inter.Cert), store(t, "S", true, rootOf(root)), epoch, nil, nil) + if r.Distrust != "" || len(r.Listed) != 0 { + t.Errorf("no list, yet listed: %+v", r) + } +} + +// TestDistrustByCertificateFingerprint covers entries known only by certificate hash. +func TestDistrustByCertificateFingerprint(t *testing.T) { + root := pkitest.NewRoot(t, "Root", pkitest.CAOpts{}) + inter := root.Intermediate(t, "Blocked CA", pkitest.CAOpts{}) + leaf := inter.Leaf(t, pkitest.LeafOpts{}) + dl := pki.NewDistrust() + l := compromised + l.Kind, l.Name = pki.KindCADistrust, "Blocked CA" + dl.Add("", []string{pki.Fingerprint(inter.Cert)}, l) + r := pki.Validate(analyze(t, leaf, inter.Cert), store(t, "S", true, rootOf(root)), epoch, nil, dl) + if r.Trusted || !strings.Contains(r.Distrust, "which root programs have blocked") || len(r.Path) != 3 { + t.Errorf("certificate-hash entry: %+v", r) + } + if _, ok := dl.LookupHashes(pki.Fingerprint(inter.Cert), ""); !ok { + t.Error("LookupHashes by certificate") + } + var nilList *pki.Distrust + if _, ok := nilList.Lookup(inter.Cert); ok || nilList.Len() != 0 { + t.Error("a nil list matched") + } +} + +// TestPathsFollowsCrossSigns: the same key under two issuers gives one path per +// root, and the list (matched by key) catches both. +func TestPathsFollowsCrossSigns(t *testing.T) { + rootA := pkitest.NewRoot(t, "Root A", pkitest.CAOpts{}) + rootB := pkitest.NewRoot(t, "Root B", pkitest.CAOpts{}) + inter := rootA.IntermediateWithKey(t, "Weak CA", pkitest.CAOpts{}, pkitest.SmallScalarKey(t, elliptic.P384(), 4)) + cross := rootB.CrossSign(t, inter, pkitest.CAOpts{}) + leaf := inter.Leaf(t, pkitest.LeafOpts{}) + s := analyze(t, leaf, inter.Cert, cross) + + paths := pki.Paths(s, store(t, "Both", true, rootOf(rootA), rootOf(rootB))) + if len(paths) != 2 { + t.Fatalf("%d paths, want 2", len(paths)) + } + dl := pki.NewDistrust() + dl.Add(emudhraG3C, nil, compromised) + for _, p := range paths { + if on := dl.ListedOnPath(p); len(on) != 1 || on[0].Position != 1 { + t.Errorf("path %v: listed %+v", p, on) + } + } + // Only root B: the path goes through the cross-sign. + r := pki.Validate(s, store(t, "B", true, rootOf(rootB)), epoch, nil, dl) + if r.Trusted || len(r.Path) != 3 || r.Path[1] != pki.Fingerprint(cross) { + t.Errorf("via the cross-sign: %+v", r) + } + // A root nothing chains to gives no path. + if p := pki.Paths(s, store(t, "Else", true, rootOf(pkitest.NewRoot(t, "Else", pkitest.CAOpts{})))); len(p) != 0 { + t.Errorf("paths to an unrelated root: %d", len(p)) + } +} diff --git a/internal/pki/oracle_test.go b/internal/pki/oracle_test.go index 488609f..8fdd29f 100644 --- a/internal/pki/oracle_test.go +++ b/internal/pki/oracle_test.go @@ -75,7 +75,7 @@ func TestWriteOracleFixtures(t *testing.T) { if err != nil { t.Fatal(err) } - r := pki.Validate(s, st, c.at, nil) + r := pki.Validate(s, st, c.at, nil, nil) verdict := "untrusted" if r.Trusted { verdict = "trusted" diff --git a/internal/pki/pki_test.go b/internal/pki/pki_test.go index ec4a4f4..b677c47 100644 --- a/internal/pki/pki_test.go +++ b/internal/pki/pki_test.go @@ -127,65 +127,65 @@ func TestValidate(t *testing.T) { now := epoch plain := store(t, "Plain", true, rootOf(root)) - if r := pki.Validate(analyze(t, leaf, inter.Cert), plain, now, nil); !r.Trusted || len(r.Path) != 3 { + if r := pki.Validate(analyze(t, leaf, inter.Cert), plain, now, nil, nil); !r.Trusted || len(r.Path) != 3 { t.Errorf("clean chain: %+v", r) } - if r := pki.Validate(analyze(t, leaf), plain, now, nil); r.Trusted || r.Error == "" { + if r := pki.Validate(analyze(t, leaf), plain, now, nil, nil); r.Trusted || r.Error == "" { t.Errorf("missing intermediate validated (no AIA allowed): %+v", r) } - if r := pki.Validate(analyze(t, leaf, inter.Cert), store(t, "Empty", true, rootOf(pkitest.NewRoot(t, "Else", pkitest.CAOpts{}))), now, nil); r.Trusted { + if r := pki.Validate(analyze(t, leaf, inter.Cert), store(t, "Empty", true, rootOf(pkitest.NewRoot(t, "Else", pkitest.CAOpts{}))), now, nil, nil); r.Trusted { t.Errorf("trusted by a store without its root: %+v", r) } // Mozilla-style distrust after a date. distrusted := rootOf(root) distrusted.DistrustAfter = leaf.NotBefore.Add(-48 * time.Hour) - r := pki.Validate(analyze(t, leaf, inter.Cert), store(t, "Mozilla", true, distrusted), now, nil) + r := pki.Validate(analyze(t, leaf, inter.Cert), store(t, "Mozilla", true, distrusted), now, nil, nil) if r.Trusted || !strings.Contains(r.Distrust, "distrusted for certificates issued after") { t.Errorf("distrust-after ignored: %+v", r) } // Unconstrained (private) stores do not apply constraints. - if r := pki.Validate(analyze(t, leaf, inter.Cert), store(t, "private", false, distrusted), now, nil); !r.Trusted { + if r := pki.Validate(analyze(t, leaf, inter.Cert), store(t, "private", false, distrusted), now, nil, nil); !r.Trusted { t.Errorf("private store applied a constraint: %+v", r) } // Issued on the cut-off date itself: still trusted. onDay := rootOf(root) onDay.DistrustAfter = leaf.NotBefore - if r := pki.Validate(analyze(t, leaf, inter.Cert), store(t, "Mozilla", true, onDay), now, nil); !r.Trusted { + if r := pki.Validate(analyze(t, leaf, inter.Cert), store(t, "Mozilla", true, onDay), now, nil, nil); !r.Trusted { t.Errorf("issued on the cut-off: %+v", r) } // Chrome-style sct_not_after. sctRoot := rootOf(root) sctRoot.SCTNotAfter = leaf.NotBefore.Add(-time.Hour) - if r := pki.Validate(analyze(t, leaf, inter.Cert), store(t, "Chrome", true, sctRoot), now, []time.Time{leaf.NotBefore}); r.Trusted || r.Distrust == "" { + if r := pki.Validate(analyze(t, leaf, inter.Cert), store(t, "Chrome", true, sctRoot), now, []time.Time{leaf.NotBefore}, nil); r.Trusted || r.Distrust == "" { t.Errorf("late SCT accepted: %+v", r) } - if r := pki.Validate(analyze(t, leaf, inter.Cert), store(t, "Chrome", true, sctRoot), now, []time.Time{leaf.NotBefore, sctRoot.SCTNotAfter}); !r.Trusted { + if r := pki.Validate(analyze(t, leaf, inter.Cert), store(t, "Chrome", true, sctRoot), now, []time.Time{leaf.NotBefore, sctRoot.SCTNotAfter}, nil); !r.Trusted { t.Errorf("one SCT in time should satisfy the constraint: %+v", r) } - if r := pki.Validate(analyze(t, leaf, inter.Cert), store(t, "Chrome", true, sctRoot), now, nil); r.Trusted { + if r := pki.Validate(analyze(t, leaf, inter.Cert), store(t, "Chrome", true, sctRoot), now, nil, nil); r.Trusted { t.Errorf("no SCTs and notBefore after the limit: %+v", r) } // Root-program name constraints. named := rootOf(root) named.PermittedDNS = []string{"tr"} - if r := pki.Validate(analyze(t, leaf, inter.Cert), store(t, "Mozilla", true, named), now, nil); r.Trusted { + if r := pki.Validate(analyze(t, leaf, inter.Cert), store(t, "Mozilla", true, named), now, nil, nil); r.Trusted { t.Errorf("name constraint ignored: %+v", r) } trLeaf := inter.Leaf(t, pkitest.LeafOpts{DNS: []string{"www.example.com.tr"}}) - if r := pki.Validate(analyze(t, trLeaf, inter.Cert), store(t, "Mozilla", true, named), now, nil); !r.Trusted { + if r := pki.Validate(analyze(t, trLeaf, inter.Cert), store(t, "Mozilla", true, named), now, nil, nil); !r.Trusted { t.Errorf("name inside the constraint: %+v", r) } // Expired: the path holds, only time is wrong. - if r := pki.Validate(analyze(t, leaf, inter.Cert), plain, leaf.NotAfter.Add(time.Hour), nil); r.Trusted || !r.ExpiredOnly { + if r := pki.Validate(analyze(t, leaf, inter.Cert), plain, leaf.NotAfter.Add(time.Hour), nil, nil); r.Trusted || !r.ExpiredOnly { t.Errorf("expired leaf: %+v", r) } // No serverAuth. client := inter.Leaf(t, pkitest.LeafOpts{EKU: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}) - if r := pki.Validate(analyze(t, client, inter.Cert), plain, now, nil); r.Trusted || !strings.Contains(r.Error, "server authentication") { + if r := pki.Validate(analyze(t, client, inter.Cert), plain, now, nil, nil); r.Trusted || !strings.Contains(r.Error, "server authentication") { t.Errorf("clientAuth-only leaf: %+v", r) } } diff --git a/internal/pki/pkitest/pkitest.go b/internal/pki/pkitest/pkitest.go index 6494314..ed16364 100644 --- a/internal/pki/pkitest/pkitest.go +++ b/internal/pki/pkitest/pkitest.go @@ -93,6 +93,36 @@ func (ca *CA) Intermediate(t testing.TB, name string, o CAOpts) *CA { return &CA{Cert: create(t, caTemplate(name, o), ca.Cert, &k.PublicKey, ca.Key), Key: k} } +// IntermediateWithKey issues a subordinate CA for a key the test chose, for +// example a deliberately weak one from SmallScalarKey. +func (ca *CA) IntermediateWithKey(t testing.TB, name string, o CAOpts, k *ecdsa.PrivateKey) *CA { + t.Helper() + return &CA{Cert: create(t, caTemplate(name, o), ca.Cert, &k.PublicKey, ca.Key), Key: k} +} + +// CrossSign issues a second certificate for sub's name and key under ca: the +// same CA reachable through another issuer. +func (ca *CA) CrossSign(t testing.TB, sub *CA, o CAOpts) *x509.Certificate { + t.Helper() + tmpl := caTemplate(sub.Cert.Subject.CommonName, o) + tmpl.Subject = sub.Cert.Subject + return create(t, tmpl, ca.Cert, sub.Cert.PublicKey, ca.Key) +} + +// SmallScalarKey returns the ECDSA key whose private scalar is d on curve: the +// class of key behind the eMudhra compromise (P-384, d < 256), where the +// public key is d·G and anyone can recover d by counting. +func SmallScalarKey(t testing.TB, curve elliptic.Curve, d int64) *ecdsa.PrivateKey { + t.Helper() + b := make([]byte, (curve.Params().BitSize+7)/8) + big.NewInt(d).FillBytes(b) + k, err := ecdsa.ParseRawPrivateKey(curve, b) + if err != nil { + t.Fatalf("pkitest: small scalar %d: %v", d, err) + } + return k +} + // SCTSpec is one SCT to embed in a leaf. type SCTSpec struct { LogID [32]byte diff --git a/internal/pki/trust.go b/internal/pki/trust.go index 5c83348..3a338ef 100644 --- a/internal/pki/trust.go +++ b/internal/pki/trust.go @@ -18,9 +18,13 @@ type StoreResult struct { // anchor. Empty when nothing validated. Path []string `json:"path,omitempty"` // Error says why nothing validated; Distrust says which root-program - // constraint stopped an otherwise valid path. + // constraint, or which listed key, stopped a path that exists. Error string `json:"error,omitempty"` Distrust string `json:"distrust,omitempty"` + // Listed are the certificates on Path that the distrust list names. Path is + // then the signature-valid path through them: the evaluation reports it + // rather than losing it. + Listed []ListedOn `json:"listed,omitempty"` // ExpiredOnly is set when the path validates except that a certificate on it // is outside its validity at the evaluation time: the expiry finding covers // it, so "untrusted" is not raised a second time. @@ -32,7 +36,12 @@ type StoreResult struct { // pool), for TLS server authentication at now. sctTimes are the timestamps of // every SCT seen for the leaf (embedded, TLS, OCSP); they decide Chrome's // sct_not_after constraint. -func Validate(s *Served, store *Store, now time.Time, sctTimes []time.Time) StoreResult { +// +// dl is the distrust list (nil for none). It applies to every store, private +// ones included: a path through a listed key is reported as distrusted, with the +// path and the listed certificates, whatever crypto/x509 did with it (Go 1.27.3 +// and later drop such paths silently, older versions accept them). +func Validate(s *Served, store *Store, now time.Time, sctTimes []time.Time, dl *Distrust) StoreResult { res := StoreResult{Store: store.Name, Snapshot: store.Snapshot} inter := x509.NewCertPool() for _, c := range s.Intermediates() { @@ -44,6 +53,16 @@ func Validate(s *Served, store *Store, now time.Time, sctTimes []time.Time) Stor } chains, err := verify(now) if err != nil { + // A path that exists but runs through a listed key: say so and show it, + // instead of the bare error crypto/x509 gives when it drops such a path. + if dl.Len() > 0 { + for _, p := range Paths(s, store) { + if on := dl.ListedOnPath(p); len(on) > 0 { + res.Distrust, res.Listed, res.Path = listedReason(on), on, chainSHAs(p) + return res + } + } + } var inv x509.CertificateInvalidError if errors.As(err, &inv) && inv.Reason == x509.Expired { // Would the path hold while every certificate on it was valid? @@ -60,12 +79,21 @@ func Validate(s *Served, store *Store, now time.Time, sctTimes []time.Time) Stor return res } var reasons []string + var listed []ListedOn + var listedPath []*x509.Certificate for _, ch := range chains { root := ch[len(ch)-1] r, ok := store.Root(Fingerprint(root)) if !ok { continue } + if on := dl.ListedOnPath(ch); len(on) > 0 { + reasons = append(reasons, listedReason(on)) + if listed == nil { + listed, listedPath = on, ch + } + continue + } if why := constraintFailure(r, s.Leaf(), sctTimes); why != "" && store.Constrained { reasons = append(reasons, why) continue @@ -79,9 +107,21 @@ func Validate(s *Served, store *Store, now time.Time, sctTimes []time.Time) Stor } res.Distrust = strings.Join(dedupe(reasons), "; ") res.Path = chainSHAs(chains[0]) + if listed != nil { + res.Path, res.Listed = chainSHAs(listedPath), listed + } return res } +// listedReason says which listed certificates a path goes through. +func listedReason(on []ListedOn) string { + parts := make([]string, 0, len(on)) + for _, l := range on { + parts = append(parts, fmt.Sprintf("%q %s", l.Name, l.Phrase())) + } + return "the path goes through " + strings.Join(parts, " and ") +} + // constraintFailure applies a root program's constraints to a leaf. func constraintFailure(r Root, leaf *x509.Certificate, sctTimes []time.Time) string { if !r.DistrustAfter.IsZero() && leaf.NotBefore.After(r.DistrustAfter) { diff --git a/internal/refdata/ctlogs_gen.go b/internal/refdata/ctlogs_gen.go index cabed48..3c3e451 100644 --- a/internal/refdata/ctlogs_gen.go +++ b/internal/refdata/ctlogs_gen.go @@ -1,77 +1,93 @@ -// Code generated by internal/refdata/gen from Chrome's CT log list v3 (version 93.1, retrieved 2026-10-02). DO NOT EDIT. +// Code generated by internal/refdata/gen from Chrome's CT log list v3 (version 95.9, retrieved 2026-10-23). DO NOT EDIT. package refdata -var ctSource = Source{Name: "CT log list", URL: "https://www.gstatic.com/ct/log_list/v3/log_list.json", Retrieved: "2026-10-02", SHA256: "caa6af5d6f87b8ec447b7150dc6a86216ed986029513eaa74e6ff4a20ab0e4e9", Licence: licenceCTList} +var ctSource = Source{Name: "CT log list", URL: "https://www.gstatic.com/ct/log_list/v3/log_list.json", Retrieved: "2026-10-23", SHA256: "cca934bf9f2eaaf3f038ed0565e6d2a3775e44b200be71d3f437bc889f4f59ac", Licence: licenceCTList} var ctLogs = []CTLog{ + {ID: "+ZgQ1evkfhzxtCH/beNb+MHsluJeBNv8dW9r5FxlXMo=", Name: "HARICA Selene2028h2", Operator: "HARICA", State: "qualified", URL: "https://selene.mon.ct.harica.eu/selene2028h2/"}, + {ID: "/Cvx3M57P/mNzO4pdqi0IuiOCa6pt5mhesjSWzqR+MA=", Name: "Sectigo Statue2028h1", Operator: "Sectigo", State: "qualified", URL: "https://statue2028h1.mon.ct.sectigo.com/"}, {ID: "/cLoBbiE8/CzcwbEliJzk9OExUIiB/1Aj+4CBc6jVhY=", Name: "GoDaddy Aquamarine2027h1", Operator: "GoDaddy", State: "qualified", URL: "https://ct-log-api.godaddy.com/aquamarine2027h1/"}, {ID: "/jwyxS3R1tXbsRUdYe6iH5EhBfIZa1UNh/as89aPnFM=", Name: "GoDaddy Aquamarine2028h2", Operator: "GoDaddy", State: "qualified", URL: "https://ct-log-api.godaddy.com/aquamarine2028h2/"}, {ID: "0vxlL6X5tzi4N1X6XrFfC0UlP06Po7m2T9TeVmLRhwg=", Name: "Bogus RFC6962 log to avoid breaking misbehaving CT libraries", Operator: "IPng Networks", State: "retired", URL: "https://ct.example.com/bogus/ipng/"}, - {ID: "1219ENGn9XfCx+lf1wC/+YLJM1pl4dCzAXMXwMjFaXc=", Name: "Google 'Argon2026h2' log", Operator: "Google", State: "usable", URL: "https://ct.googleapis.com/logs/us1/argon2026h2/"}, - {ID: "1d5V7roItgyf/BjFE75qYLoARga8WVuWu0T2LMV9Ofo=", Name: "Geomys 'Tuscolo2027h2'", Operator: "Geomys", State: "usable", URL: "https://tuscolo2027h2.skylight.geomys.org/"}, - {ID: "1tWNqdAXU/NqSqDHV0kCr+vH3CzTjNn3ZMgMiRkenwI=", Name: "Google 'Argon2027h1'", Operator: "Google", State: "usable", URL: "https://ct.googleapis.com/logs/us1/argon2027h1/"}, - {ID: "2AlVO5RPev/IFhlvlE+Fq7D4/F6HVSYPFdEucrtFSxQ=", Name: "Google 'Xenon2026h2' log", Operator: "Google", State: "usable", URL: "https://ct.googleapis.com/logs/eu1/xenon2026h2/"}, + {ID: "1219ENGn9XfCx+lf1wC/+YLJM1pl4dCzAXMXwMjFaXc=", Name: "Google Argon2026h2", Operator: "Google", State: "usable", URL: "https://ct.googleapis.com/logs/us1/argon2026h2/"}, + {ID: "1d5V7roItgyf/BjFE75qYLoARga8WVuWu0T2LMV9Ofo=", Name: "Geomys Tuscolo2027h2", Operator: "Geomys", State: "usable", URL: "https://tuscolo2027h2.skylight.geomys.org/"}, + {ID: "1tWNqdAXU/NqSqDHV0kCr+vH3CzTjNn3ZMgMiRkenwI=", Name: "Google Argon2027h1", Operator: "Google", State: "usable", URL: "https://ct.googleapis.com/logs/us1/argon2027h1/"}, + {ID: "2AlVO5RPev/IFhlvlE+Fq7D4/F6HVSYPFdEucrtFSxQ=", Name: "Google Xenon2026h2", Operator: "Google", State: "usable", URL: "https://ct.googleapis.com/logs/eu1/xenon2026h2/"}, {ID: "2iKjSO8buzFuV+ARSkqcdAe1DxOmJTk9JtLe4joj4hY=", Name: "Microsec Eszigno2028h2", Operator: "Microsec", State: "qualified", URL: "https://eszigno2028h2.mon.ct.e-szigno.hu/"}, - {ID: "5eNiR9ku9K2jhYO1NZHbcp/C8ArktnRRdNPd/GqiU4g=", Name: "Let's Encrypt 'Sycamore2027h2'", Operator: "Let's Encrypt", State: "usable", URL: "https://mon.sycamore.ct.letsencrypt.org/2027h2/"}, + {ID: "5eNiR9ku9K2jhYO1NZHbcp/C8ArktnRRdNPd/GqiU4g=", Name: "Let's Encrypt Sycamore2027h2", Operator: "Let's Encrypt", State: "usable", URL: "https://mon.sycamore.ct.letsencrypt.org/2027h2/"}, {ID: "6+a9C+qJOkTtPzg+2/4QzZnxhphufXsmsBqEMk57934=", Name: "IPng Networks Halloumi2028h1", Operator: "IPng Networks", State: "qualified", URL: "https://halloumi2028h1.mon.ct.ipng.ch/"}, {ID: "6jAGlJ1CunsD2FCB4yYsua5xil5vUDT3ePE4BU6CdFw=", Name: "GoDaddy Aquamarine2026h2", Operator: "GoDaddy", State: "qualified", URL: "https://ct-log-api.godaddy.com/aquamarine2026h2/"}, - {ID: "7drrgVxjITRJtHvlB3kFq9DZMUfCesUUazvFjkPptsc=", Name: "TrustAsia 'HETU2027'", Operator: "TrustAsia", State: "usable", URL: "https://hetu2027.trustasia.com/hetu2027/"}, + {ID: "7drrgVxjITRJtHvlB3kFq9DZMUfCesUUazvFjkPptsc=", Name: "TrustAsia HETU2027", Operator: "TrustAsia", State: "usable", URL: "https://hetu2027.trustasia.com/hetu2027/"}, {ID: "8LDBho1KJqIqj/7ghUOplCvvi7u69tYBqyHdvnFJ/68=", Name: "GoDaddy Aquamarine2027h2", Operator: "GoDaddy", State: "qualified", URL: "https://ct-log-api.godaddy.com/aquamarine2027h2/"}, {ID: "A18eYQnn44YBqbhkHlfl9aePeIcnUy/PQlPx68mqDzk=", Name: "Geomys Tuscolo2028h2", Operator: "Geomys", State: "qualified", URL: "https://tuscolo2028h2.skylight.geomys.org/"}, - {ID: "A4AqwmL24F4D+Lxve5hRMk/Xaj31t1lRdeIi+46b1fY=", Name: "Sectigo 'Tiger2027h2'", Operator: "Sectigo", State: "usable", URL: "https://tiger2027h2.ct.sectigo.com/"}, - {ID: "ABpdGhwtk3W2SFV4+C9xoa5u7zl9KXyK4xV7yt7hoB4=", Name: "DigiCert 'Wyvern2027h1'", Operator: "DigiCert", State: "usable", URL: "https://wyvern.ct.digicert.com/2027h1/"}, + {ID: "A4AqwmL24F4D+Lxve5hRMk/Xaj31t1lRdeIi+46b1fY=", Name: "Sectigo Tiger2027h2", Operator: "Sectigo", State: "usable", URL: "https://tiger2027h2.ct.sectigo.com/"}, + {ID: "ABpdGhwtk3W2SFV4+C9xoa5u7zl9KXyK4xV7yt7hoB4=", Name: "DigiCert Wyvern2027h1", Operator: "DigiCert", State: "usable", URL: "https://wyvern.ct.digicert.com/2027h1/"}, {ID: "ALNWz93lJLRlXUxjZTxSUMQ04goEhRA5uv1kzlyFq2s=", Name: "IPng Networks Halloumi2028h2", Operator: "IPng Networks", State: "qualified", URL: "https://halloumi2028h2.mon.ct.ipng.ch/"}, {ID: "ANXCUqZpzf6O0sgXpi5M/O2SyHSR7pccmv3bUoh4hag=", Name: "IPng Networks Gouda2028h2", Operator: "IPng Networks", State: "qualified", URL: "https://gouda2028h2.mon.ct.ipng.ch/"}, - {ID: "CRV/Yy1Gx/dtlSZUk7wPALOVrF2zorJr+wQ9ukrGOJM=", Name: "IPng Networks 'Halloumi2027h2'", Operator: "IPng Networks", State: "usable", URL: "https://halloumi2027h2.mon.ct.ipng.ch/"}, + {ID: "CRV/Yy1Gx/dtlSZUk7wPALOVrF2zorJr+wQ9ukrGOJM=", Name: "IPng Networks Halloumi2027h2", Operator: "IPng Networks", State: "usable", URL: "https://halloumi2027h2.mon.ct.ipng.ch/"}, {ID: "EgXQJVrBiTyuVBBwPE8Sc/yXAOErWEp/gGrcVVWAwLE=", Name: "IPng Networks Gouda2028h1", Operator: "IPng Networks", State: "qualified", URL: "https://gouda2028h1.mon.ct.ipng.ch/"}, {ID: "F23RQr8KrLyBjUUTu9zDCtteWTJiU5KLmtfLdg/247k=", Name: "Geomys Trastevere2027h1", Operator: "Geomys", State: "qualified", URL: "https://trastevere2027h1.skylight.geomys.org/"}, - {ID: "Gouda43XkdHNBUnttgNV1ga2T60w23H+eI8Px8j7xLE=", Name: "IPng Networks 'Gouda2027h1'", Operator: "IPng Networks", State: "usable", URL: "https://gouda2027h1.mon.ct.ipng.ch/"}, - {ID: "GoudaVNi2GSSp7niI2BuNOzp4xC6NPuTBXhdKc5XV+s=", Name: "IPng Networks 'Gouda2027h2'", Operator: "IPng Networks", State: "usable", URL: "https://gouda2027h2.mon.ct.ipng.ch/"}, - {ID: "Goudaw/+v4G0eTnG0jEKhtbRAtTwRuIYLJ3jX14mJe8=", Name: "IPng Networks 'Gouda2026h2'", Operator: "IPng Networks", State: "usable", URL: "https://gouda2026h2.mon.ct.ipng.ch/"}, + {ID: "G3i5n/pJwlzEUFl1q/10eRTf3fp7/dOdKjW5WQStPog=", Name: "Let's Encrypt Sycamore2028h1", Operator: "Let's Encrypt", State: "qualified", URL: "https://mon.sycamore.ct.letsencrypt.org/2028h1/"}, + {ID: "GZ9TRtShpGoaPdygptJx/Taqms3LslRJmOM+dVHZi8k=", Name: "HARICA Selene2028h1", Operator: "HARICA", State: "qualified", URL: "https://selene.mon.ct.harica.eu/selene2028h1/"}, + {ID: "Gouda43XkdHNBUnttgNV1ga2T60w23H+eI8Px8j7xLE=", Name: "IPng Networks Gouda2027h1", Operator: "IPng Networks", State: "usable", URL: "https://gouda2027h1.mon.ct.ipng.ch/"}, + {ID: "GoudaVNi2GSSp7niI2BuNOzp4xC6NPuTBXhdKc5XV+s=", Name: "IPng Networks Gouda2027h2", Operator: "IPng Networks", State: "usable", URL: "https://gouda2027h2.mon.ct.ipng.ch/"}, + {ID: "Goudaw/+v4G0eTnG0jEKhtbRAtTwRuIYLJ3jX14mJe8=", Name: "IPng Networks Gouda2026h2", Operator: "IPng Networks", State: "usable", URL: "https://gouda2026h2.mon.ct.ipng.ch/"}, {ID: "GrnppF/Ct4QLH+fciv7lRpXau5p8I9NfpTclu4k6md0=", Name: "Microsec Eszigno2027h2", Operator: "Microsec", State: "qualified", URL: "https://eszigno2027h2.mon.ct.e-szigno.hu/"}, - {ID: "H1bRq5RwSkHdP+r99GmTVTAsFDG/5hNGCJ//rnldzC8=", Name: "Sectigo 'Sabre2026h2'", Operator: "Sectigo", State: "readonly", URL: "https://sabre2026h2.ct.sectigo.com/"}, - {ID: "H7D4qS2K3aEhd2wF4qouFbrLxitlOTaVV2qqtS4R0R0=", Name: "DigiCert 'sphinx2027h2'", Operator: "DigiCert", State: "usable", URL: "https://sphinx.ct.digicert.com/2027h2/"}, - {ID: "HIl0B+YBCgEpO7Z439ejaM6xjMpSHcyk04bkoy5bXQQ=", Name: "Google 'ParcelYard2027h1' log", Operator: "Google", State: "usable", URL: "https://storage.googleapis.com/parcelyard2027h1.prod.certificate.transparency.goog/"}, - {ID: "HJ9oLOn68EVpUPgbloqH3dsyENhM5siy44JSSsTPWZ8=", Name: "Sectigo 'Tiger2027h1'", Operator: "Sectigo", State: "usable", URL: "https://tiger2027h1.ct.sectigo.com/"}, - {ID: "IPBfqqOkkvxqwUjMM6ICNhhrSfqpfmuzkk+yXsNrHDU=", Name: "Google 'PlumbersArms2026h2' log", Operator: "Google", State: "usable", URL: "https://storage.googleapis.com/plumbersarms2026h2.prod.certificate.transparency.goog/"}, - {ID: "Jbfv3qETAZPtkweXcKoyKiZiDeNayKp8dRl94LGp4GU=", Name: "TrustAsia 'log2026b'", Operator: "TrustAsia", State: "usable", URL: "https://ct2026-b.trustasia.com/log2026b/"}, - {ID: "JuNkblhpISO8ND9HJDWbN5LNJFqI2BXTkzP9mRirRyM=", Name: "IPng Networks 'Halloumi2026h2a'", Operator: "IPng Networks", State: "usable", URL: "https://halloumi2026h2a.mon.ct.ipng.ch/"}, + {ID: "H1bRq5RwSkHdP+r99GmTVTAsFDG/5hNGCJ//rnldzC8=", Name: "Sectigo Sabre2026h2", Operator: "Sectigo", State: "readonly", URL: "https://sabre2026h2.ct.sectigo.com/"}, + {ID: "H7D4qS2K3aEhd2wF4qouFbrLxitlOTaVV2qqtS4R0R0=", Name: "DigiCert Sphinx2027h2", Operator: "DigiCert", State: "usable", URL: "https://sphinx.ct.digicert.com/2027h2/"}, + {ID: "HIl0B+YBCgEpO7Z439ejaM6xjMpSHcyk04bkoy5bXQQ=", Name: "Google ParcelYard2027h1", Operator: "Google", State: "usable", URL: "https://storage.googleapis.com/parcelyard2027h1.prod.certificate.transparency.goog/"}, + {ID: "HJ9oLOn68EVpUPgbloqH3dsyENhM5siy44JSSsTPWZ8=", Name: "Sectigo Tiger2027h1", Operator: "Sectigo", State: "usable", URL: "https://tiger2027h1.ct.sectigo.com/"}, + {ID: "IPBfqqOkkvxqwUjMM6ICNhhrSfqpfmuzkk+yXsNrHDU=", Name: "Google PlumbersArms2026h2", Operator: "Google", State: "usable", URL: "https://storage.googleapis.com/plumbersarms2026h2.prod.certificate.transparency.goog/"}, + {ID: "Jbfv3qETAZPtkweXcKoyKiZiDeNayKp8dRl94LGp4GU=", Name: "TrustAsia log2026b", Operator: "TrustAsia", State: "usable", URL: "https://ct2026-b.trustasia.com/log2026b/"}, + {ID: "JuNkblhpISO8ND9HJDWbN5LNJFqI2BXTkzP9mRirRyM=", Name: "IPng Networks Halloumi2026h2a", Operator: "IPng Networks", State: "usable", URL: "https://halloumi2026h2a.mon.ct.ipng.ch/"}, {ID: "KNPGjqGDY293g5DLHjiKzpOgTpFpnQdXNunGFtEf4Gc=", Name: "Microsec Eszigno2026h2", Operator: "Microsec", State: "qualified", URL: "https://eszigno2026h2.mon.ct.e-szigno.hu/"}, {ID: "LtakTeuPDIZGZ3acTt0EH4QjZ1X6OqymNNCTXfzVmnA=", Name: "Bogus placeholder log to unbreak misbehaving CT libraries", Operator: "Geomys", State: "retired", URL: "https://ct.example.com/bogus/"}, - {ID: "M3qex8rmqQSLdS8tk65+SyqQLSG0lxKdg+J5x1PJL4w=", Name: "Google 'PlumbersArms2027h2' log", Operator: "Google", State: "usable", URL: "https://storage.googleapis.com/plumbersarms2027h2.prod.certificate.transparency.goog/"}, - {ID: "N6oHzCFvLm2RnHCdJNj3MbAPKxR8YhzAkaX6GoTYFt0=", Name: "DigiCert 'Wyvern2027h2'", Operator: "DigiCert", State: "usable", URL: "https://wyvern.ct.digicert.com/2027h2/"}, + {ID: "M3qex8rmqQSLdS8tk65+SyqQLSG0lxKdg+J5x1PJL4w=", Name: "Google PlumbersArms2027h2", Operator: "Google", State: "usable", URL: "https://storage.googleapis.com/plumbersarms2027h2.prod.certificate.transparency.goog/"}, + {ID: "Mk28xZLuH+bg6aYx4WURJaunVGze9l0TxxGtWhs4aqk=", Name: "Sectigo Monument2028h1", Operator: "Sectigo", State: "qualified", URL: "https://monument2028h1.mon.ct.sectigo.com/"}, + {ID: "N6oHzCFvLm2RnHCdJNj3MbAPKxR8YhzAkaX6GoTYFt0=", Name: "DigiCert Wyvern2027h2", Operator: "DigiCert", State: "usable", URL: "https://wyvern.ct.digicert.com/2027h2/"}, {ID: "Nh2PNG9+2gYj4hlfNqqVGKiHKCEzvpzppruUJGPrTaY=", Name: "GoDaddy Aquamarine2028h1", Operator: "GoDaddy", State: "qualified", URL: "https://ct-log-api.godaddy.com/aquamarine2028h1/"}, - {ID: "RMK9DOkUDmSlyUoBkwpaobs1lw4A7hEWiWgqHETXtWY=", Name: "Google 'Xenon2027h1'", Operator: "Google", State: "usable", URL: "https://ct.googleapis.com/logs/eu1/xenon2027h1/"}, - {ID: "ROgi/CurDpLu0On61pZkYCd20Bdg4IkFCckjobA/w38=", Name: "IPng Networks 'Halloumi2027h1'", Operator: "IPng Networks", State: "usable", URL: "https://halloumi2027h1.mon.ct.ipng.ch/"}, - {ID: "Rq+GPTs+5Z+ld96oJF02sNntIqIj9GF3QSKUUu6VUF8=", Name: "Geomys 'Tuscolo2026h2'", Operator: "Geomys", State: "usable", URL: "https://tuscolo2026h2.skylight.geomys.org/"}, - {ID: "RqI5Z8YNtkaHxm89+ZmUdpOmphEghFfVVefj0KHZtkY=", Name: "DigiCert 'sphinx2027h1'", Operator: "DigiCert", State: "usable", URL: "https://sphinx.ct.digicert.com/2027h1/"}, - {ID: "TGPcmOWcHauI9h6KPd6uj6tEozd7X5uUw/uhnPzBviY=", Name: "Cloudflare 'Nimbus2027'", Operator: "Cloudflare", State: "usable", URL: "https://ct.cloudflare.com/logs/nimbus2027/"}, + {ID: "RMK9DOkUDmSlyUoBkwpaobs1lw4A7hEWiWgqHETXtWY=", Name: "Google Xenon2027h1", Operator: "Google", State: "usable", URL: "https://ct.googleapis.com/logs/eu1/xenon2027h1/"}, + {ID: "ROgi/CurDpLu0On61pZkYCd20Bdg4IkFCckjobA/w38=", Name: "IPng Networks Halloumi2027h1", Operator: "IPng Networks", State: "usable", URL: "https://halloumi2027h1.mon.ct.ipng.ch/"}, + {ID: "Rq+GPTs+5Z+ld96oJF02sNntIqIj9GF3QSKUUu6VUF8=", Name: "Geomys Tuscolo2026h2", Operator: "Geomys", State: "usable", URL: "https://tuscolo2026h2.skylight.geomys.org/"}, + {ID: "RqI5Z8YNtkaHxm89+ZmUdpOmphEghFfVVefj0KHZtkY=", Name: "DigiCert Sphinx2027h1", Operator: "DigiCert", State: "usable", URL: "https://sphinx.ct.digicert.com/2027h1/"}, + {ID: "TGPcmOWcHauI9h6KPd6uj6tEozd7X5uUw/uhnPzBviY=", Name: "Cloudflare Nimbus2027", Operator: "Cloudflare", State: "usable", URL: "https://ct.cloudflare.com/logs/nimbus2027/"}, {ID: "TefD7+U3ZpoMsB44OPqv3PCMmoBWM55wK1iVm9FxiS4=", Name: "Geomys Tuscolo2028h1", Operator: "Geomys", State: "qualified", URL: "https://tuscolo2028h1.skylight.geomys.org/"}, + {ID: "V+RgJ5TwE01vJbkN3nyKr9cuefY8ogzD9dFPs4lFv1U=", Name: "Sectigo Statue2027h2", Operator: "Sectigo", State: "qualified", URL: "https://statue2027h2.mon.ct.sectigo.com/"}, {ID: "VzRIzG4dLA3JS2nyh9Hv5IPHolxQxTILuzrep29usEE=", Name: "TrustAsia Luoshu2027", Operator: "TrustAsia", State: "usable", URL: "https://luoshu2027.trustasia.com/luoshu2027/"}, - {ID: "WW5sM4aUsllyolbIoOjdkEp26Ag92oc7AQg4KBQ87lk=", Name: "Geomys 'Tuscolo2027h1'", Operator: "Geomys", State: "usable", URL: "https://tuscolo2027h1.skylight.geomys.org/"}, - {ID: "X/nA+6QtrHjOZELrGazggaKb1OL7U68MYzB55QWCBMk=", Name: "Google 'PlumbersArms2027h1' log", Operator: "Google", State: "usable", URL: "https://storage.googleapis.com/plumbersarms2027h1.prod.certificate.transparency.goog/"}, - {ID: "YEyar3p/d18B1Ab8kg3ImesLHH34yVIb+voXdzuXi8k=", Name: "Sectigo 'Elephant2027h1'", Operator: "Sectigo", State: "usable", URL: "https://elephant2027h1.ct.sectigo.com/"}, - {ID: "aYS3IzyydLLRRn2l4EDXH+upCiHniGU3Srn5vPhocjc=", Name: "Google 'ParcelYard2027h2' log", Operator: "Google", State: "usable", URL: "https://storage.googleapis.com/parcelyard2027h2.prod.certificate.transparency.goog/"}, - {ID: "bP5QGUOoXqkWvFLRM+TcyR7xQRx9JYQg0XOAnhgY6zo=", Name: "Let's Encrypt 'Sycamore2026h2'", Operator: "Let's Encrypt", State: "usable", URL: "https://mon.sycamore.ct.letsencrypt.org/2026h2/"}, + {ID: "WW5sM4aUsllyolbIoOjdkEp26Ag92oc7AQg4KBQ87lk=", Name: "Geomys Tuscolo2027h1", Operator: "Geomys", State: "usable", URL: "https://tuscolo2027h1.skylight.geomys.org/"}, + {ID: "X/nA+6QtrHjOZELrGazggaKb1OL7U68MYzB55QWCBMk=", Name: "Google PlumbersArms2027h1", Operator: "Google", State: "usable", URL: "https://storage.googleapis.com/plumbersarms2027h1.prod.certificate.transparency.goog/"}, + {ID: "YEyar3p/d18B1Ab8kg3ImesLHH34yVIb+voXdzuXi8k=", Name: "Sectigo Elephant2027h1", Operator: "Sectigo", State: "usable", URL: "https://elephant2027h1.ct.sectigo.com/"}, + {ID: "Zw/v+c0tZeC4UfTSi9vY+BdtNeIL3fhbQhT74s+jXiM=", Name: "Sectigo Statue2028h2", Operator: "Sectigo", State: "qualified", URL: "https://statue2028h2.mon.ct.sectigo.com/"}, + {ID: "aYS3IzyydLLRRn2l4EDXH+upCiHniGU3Srn5vPhocjc=", Name: "Google ParcelYard2027h2", Operator: "Google", State: "usable", URL: "https://storage.googleapis.com/parcelyard2027h2.prod.certificate.transparency.goog/"}, + {ID: "bHsR9YaIq7BLUlT3PyIbVVjgvpNRI3MlcGGfeugsbBA=", Name: "Sectigo Monument2028h2", Operator: "Sectigo", State: "qualified", URL: "https://monument2028h2.mon.ct.sectigo.com/"}, + {ID: "bP5QGUOoXqkWvFLRM+TcyR7xQRx9JYQg0XOAnhgY6zo=", Name: "Let's Encrypt Sycamore2026h2", Operator: "Let's Encrypt", State: "usable", URL: "https://mon.sycamore.ct.letsencrypt.org/2026h2/"}, {ID: "bdyd6YsZgQp1oXcR+tZVoOVbdYzpKkXZg27d9PznIBM=", Name: "Microsec Eszigno2028h1", Operator: "Microsec", State: "qualified", URL: "https://eszigno2028h1.mon.ct.e-szigno.hu/"}, - {ID: "dNudWPfUfp39eHoWKpkcGM9pjafHKZGMmhiwRQ26RLw=", Name: "TrustAsia 'log2026a'", Operator: "TrustAsia", State: "usable", URL: "https://ct2026-a.trustasia.com/log2026a/"}, + {ID: "caQ99zUpUI88YXJqTPeP+cknbMBSwvd3Zql1T9QKkMM=", Name: "Let's Encrypt Willow2028h1", Operator: "Let's Encrypt", State: "qualified", URL: "https://mon.willow.ct.letsencrypt.org/2028h1/"}, + {ID: "d+CXC8fTD+AkXF2y76h1TAjrOpDdwSu11U7iiv4neHA=", Name: "Let's Encrypt Sycamore2028h2", Operator: "Let's Encrypt", State: "qualified", URL: "https://mon.sycamore.ct.letsencrypt.org/2028h2/"}, + {ID: "dNudWPfUfp39eHoWKpkcGM9pjafHKZGMmhiwRQ26RLw=", Name: "TrustAsia log2026a", Operator: "TrustAsia", State: "usable", URL: "https://ct2026-a.trustasia.com/log2026a/"}, {ID: "dZIIKK86Y1XymO0JfjR1dfm5hDwR4TkfV0QpnxyujO4=", Name: "Geomys Trastevere2027h2", Operator: "Geomys", State: "qualified", URL: "https://trastevere2027h2.skylight.geomys.org/"}, {ID: "eBAmYgxXrnCeBgs/iwSfqrh6aXhhDP+YpDWqAgNwe9k=", Name: "Microsec Eszigno2027h1", Operator: "Microsec", State: "qualified", URL: "https://eszigno2027h1.mon.ct.e-szigno.hu/"}, + {ID: "g92PTAqC4jctZFmPZBgRxF27h4JAz96XF7b9cOQAZ4c=", Name: "HARICA Selene2027h1", Operator: "HARICA", State: "qualified", URL: "https://selene.mon.ct.harica.eu/selene2027h1/"}, {ID: "j5ct6c9T2eqhZDHY6vWLWGZ8/QysT5c0VLGZaVHvlYc=", Name: "Geomys Trastevere2028h1", Operator: "Geomys", State: "qualified", URL: "https://trastevere2028h1.skylight.geomys.org/"}, - {ID: "jspHC6zeavOiBrCkeoS3Rv4fxr+VPiXmm07kAkjzxug=", Name: "Let's Encrypt 'Sycamore2027h1'", Operator: "Let's Encrypt", State: "usable", URL: "https://mon.sycamore.ct.letsencrypt.org/2027h1/"}, - {ID: "lE5Dh/rswe+B8xkkJqgYZQHH0184AgE/cmd9VTcuGdg=", Name: "DigiCert 'Sphinx2026h2'", Operator: "DigiCert", State: "usable", URL: "https://sphinx.ct.digicert.com/2026h2/"}, - {ID: "lLHBirDQV8R74KwEDh8svI3DdXJ7yVHyClJhJoY7pzw=", Name: "Sectigo 'Mammoth2026h2'", Operator: "Sectigo", State: "readonly", URL: "https://mammoth2026h2.ct.sectigo.com/"}, - {ID: "okkM3NuOM6QAMhdg1tTVGiA2GR6nfZaL4mqKAPb///c=", Name: "Sectigo 'Elephant2027h2'", Operator: "Sectigo", State: "usable", URL: "https://elephant2027h2.ct.sectigo.com/"}, - {ID: "ooEAGHNOF24dR+CVQPOBulRml81jqENQcW64CU7a8Q0=", Name: "Let's Encrypt 'Willow2027h1'", Operator: "Let's Encrypt", State: "usable", URL: "https://mon.willow.ct.letsencrypt.org/2027h1/"}, - {ID: "ppWirZJtb5lujvxJAUJX2LvwRqfWJYm4jcLXh2x45S8=", Name: "Let's Encrypt 'Willow2027h2'", Operator: "Let's Encrypt", State: "usable", URL: "https://mon.willow.ct.letsencrypt.org/2027h2/"}, - {ID: "qCbL4wrGNRJGUz/gZfFPGdluGQgTxB3ZbXkAsxI8VSc=", Name: "Let's Encrypt 'Willow2026h2'", Operator: "Let's Encrypt", State: "usable", URL: "https://mon.willow.ct.letsencrypt.org/2026h2/"}, - {ID: "r2eIO1ewTt2Pptl+9i6o64EKx3Fg8CReVdYML+eFhzo=", Name: "Sectigo 'Elephant2026h2'", Operator: "Sectigo", State: "usable", URL: "https://elephant2026h2.ct.sectigo.com/"}, - {ID: "rKswcGzr7IQx9BPS9JFfER5CJEOx8qaMTzwrO6ceAsM=", Name: "Let's Encrypt 'Oak2026h2'", Operator: "Let's Encrypt", State: "retired", URL: "https://oak.ct.letsencrypt.org/2026h2/"}, - {ID: "utuIpG+cr6QJDoLlk1bbbni0pT9YLbCBl5UkLym2jJg=", Name: "Google 'ParcelYard2026h2' log", Operator: "Google", State: "usable", URL: "https://storage.googleapis.com/parcelyard2026h2.prod.certificate.transparency.goog/"}, - {ID: "wjF+V0UZo0XufzjespBB68fCIVoiv3/Vta12mtkOUs0=", Name: "DigiCert 'Wyvern2026h2'", Operator: "DigiCert", State: "usable", URL: "https://wyvern.ct.digicert.com/2026h2/"}, + {ID: "jspHC6zeavOiBrCkeoS3Rv4fxr+VPiXmm07kAkjzxug=", Name: "Let's Encrypt Sycamore2027h1", Operator: "Let's Encrypt", State: "usable", URL: "https://mon.sycamore.ct.letsencrypt.org/2027h1/"}, + {ID: "k0C4iKejzC796l4cBupj6U7f6w4VXgIIZFIAYZlMotE=", Name: "Sectigo Statue2027h1", Operator: "Sectigo", State: "qualified", URL: "https://statue2027h1.mon.ct.sectigo.com/"}, + {ID: "lE5Dh/rswe+B8xkkJqgYZQHH0184AgE/cmd9VTcuGdg=", Name: "DigiCert Sphinx2026h2", Operator: "DigiCert", State: "usable", URL: "https://sphinx.ct.digicert.com/2026h2/"}, + {ID: "lLHBirDQV8R74KwEDh8svI3DdXJ7yVHyClJhJoY7pzw=", Name: "Sectigo Mammoth2026h2", Operator: "Sectigo", State: "readonly", URL: "https://mammoth2026h2.ct.sectigo.com/"}, + {ID: "lU+RNjI2rVhXzDYVRyLcQOf5ugowsOdS+/DHSylmVrg=", Name: "Sectigo Monument2027h1", Operator: "Sectigo", State: "qualified", URL: "https://monument2027h1.mon.ct.sectigo.com/"}, + {ID: "okkM3NuOM6QAMhdg1tTVGiA2GR6nfZaL4mqKAPb///c=", Name: "Sectigo Elephant2027h2", Operator: "Sectigo", State: "usable", URL: "https://elephant2027h2.ct.sectigo.com/"}, + {ID: "ooEAGHNOF24dR+CVQPOBulRml81jqENQcW64CU7a8Q0=", Name: "Let's Encrypt Willow2027h1", Operator: "Let's Encrypt", State: "usable", URL: "https://mon.willow.ct.letsencrypt.org/2027h1/"}, + {ID: "ppWirZJtb5lujvxJAUJX2LvwRqfWJYm4jcLXh2x45S8=", Name: "Let's Encrypt Willow2027h2", Operator: "Let's Encrypt", State: "usable", URL: "https://mon.willow.ct.letsencrypt.org/2027h2/"}, + {ID: "qCbL4wrGNRJGUz/gZfFPGdluGQgTxB3ZbXkAsxI8VSc=", Name: "Let's Encrypt Willow2026h2", Operator: "Let's Encrypt", State: "usable", URL: "https://mon.willow.ct.letsencrypt.org/2026h2/"}, + {ID: "r2eIO1ewTt2Pptl+9i6o64EKx3Fg8CReVdYML+eFhzo=", Name: "Sectigo Elephant2026h2", Operator: "Sectigo", State: "usable", URL: "https://elephant2026h2.ct.sectigo.com/"}, + {ID: "rKswcGzr7IQx9BPS9JFfER5CJEOx8qaMTzwrO6ceAsM=", Name: "Let's Encrypt Oak2026h2", Operator: "Let's Encrypt", State: "retired", URL: "https://oak.ct.letsencrypt.org/2026h2/"}, + {ID: "sYdPqlxcQTingZPPo7kk2ImvyDNpOcfp+q0JVhrGpV4=", Name: "HARICA Selene2027h2", Operator: "HARICA", State: "qualified", URL: "https://selene.mon.ct.harica.eu/selene2027h2/"}, + {ID: "tvzC6p+E49qX4wl/sfIW6R/w154KB0QDJbBvlPXmU8U=", Name: "Sectigo Monument2027h2", Operator: "Sectigo", State: "qualified", URL: "https://monument2027h2.mon.ct.sectigo.com/"}, + {ID: "utuIpG+cr6QJDoLlk1bbbni0pT9YLbCBl5UkLym2jJg=", Name: "Google ParcelYard2026h2", Operator: "Google", State: "usable", URL: "https://storage.googleapis.com/parcelyard2026h2.prod.certificate.transparency.goog/"}, + {ID: "vey1qmLph0NZ/U7NvnDZ8G8+j71Fo6xrycKFEbx0wJw=", Name: "Let's Encrypt Willow2028h2", Operator: "Let's Encrypt", State: "qualified", URL: "https://mon.willow.ct.letsencrypt.org/2028h2/"}, + {ID: "wjF+V0UZo0XufzjespBB68fCIVoiv3/Vta12mtkOUs0=", Name: "DigiCert Wyvern2026h2", Operator: "DigiCert", State: "usable", URL: "https://wyvern.ct.digicert.com/2026h2/"}, {ID: "y6VZ4BSDyZ+U7N2er0ezmWM1bZ/1wShe6un50SqgTMc=", Name: "Geomys Trastevere2028h2", Operator: "Geomys", State: "qualified", URL: "https://trastevere2028h2.skylight.geomys.org/"}, - {ID: "yKPEf8ezrbk1awE/anoSbeM6TkOlxkb5l605dZkdz5o=", Name: "Sectigo 'Tiger2026h2'", Operator: "Sectigo", State: "usable", URL: "https://tiger2026h2.ct.sectigo.com/"}, + {ID: "yKPEf8ezrbk1awE/anoSbeM6TkOlxkb5l605dZkdz5o=", Name: "Sectigo Tiger2026h2", Operator: "Sectigo", State: "usable", URL: "https://tiger2026h2.ct.sectigo.com/"}, {ID: "yMaDuhp5vKPNGyZsbZ6MzSf0hFEMerHyV59nUhBYOgM=", Name: "Geomys Trastevere2026h2", Operator: "Geomys", State: "qualified", URL: "https://trastevere2026h2.skylight.geomys.org/"}, - {ID: "yzj3FYl8hKFEX1vB3fvJbvKaWc1HCmkFhbDLFMMUWOc=", Name: "Cloudflare 'Nimbus2026'", Operator: "Cloudflare", State: "usable", URL: "https://ct.cloudflare.com/logs/nimbus2026/"}, + {ID: "yzj3FYl8hKFEX1vB3fvJbvKaWc1HCmkFhbDLFMMUWOc=", Name: "Cloudflare Nimbus2026", Operator: "Cloudflare", State: "usable", URL: "https://ct.cloudflare.com/logs/nimbus2026/"}, } diff --git a/internal/refdata/dhprimes_gen.go b/internal/refdata/dhprimes_gen.go index a7647de..786567a 100644 --- a/internal/refdata/dhprimes_gen.go +++ b/internal/refdata/dhprimes_gen.go @@ -1,8 +1,8 @@ -// Code generated by internal/refdata/gen from the texts of RFC 2409, 3526, 5114 and 7919 (retrieved 2026-10-02). DO NOT EDIT. +// Code generated by internal/refdata/gen from the texts of RFC 2409, 3526, 5114 and 7919 (retrieved 2026-10-23). DO NOT EDIT. package refdata -var dhSource = Source{Name: "Published DH groups", URL: "https://www.rfc-editor.org/rfc/rfc2409.txt https://www.rfc-editor.org/rfc/rfc3526.txt https://www.rfc-editor.org/rfc/rfc5114.txt https://www.rfc-editor.org/rfc/rfc7919.txt", Retrieved: "2026-10-02", SHA256: "rfc2409.txt=5b19aab3886800c3ce7528e0dba4eb05dc6405e03388ed4dd3032fb02f4616d9 rfc3526.txt=6c95b231eb1c2d9ffa480efbe3ae53477231271f3438570a3af370b2aa4a968b rfc5114.txt=7fde6217be0cf9c036e755fda502ef8345dbd7c274e8fa87ca87c69b8e4ce25b rfc7919.txt=e7ffd3d8109670aa48d218964ceccf82cc6d2fd36132403c89589434bf8a4d72", Licence: licenceRFC} +var dhSource = Source{Name: "Published DH groups", URL: "https://www.rfc-editor.org/rfc/rfc2409.txt https://www.rfc-editor.org/rfc/rfc3526.txt https://www.rfc-editor.org/rfc/rfc5114.txt https://www.rfc-editor.org/rfc/rfc7919.txt", Retrieved: "2026-10-23", SHA256: "rfc2409.txt=5b19aab3886800c3ce7528e0dba4eb05dc6405e03388ed4dd3032fb02f4616d9 rfc3526.txt=6c95b231eb1c2d9ffa480efbe3ae53477231271f3438570a3af370b2aa4a968b rfc5114.txt=7fde6217be0cf9c036e755fda502ef8345dbd7c274e8fa87ca87c69b8e4ce25b rfc7919.txt=e7ffd3d8109670aa48d218964ceccf82cc6d2fd36132403c89589434bf8a4d72", Licence: licenceRFC} // dhPrimes is keyed by the SHA-256 of the prime's big-endian bytes, leading zeros trimmed. var dhPrimes = []DHPrime{ diff --git a/internal/refdata/distrust.go b/internal/refdata/distrust.go new file mode 100644 index 0000000..8bac7e3 --- /dev/null +++ b/internal/refdata/distrust.go @@ -0,0 +1,89 @@ +package refdata + +import ( + "crypto/sha256" + _ "embed" // the distrust list + "encoding/hex" + "encoding/json" + "sync" + + "github.com/offsetsecurity/cipherhorizon/internal/pki" +) + +// distrustJSON is the hand-curated list of distrusted keys and certificates. +// Unlike the generated lists it has no single upstream: each incident cites +// the root-program bug and announcements it comes from. +// +//go:embed distrust.json +var distrustJSON []byte + +// DistrustFile is the schema of distrust.json. +type DistrustFile struct { + Version string `json:"version"` // YYYY-MM-DD of the last edit + About string `json:"about"` + Incidents []DistrustIncident `json:"incidents"` +} + +// DistrustIncident is one event that put keys on the list. +type DistrustIncident struct { + ID string `json:"id"` + Title string `json:"title"` + Kind string `json:"kind"` // pki.KindKeyCompromise | pki.KindCADistrust + Since string `json:"since"` + Summary string `json:"summary"` + Sources []string `json:"sources"` + Entries []DistrustEntry `json:"entries"` +} + +// DistrustEntry is one key, with the certificates known to carry it. +type DistrustEntry struct { + Name string `json:"name"` + SPKISHA256 string `json:"spkiSha256,omitempty"` + CertSHA256 []string `json:"certSha256,omitempty"` +} + +const licenceDistrust = "Published identifiers (certificate and key SHA-256) from root-program incident reports; factual values" + +var ( + distrustOnce sync.Once + distrustFile DistrustFile + distrust *pki.Distrust +) + +func loadDistrust() { + distrustOnce.Do(func() { + if err := json.Unmarshal(distrustJSON, &distrustFile); err != nil { + panic("refdata: distrust.json: " + err.Error()) + } + distrust = pki.NewDistrust() + for _, in := range distrustFile.Incidents { + for _, e := range in.Entries { + distrust.Add(e.SPKISHA256, e.CertSHA256, pki.Listed{Name: e.Name, Kind: in.Kind, Incident: in.ID, + Title: in.Title, Since: in.Since, Summary: in.Summary, Sources: in.Sources}) + } + } + }) +} + +// Distrust returns the bundled distrust list. The value is shared and read-only. +func Distrust() *pki.Distrust { + loadDistrust() + return distrust +} + +// DistrustData returns the parsed list, for the reference-data page and tests. +func DistrustData() DistrustFile { + loadDistrust() + return distrustFile +} + +func distrustSource() Source { + loadDistrust() + sum := sha256.Sum256(distrustJSON) + n := 0 + for _, in := range distrustFile.Incidents { + n += len(in.Entries) + } + return Source{Name: "Distrusted CA keys", URL: "internal/refdata/distrust.json (each incident cites its sources)", + Retrieved: distrustFile.Version, SHA256: hex.EncodeToString(sum[:]), Licence: licenceDistrust, Count: n} +} diff --git a/internal/refdata/distrust.json b/internal/refdata/distrust.json new file mode 100644 index 0000000..d12d2ce --- /dev/null +++ b/internal/refdata/distrust.json @@ -0,0 +1,36 @@ +{ + "version": "2026-10-23", + "about": "Keys and certificates no certification path should rely on: CA keys whose private half is public or recoverable, and CA certificates root programs have blocked. Matched by SHA-256 of the SubjectPublicKeyInfo (covers the original, cross-signs and reissues) and of the certificate DER. Every value is a published, factual identifier; each incident cites where it is published. Edited by hand; TestDistrustListIsWellFormed checks it. See docs/reference-data.md.", + "incidents": [ + { + "id": "emudhra-2026-10", + "title": "eMudhra intermediate CA private keys recoverable", + "kind": "key-compromise", + "since": "2026-10-17", + "summary": "Eight eMudhra intermediate CAs (ECDSA P-384, under emSign Root TLS CA - G3 and emSign Root CS CA - G3, cross-signed by Sectigo AAA Certificate Services) had private keys smaller than 256, so anyone can recover them and issue certificates as these CAs. Revoked by eMudhra on 2026-10-18 (cACompromise), cross-signs revoked by Sectigo; blocked by Mozilla, Chrome, Apple, Microsoft and Go 1.27.3 / 1.26.10.", + "sources": [ + "https://bugzilla.mozilla.org/show_bug.cgi?id=2083449", + "https://blog.mozilla.org/security/2026/10/20/protecting-firefox-users-following-the-compromise-of-emudhra-intermediate-certificates/", + "https://emudhra.we-have-a-problem.com/" + ], + "entries": [ + {"name": "EM DV TLS CA - G3B", "spkiSha256": "ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105", + "certSha256": ["42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493", "45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe"]}, + {"name": "EM OV TLS CA - G3B", "spkiSha256": "43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b", + "certSha256": ["98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b", "5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699"]}, + {"name": "EM EV TLS CA - G3B", "spkiSha256": "e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41", + "certSha256": ["e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838", "579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512"]}, + {"name": "EM DV TLS CA - G3C", "spkiSha256": "49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f", + "certSha256": ["0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19", "62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72"]}, + {"name": "EM OV TLS CA - G3C", "spkiSha256": "63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8", + "certSha256": ["cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f", "7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489"]}, + {"name": "EM EV TLS CA - G3C", "spkiSha256": "5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0", + "certSha256": ["432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3", "fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f"]}, + {"name": "EM OV CS CA - G3B", "spkiSha256": "ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6", + "certSha256": ["9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4", "a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8"]}, + {"name": "EM OV CS CA - G3C", "spkiSha256": "92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30", + "certSha256": ["4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1", "84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd"]} + ] + } + ] +} diff --git a/internal/refdata/distrust_test.go b/internal/refdata/distrust_test.go new file mode 100644 index 0000000..92f1c9e --- /dev/null +++ b/internal/refdata/distrust_test.go @@ -0,0 +1,119 @@ +package refdata + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "math/big" + "strings" + "testing" + "time" + + "github.com/offsetsecurity/cipherhorizon/internal/pki" +) + +func isHex64(s string) bool { + if len(s) != 64 { + return false + } + _, err := hex.DecodeString(s) + return err == nil && strings.ToLower(s) == s +} + +// TestDistrustListIsWellFormed: every incident is dated, typed and cited, and +// every value is a lower-case SHA-256 listed once. +func TestDistrustListIsWellFormed(t *testing.T) { + f := DistrustData() + if _, err := time.Parse("2006-01-02", f.Version); err != nil { + t.Errorf("version %q", f.Version) + } + seen := map[string]string{} + ids := map[string]bool{} + for _, in := range f.Incidents { + if in.ID == "" || ids[in.ID] || in.Title == "" || in.Summary == "" || len(in.Entries) == 0 { + t.Errorf("incident %q incomplete or duplicated", in.ID) + } + ids[in.ID] = true + if in.Kind != pki.KindKeyCompromise && in.Kind != pki.KindCADistrust { + t.Errorf("%s: kind %q", in.ID, in.Kind) + } + if _, err := time.Parse("2006-01-02", in.Since); err != nil { + t.Errorf("%s: since %q", in.ID, in.Since) + } + if len(in.Sources) == 0 { + t.Errorf("%s: no sources", in.ID) + } + for _, s := range in.Sources { + if !strings.HasPrefix(s, "https://") { + t.Errorf("%s: source %q", in.ID, s) + } + } + for _, e := range in.Entries { + if e.Name == "" || (e.SPKISHA256 == "" && len(e.CertSHA256) == 0) { + t.Errorf("%s: entry %+v", in.ID, e) + } + vals := append([]string{}, e.CertSHA256...) + if e.SPKISHA256 != "" { + vals = append(vals, e.SPKISHA256) + } + for _, v := range vals { + if !isHex64(v) { + t.Errorf("%s/%s: %q is not a lower-case SHA-256", in.ID, e.Name, v) + } + if prev, dup := seen[v]; dup { + t.Errorf("%s listed twice (%s, %s)", v, prev, e.Name) + } + seen[v] = e.Name + } + } + } + if Distrust().Len() != len(seen) { + t.Errorf("loaded %d values, file has %d", Distrust().Len(), len(seen)) + } +} + +// TestEmudhraKeysAreSmallScalars checks the eMudhra entries cryptographically, +// not by copying: each listed key must be d·G on P-384 for the private value +// published for that CA, and every published value must be listed. +func TestEmudhraKeysAreSmallScalars(t *testing.T) { + want := map[string]int64{ + "EM DV TLS CA - G3B": 156, "EM OV TLS CA - G3B": 155, "EM EV TLS CA - G3B": 115, "EM OV CS CA - G3B": 66, + "EM DV TLS CA - G3C": 230, "EM OV TLS CA - G3C": 4, "EM EV TLS CA - G3C": 56, "EM OV CS CA - G3C": 237, + } + found := 0 + for _, in := range DistrustData().Incidents { + if in.ID != "emudhra-2026-10" { + continue + } + for _, e := range in.Entries { + d, ok := want[e.Name] + if !ok { + t.Errorf("unexpected entry %q", e.Name) + continue + } + b := make([]byte, 48) + big.NewInt(d).FillBytes(b) + k, err := ecdsa.ParseRawPrivateKey(elliptic.P384(), b) + if err != nil { + t.Fatal(err) + } + spki, err := x509.MarshalPKIXPublicKey(&k.PublicKey) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(spki) + if got := hex.EncodeToString(sum[:]); got != e.SPKISHA256 { + t.Errorf("%s: d=%d gives SPKI %s, list says %s", e.Name, d, got, e.SPKISHA256) + } + if len(e.CertSHA256) != 2 { + t.Errorf("%s: want the eMudhra certificate and the Sectigo cross-sign, have %d", e.Name, len(e.CertSHA256)) + } + found++ + } + } + if found != len(want) { + t.Errorf("%d eMudhra entries, want %d", found, len(want)) + } +} diff --git a/internal/refdata/refdata.go b/internal/refdata/refdata.go index 4652a39..329a8e5 100644 --- a/internal/refdata/refdata.go +++ b/internal/refdata/refdata.go @@ -175,5 +175,5 @@ func LookupDHPrime(sha string) (DHPrime, bool) { func Sources() []Source { m, c, d, l, t := mozillaSource, chromeSource, dhSource, ctSource, lifetimeSource m.Count, c.Count, d.Count, l.Count, t.Count = len(mozillaRoots), len(chromeRoots), len(dhPrimes), len(ctLogs), len(lifetimeLimits) - return []Source{m, c, l, d, t} + return []Source{m, c, l, d, t, distrustSource()} } diff --git a/internal/refdata/refdata_test.go b/internal/refdata/refdata_test.go index 1783f6e..813e11e 100644 --- a/internal/refdata/refdata_test.go +++ b/internal/refdata/refdata_test.go @@ -172,7 +172,7 @@ func TestCTLookup(t *testing.T) { func TestSourcesRecorded(t *testing.T) { src := Sources() - if len(src) != 5 { + if len(src) != 6 { t.Fatalf("%d sources", len(src)) } for _, s := range src { diff --git a/internal/refdata/roots_gen.go b/internal/refdata/roots_gen.go index 9c6fa09..a491b6a 100644 --- a/internal/refdata/roots_gen.go +++ b/internal/refdata/roots_gen.go @@ -1,11 +1,11 @@ // Code generated by internal/refdata/gen from the CCADB reports MozillaTLSServerAuthenticationCSV and -// ChromeTLSServerAuthenticationCSV (retrieved 2026-10-02; CDLA-Permissive-2.0, see docs/reference-data.md). DO NOT EDIT. +// ChromeTLSServerAuthenticationCSV (retrieved 2026-10-23; CDLA-Permissive-2.0, see docs/reference-data.md). DO NOT EDIT. package refdata -var mozillaSource = Source{Name: "Mozilla root store", URL: "https://ccadb.my.salesforce-sites.com/ccadb/Report?Name=MozillaTLSServerAuthenticationCSV", Retrieved: "2026-10-02", SHA256: "1f58652b2364555791c9cf567ef4675bb435a996b5e4df69183e7245896fa67a", Licence: licenceCCADB} +var mozillaSource = Source{Name: "Mozilla root store", URL: "https://ccadb.my.salesforce-sites.com/ccadb/Report?Name=MozillaTLSServerAuthenticationCSV", Retrieved: "2026-10-23", SHA256: "0b8024fed910ba07b9a198437d2c98c53ce83f4f88702aaf28dde3068f62c05d", Licence: licenceCCADB} -var chromeSource = Source{Name: "Chrome Root Store", URL: "https://ccadb.my.salesforce-sites.com/ccadb/Report?Name=ChromeTLSServerAuthenticationCSV", Retrieved: "2026-10-02", SHA256: "92a8efdb705a33313393f2515763c58d7319cfe814d5e560f11d0ba7146bc16d", Licence: licenceCCADB} +var chromeSource = Source{Name: "Chrome Root Store", URL: "https://ccadb.my.salesforce-sites.com/ccadb/Report?Name=ChromeTLSServerAuthenticationCSV", Retrieved: "2026-10-23", SHA256: "eaed9c269707d9e0d0bfa493d8dc9bba149d9d1a8effc1658a587bef62b6c50e", Licence: licenceCCADB} // rootDER is every root's DER, base64, by SHA-256. Each store refers to it. var rootDER = map[string]string{ @@ -14,7 +14,6 @@ var rootDER = map[string]string{ "06c08d7dafd876971eb1124fe67f847ec0c7a158d3ea53cbe940e2ea9791f4c3": "MIIFgDCCA2igAwIBAgIUHBjYz+VTPyI1RlNUJDxsR9FcSpwwDQYJKoZIhvcNAQEMBQAwWDELMAkGA1UEBhMCQ04xJTAjBgNVBAoTHFRydXN0QXNpYSBUZWNobm9sb2dpZXMsIEluYy4xIjAgBgNVBAMTGVRydXN0QXNpYSBUTFMgUlNBIFJvb3QgQ0EwHhcNMjQwNTE1MDU0MTU3WhcNNDQwNTE1MDU0MTU2WjBYMQswCQYDVQQGEwJDTjElMCMGA1UEChMcVHJ1c3RBc2lhIFRlY2hub2xvZ2llcywgSW5jLjEiMCAGA1UEAxMZVHJ1c3RBc2lhIFRMUyBSU0EgUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMMWuBtqpERz5dZO9LnPWwvB0ZqB9WOwj0PBuwhaGnrhB3YmH49pVr7+NmDQDIPNlOrnxS1cLwUWAp4KqC/lYCZUlviYQB2srp10Zy9U+5RjmOMmSoPGlbYJQ1DNDX3eRA5gEk9bNb2/mThtfWza4mhzH/kxpRkQcwUqwzIZheo0qt1CHjCNP561HmHVb70AcnKtEj+qpklz8oYVlQwQX1Fkzv93uMltrOXVmPGZLmzjyUT5tUMnCE32ft5EebuyjBza00tsLtbDeLdM1aTk2tyKjg7/D8OmYCYozza/+lcK7Fs/6TAWe8TbxNRkoDD75f0dcZLdKY9BWN4ArTr9PXwaqLEX8E40eFgl1oUh63kd0Nyrz2I8sMeXi9bQn9P+PN7F4/w6g3CEIR0JwqH8uyghZVNgepBtljhb//HXeltt08lwSUq6HTrQUNoyIBnkiz/r1RYmNzz7dZ6wB3C4FGB33PYPXFIKvF1tjVEK2sUYyJtt3LCDs3+jTnhMmCWr8n4uIF6CFabW2I+s5c0yhsj55NqJ4js+k8UTav/H9xj8Z7XvGCxUq0DTbE3txci3OE9kxJRMT6DNrqXGJyV1J23G2pyOsAWZ1SgRxSHUuPzHlqtKZFlhaxP8S8ySpg+kUb8OWJDZgoM5pl+z+m6Ss80zDoWo8SnTq1mt1tve1CuBAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFLgHkXlcBvRG/XtZylomkadFK/hTMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQwFAAOCAgEAIZtqBSBdGBanEqT3Rz/NyjuujsCCztxIJXgXbODgcMTWltnZ9r96nBO7U5WS/8+S4PPFJzVXqDuiGev4iqME3mmL5Dw8veWv0BIb5Ylrc5tvJQJLkIKvQMKtuppgJFqBTQUYo+IzeXoLH5Pt7DlK9RME7I10nYEKqG/odv6LTytpEoYKNDbdgptvT+Bz3Ul/KD7JO6NXBNiT2Twp2xIQaOHEibgGIOcberyxk2GaGUARtWqFVwHxtlotJnMnlvm5P1vQiJ3koP26TpUJg3933FEFlJ0gcXax7PqJtZwuhfG5WyRasQmr2soaB82G39tp27RIGAAtvKLEiUUjpQ7hRGU+isFqMB3iYPg6qocJQrmBktwliJiJ8Xw18WLK7nn4GS/+X/jbh87qqA8MpugLoDzga5SYnH+tBuYc6kIQX+ImFTw3OffXvO645e8D7r0i+yiGNFjEWn9hongPXvPKnbwbPKfILfanIhHKA9jnZwqKDss1jjQ52MjqjZ9k4DewbNfFj8GQYSbbJIweSsCI3zWQzj8C9GRh3sfIB5XeMhg6j6JCQCTl1jNdfK7vsU1P1FeQNWrcrgSXSYk0ly4wBOeY99sLAZDBHwo/+ML+TvrbmnNzFrwFuHnYWa8G5z9nODmxfKuU4CkUpijy323imttUQ/hHWKNddBWcwauwxzQ=", "08170d1aa36453901a2f959245e347db0c8d37abaabc56b81aa100dc958970db": "MIIC2zCCAmCgAwIBAgIQXwJB13qHfEwDo6yWjfv/0DAKBggqhkjOPQQDAzBIMQswCQYDVQQGEwJERTEVMBMGA1UEChMMRC1UcnVzdCBHbWJIMSIwIAYDVQQDExlELVRSVVNUIEVWIFJvb3QgQ0EgMSAyMDIwMB4XDTIwMDIxMTEwMDAwMFoXDTM1MDIxMTA5NTk1OVowSDELMAkGA1UEBhMCREUxFTATBgNVBAoTDEQtVHJ1c3QgR21iSDEiMCAGA1UEAxMZRC1UUlVTVCBFViBSb290IENBIDEgMjAyMDB2MBAGByqGSM49AgEGBSuBBAAiA2IABPEL3YZDIBnfl4XoIkqbz52Yv7QFJsnL46bSj8WeeHsxiamJrSc8ZRCC/N/DnU7wMyPE0jL1HLDfMxddxfCxivnvubcUyilKwg+pf3VlSSowZ/Rk99Yad9rDwpdhQntJraOCAQ0wggEJMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFH8QARY3OqQo5FD4pPfsazK2/umLMA4GA1UdDwEB/wQEAwIBBjCBxgYDVR0fBIG+MIG7MD6gPKA6hjhodHRwOi8vY3JsLmQtdHJ1c3QubmV0L2NybC9kLXRydXN0X2V2X3Jvb3RfY2FfMV8yMDIwLmNybDB5oHegdYZzbGRhcDovL2RpcmVjdG9yeS5kLXRydXN0Lm5ldC9DTj1ELVRSVVNUJTIwRVYlMjBSb290JTIwQ0ElMjAxJTIwMjAyMCxPPUQtVHJ1c3QlMjBHbWJILEM9REU/Y2VydGlmaWNhdGVyZXZvY2F0aW9ubGlzdDAKBggqhkjOPQQDAwNpADBmAjEAyjzGKnXCXnViOTYAYFqLwZOZzNnbQTs7h5kXO9XMT8oi96CAy/m0sRtW9XLS/BnRAjEAkfcwkz8QRitxpNA7RJvAKQIFskF3UfN5Wp6OFKBOQtJbgfM0agPnIjhQW+0ZT0MW", "098e08a91dbbf77478b96cceb89b1413a5da37b7c862606a955deb07179f4326": "MIICMjCCAbegAwIBAgIPAYvSIlRjTQSLbOVHH9K1MAoGCCqGSM49BAMDMEoxCzAJBgNVBAYTAlNFMRkwFwYDVQQKDBBUZWxpYSBDb21wYW55IEFCMSAwHgYDVQQDDBdUZWxpYSBFQyBUTFMgUm9vdCBDQSB2MzAeFw0yMzExMTUwODU1MjZaFw00ODA1MjMxMTAwMDBaMEoxCzAJBgNVBAYTAlNFMRkwFwYDVQQKDBBUZWxpYSBDb21wYW55IEFCMSAwHgYDVQQDDBdUZWxpYSBFQyBUTFMgUm9vdCBDQSB2MzB2MBAGByqGSM49AgEGBSuBBAAiA2IABMHIlhVDLbmFKUpW0iK4dpryT6emYOeS31JPwWnWPmkWRrAkTbPX40sQfHI9mpR7Rbktu3ngg6W+BBSXSechtMCnBmWXj/EaVlmV5cY1jD2HoTfhBQ3AacpCNMLJK4NpZaNjMGEwHwYDVR0jBBgwFoAU1GToQ4g6cy/QGnGCNgtehd7H3kMwHQYDVR0OBBYEFNRk6EOIOnMv0BpxgjYLXoXex95DMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMDA2kAMGYCMQCXAUdS/9bbJ8A1JYaGf/bWt/s7Ta0ot5Ulno8OjSNYRWQIlS4tVWldvTAVA7heOFgCMQCvKr8+Z2Rn+OBr5UHzlgBObpad1LuwNTRcdNgUJxIWadcki+UBLEi1/AURKV5md2M=", - "125609aa301da0a249b97a8239cb6a34216f44dcac9f3954b14292f2e8c8608f": "MIIDczCCAlugAwIBAgILAK7PALrEzzL4Q7IwDQYJKoZIhvcNAQELBQAwVjELMAkGA1UEBhMCVVMxEzARBgNVBAsTCmVtU2lnbiBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMRwwGgYDVQQDExNlbVNpZ24gUm9vdCBDQSAtIEMxMB4XDTE4MDIxODE4MzAwMFoXDTQzMDIxODE4MzAwMFowVjELMAkGA1UEBhMCVVMxEzARBgNVBAsTCmVtU2lnbiBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMRwwGgYDVQQDExNlbVNpZ24gUm9vdCBDQSAtIEMxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz+upufGZBczYKCFK83M0UYRWEPWgTywS4/oTmifQz/l5GnRfHXk5/Fv4cI7gklL35CX5VIPZHdPIWoU/Xse2B+4+wM6ar6xWQio5JXDWv7V7Nq2s9nPczdcdioOl+yuQFTdrHCZH3DspVpNqs8FqOp099cGXOFgFixwR4+S0uF2FHYP+eF8LRWgYSKVGczQ7/g/IdrvHGPMF0Ybzhe3nudkyrVWIzqa2kbBPrH4VI5b2P/AgNBbeCsbEBEV5f6f9vtKppa+cxSMq9zwhbL2vj07FOrLzNBL834AaSaTUqZX3noleoomslMuoaJuvimUnzYnu3Yy1aylwQ6BpC+S5DwIDAQABo0IwQDAdBgNVHQ4EFgQU/qHgcB4qAzlSWkK+XJGFehiqTbUwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAMJKVvoVIXsoounlHfv4LcQ5lkFMOycsxGwYFYDGrK9HWS8mC+M2sO87/kOXSTKZEhVb3xEp/6tT+LvBeA+snFOvV71ojD1pM/CjoCNjO2RnIkSt1XHLVip4kqNPEjE2NuLe/gDEo2APJ62gsIq1NnpSob0n9CAnYuhNlCQT5AoE6TyrLshDCUrGYQTlSTR+08TI9Q/Aqum6VF7zYytPT1DU/rl7mYw9wC68AivTxEDkigcxHpvOJpkT+xHqmiIMERnHXhuBUDDIlhJu58tBf5E7oke3VIAb3ADMmpDqw8NQBmIMMMAVSKeoWXzhriKi4gp6D/piq1JM4fHfyr6DDUI=", "1435f225c5d252d7a21948cc3ce62aecfa88001e3dd72d1cc3555100eb372f93": "MIIFmjCCA4KgAwIBAgIJAO6JNNDLgOCyMA0GCSqGSIb3DQEBDAUAMFoxCzAJBgNVBAYTAkpQMSYwJAYDVQQKEx1TRUNPTSBUcnVzdCBTeXN0ZW1zIENvLiwgTHRkLjEjMCEGA1UEAxMaU0VDT00gVExTIFJTQSBSb290IENBIDIwMjQwHhcNMjQwMTMxMDUxMTU1WhcNNDkwMTE0MDUxMTU1WjBaMQswCQYDVQQGEwJKUDEmMCQGA1UEChMdU0VDT00gVHJ1c3QgU3lzdGVtcyBDby4sIEx0ZC4xIzAhBgNVBAMTGlNFQ09NIFRMUyBSU0EgUm9vdCBDQSAyMDI0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA4TjizUwzxbInq8Tx11gaFYNk5fO+34y7TyM4neh0UgL5JIZbJNLTz2x//L/B71+5m6X6nGIr7d4lFJBGtjO677hXOz93zkcWaUTm3VbOAjBlt4YWxlcccBHXuZ7o3Q+4R+ormrBdHeJ1CTUEG8ttQbKIl3G7OZYbnH8/pP8cjPub/0kDVNuMzp7xsVRROOisQt53fMoJLlYgoebbuMphOqMCtjkJ7R6efEMfLp8UAVi9ZaLRn76ET/CJkk925nduuufC4BatS4mnXFmxN0vUXb0ij9B8O/D8gixQEsVSD4GK8FWRPh3bVd/6bzdkHGJjy21XI0yejVomZUbRrOfNuz0boPGV1pt18fFC39IHQEth3OFqb5NDO3L+A9bNqTgAyUgRmIn4ucgDc/Ri/Km3V51ueZjy1/yk0qwJVadAVVrCt56iNeXOyEvzJADGgDQ8E1Pdaqct8Cynz/47ReQM62vFYO08wcQkrjmX/tesiko1V1yyaf6EfPzUFzmaGy9xvkCwdbm15EdTolOjE0H2Vb5/APDOyCFEokiYGmXTLdAUl0wKZ4IyjkHGzy0jhpaXEXE/GJcEvI6VzEchjaBL03EJ0h9pG4OqeIOycKvAo3A+TbetyfsrgYyHzU0a7/qUjGat1AAq1nVljMpKqpinPTsf/d9H39FTUeJL7TpzzjUCAwEAAaNjMGEwHQYDVR0OBBYEFCzrchKOWHdkNRVWNQFXB6l9DTbmMB8GA1UdIwQYMBaAFCzrchKOWHdkNRVWNQFXB6l9DTbmMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBDAUAA4ICAQAVwsvluSafaez5tFPR/hRTBzRxEyMMQF3XJXCVi3yegZyKoec7hmE6jx2ZM8KgM1kn2yJRwFXHX8zUW9nBLEDWc4wuE8LrlZqhGZM9pJQXGmGzResDJV6JgRBna+j4sA1M7yIdlvL0sAfFXFCTRaWTD4E1V99RLrFzWfTcC+e180hDuNMpqOEo46+lMeW/Wvh7ifOQs+kiK0O2gHxQDNxslSavnCs4V7l8HRDJ2La10o70Bo7VLzf1W8MBvv0VTnxB+NjT5qTAbhGFh9Gvp4BaJpmdUf0C5CEP6dbQlfgxWfzYr69yVT6dPQB+GFEaY03IMY+AcBCs+om1fNxrQXt9zoofMBNFbLhvpNH/JsXWdGUzfNbO12uswTa5wah8LB18FTQN2/zPHYmvBEoLuyUgZ09VNLJo5YA0kXItVYkLjMe2SixzK4scUHv81IK99I91DWx7FwMVKw2xgFp+ZLYB2dnpQQrqwlW64glHUcK2N9BDsnjLSxeZ+UPECh9RxH4WAcKiZW+cqaKMmhP2WBfR4IcR7NOL32ml11ds87hhV1CZWWFCJAcCidYZz6CZa8exzHojP9SB5RH0/v1KdHAisqhSjtJl/UIAHIQ48elOn8wrTdFap4Yb5aHglmMeNx+fAIhDluWVfxTO7H4dTPU+SFVRMLAh+wwKZfqb94nMeQ==", "1793927a0614549789adce2f8f34f7f0b66d0f3ae3a3b84d21ec15dbba4fadc7": "MIICiTCCAg+gAwIBAgIQH0evqmIAcFBUTAGem2OZKjAKBggqhkjOPQQDAzCBhTELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDgwMzA2MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBhTELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQDR3svdcmCFYX7deSRFtSrYpn1PlILBs5BAH+X4QokPB0BBO490o0JlwzgdeT6+3eKKvUDYEs2ixYjFq0JcfRK9ChQtP6IHG4/bC8vCVlbpVsLM5niwz2J+Wos77LTBumjQjBAMB0GA1UdDgQWBBR1cacZSBm8nZ3qQUfflMRId5nTeTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjEA7wNbeqy3eApyt4jf/7VGFAkK+qDmfQjGGoe9GKhzvSbKYAydzpmfz1wPMOG+FDHqAjAU9JM8SaczepBGR7NjfRObTrdvGDeAU/7dIOA1mjbRxwG55tzd8/8dLDoWV9mSOdY=", "179fbc148a3dd00fd24ea13458cc43bfa7f59c8182d783a513f6ebec100c8924": "MIICHjCCAaSgAwIBAgIRYFlJ4CYuu1X5CneKcflK2GwwCgYIKoZIzj0EAwMwUDEkMCIGA1UECxMbR2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI1MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQDEwpHbG9iYWxTaWduMB4XDTEyMTExMzAwMDAwMFoXDTM4MDExOTAzMTQwN1owUDEkMCIGA1UECxMbR2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI1MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQDEwpHbG9iYWxTaWduMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAER0UOlvt9Xb/pOdEh+J8LttV7HpI6SFkc8GIxLcB6KP4ap1yztsyX50XUWPrRd21DosCHZTQKH3rd6zwzocWdTaRvQZU4f8kehOvRnkmSh5SHDDqFSmafnVmTTZdhBoZKo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUPeYpSJvqB8ohREom3m7e0oPQn1kwCgYIKoZIzj0EAwMDaAAwZQIxAOVpEslu28YxuglB4Zf4+/2a4n0Sye18ZNPLBSWLVtmg515dTguDnFt2KaAJJiFqYgIwcdK1j1zqO+F4CYWodZI7yFz9SO8NdCKoCOJuxUnOxwy8p2Fp8fc74SrL+SvzZpA3", @@ -96,7 +95,6 @@ var rootDER = map[string]string{ "b4585f22e4ac756a4e8612a1361c5d9d031a93fd84febb778fa3068b0fc42dc2": "MIIB9zCCAX2gAwIBAgIQBiUzsUcDMydc+Y2aub/M+DAKBggqhkjOPQQDAzA9MQswCQYDVQQGEwJVUzESMBAGA1UEChMJQ2VydGFpbmx5MRowGAYDVQQDExFDZXJ0YWlubHkgUm9vdCBFMTAeFw0yMTA0MDEwMDAwMDBaFw00NjA0MDEwMDAwMDBaMD0xCzAJBgNVBAYTAlVTMRIwEAYDVQQKEwlDZXJ0YWlubHkxGjAYBgNVBAMTEUNlcnRhaW5seSBSb290IEUxMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE3m/4fxzf7flHh4axpMCK+IKXgOqPyEpeKn2IaKcBYhSRJHpcnqMXfYqGITQYUBsQ3tA3SybHGWCA6TS9YBk2QNYphwk8kXr2vBMj3VlOBF7PyAIcGFPBMdjaIOlEjeR2o0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU8ygYy2R17ikq6+2uI1g4hevIIgcwCgYIKoZIzj0EAwMDaAAwZQIxALGOWiDDshliTd6wT99u0nCK8Z9+aozmut6Dacpps6kFtZaSF4fC0urQe87YQVt8rgIwRt7qy12a7DLCZRawTDBcMPPaTnOGBtjOiQRINzf43TNRnXCve1XYAS59BWQOhriR", "b49141502d00663d740f2e7ec340c52800962666121a36d09cf7dd2b90384fb4": "MIICzzCCAjGgAwIBAgINAOhvGHvWOWuYSkmYCjAKBggqhkjOPQQDBDB1MQswCQYDVQQGEwJIVTERMA8GA1UEBwwIQnVkYXBlc3QxFjAUBgNVBAoMDU1pY3Jvc2VjIEx0ZC4xFzAVBgNVBGEMDlZBVEhVLTIzNTg0NDk3MSIwIAYDVQQDDBllLVN6aWdubyBUTFMgUm9vdCBDQSAyMDIzMB4XDTIzMDcxNzE0MDAwMFoXDTM4MDcxNzE0MDAwMFowdTELMAkGA1UEBhMCSFUxETAPBgNVBAcMCEJ1ZGFwZXN0MRYwFAYDVQQKDA1NaWNyb3NlYyBMdGQuMRcwFQYDVQRhDA5WQVRIVS0yMzU4NDQ5NzEiMCAGA1UEAwwZZS1Temlnbm8gVExTIFJvb3QgQ0EgMjAyMzCBmzAQBgcqhkjOPQIBBgUrgQQAIwOBhgAEAGgP36J8PKp0iGEKjcJMpQEiFNT3YHdCnAo4YKGMZz6zY+n6kbCLS+Y53wLCMAFSAL/fjO1ZrTJlqwlZULUZwmgcAOAFX9pQJhzDrAQixTpN7+lXWDajwRlTEArRzT/vSzUaQ49CE0y5LBqcvjC2xN7cS53kpDzLLtmt3999Cd8ukv+ho2MwYTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUWYQCYlpGePVd3I8KECgj3NXW+0UwHwYDVR0jBBgwFoAUWYQCYlpGePVd3I8KECgj3NXW+0UwCgYIKoZIzj0EAwQDgYsAMIGHAkIBLdqu9S54tma4n7Zwf2Z0z+yOfP7AAXmazlIC58PRDHpty7Ve7hekm9sEdu4pKeiv+62sUvTXK9Z3hBC9xdIoaDQCQTV2WnXzkoYI9bIeCvZlC9p2x1L/Cx6AcCIwwzPbGO2E14vs7dOoY4G1VnxHx1YwlGhza9IuqbnZLBwpvQy6uWWL", "b676f2eddae8775cd36cb0f63cd1d4603961f49e6265ba013a2f0307b6d0b804": "MIIF0jCCA7qgAwIBAgIQIdbQSk8lD8kyN/yqXhKN6TANBgkqhkiG9w0BAQ0FADCBgDELMAkGA1UEBhMCUEwxIjAgBgNVBAoTGVVuaXpldG8gVGVjaG5vbG9naWVzIFMuQS4xJzAlBgNVBAsTHkNlcnR1bSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEkMCIGA1UEAxMbQ2VydHVtIFRydXN0ZWQgTmV0d29yayBDQSAyMCIYDzIwMTExMDA2MDgzOTU2WhgPMjA0NjEwMDYwODM5NTZaMIGAMQswCQYDVQQGEwJQTDEiMCAGA1UEChMZVW5pemV0byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MSQwIgYDVQQDExtDZXJ0dW0gVHJ1c3RlZCBOZXR3b3JrIENBIDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC9+Xj45tWADGSdhhuWZGc/IjoedQF97/tcZ4zJzFxrqZHmuULlIEub2pt7uZld2ZuAS9eEQCsn0+i6MLs+CRqnSZXvK0AkwpfHp+6bJe+oCgCXhVqqndwpyeI1B+twTUrWwbNWuKFBOJvR+zF/j+Bf4bE/D44WSWDXBo0Y+aomEKsq09DRZ40bRr5HMNUuctHFY9rnY3lEfktjJImGLjQ/KUxSiyqnwOKRKIm5wFv5HdnnJ63/mgKXwcZQkpsCLL2puTRZCr+ESv/f/rOf69me4Jgj7KZrdxYq28ytOxykh9xGc14ZYmhFV+SQgkK7QtbwYeDBoz1mo130GO6IyY0XRSmZMnUCMe4pJshrAua1YkV/NxVaI2iJ1D7eTiew8EAMvE0Xy02isx7QBlrd9pPPV3WZ9fqGGmd4s7+W/jTcvedSVuWz5XV710GRBdxdaeOVDUO5/IOWOZV7bIBaTxNyxtd9KXpEulKkKtVBRgkg/iKgtlswjbyJDNXXcPiHUv3a76xRLgezTv7QCdpw75j6VuZt27VXS9zlLCUVyJ4ueE742pyehizKV/Ma5ciSixqClnrDvFASadgOWkaLOusm+iPJtrCBvkIApPjW/jAux9JG9uWOdf3yzLnQh1vMBhBgu4M1t15n3kfsmUjxpKEV/q2MYo45VU85FrmxY53/twIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBS2oVQ5AsOgP46KvPrU+Bym0ToO/TAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQENBQADggIBAHGlDs7k6b8/ONWJWsQCYftMxRQXLYtPU2sQF/xlhMcQSZDe28cmk4gmb3DWAl45oPePq5a1pRNcgRRtDoGCERuKTsZPpd1iHkTfCVn0W3cLN+mLIMb4Ck4uWBzrM9DPhmDJ2vuAL55MYIR4PSFk1vtBHxgP58l1cb29XN40hz5BsA72udY/CROWFC/emh1auVbONTqwX3BNXuMp8SMoclm2q8KMZiYcdywmdjWLKKdpoPk79SPdhRB0yZADVpHnr7pH1BKXESLjokmUbOe3lEu6LaTaM4tMpkT/WjzGHWTYtTHkpjx6qFcL2+1hGsvxznN3Y6SHb0xRONbkX8eftoEq5IVIeVheO/jbAoJnwTnbw3RLPTYe+SmTiGhbqEQZIfCn6IENLOiTNrQ3ssqwGyZ6miUfmpqAnksqP/ujmv5zMnHCnsZy4YpoJ/HkD7TETKVhk/iXEAcqMCWpuchxuO9ozC1+9eB+D4Kob7a6bINDd82Kkhehnlt4Fj1F4jNy3eFmypnTycUm/Q1oBEauttmbjL4ZvrHG8hnjXALKLNhvSgfZyTXaQHXyxKcZb55CEJh15pWLYLztxRLXis7VmFxWlgPF7ncGNf/P5O4/E2Hu29othfDNrp2yGAlFw5Khchf8R7agCyzxxN5DaAhqXzvwdmP7zAYspsbiDrW5viSP", - "bc4d809b15189d78db3e1d8cf4f9726a795da1643ca5f1358e1ddb0edc0d7eb3": "MIICKzCCAbGgAwIBAgIKe3G2gla4EnycqDAKBggqhkjOPQQDAzBaMQswCQYDVQQGEwJVUzETMBEGA1UECxMKZW1TaWduIFBLSTEUMBIGA1UEChMLZU11ZGhyYSBJbmMxIDAeBgNVBAMTF2VtU2lnbiBFQ0MgUm9vdCBDQSAtIEMzMB4XDTE4MDIxODE4MzAwMFoXDTQzMDIxODE4MzAwMFowWjELMAkGA1UEBhMCVVMxEzARBgNVBAsTCmVtU2lnbiBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMSAwHgYDVQQDExdlbVNpZ24gRUNDIFJvb3QgQ0EgLSBDMzB2MBAGByqGSM49AgEGBSuBBAAiA2IABP2lYa57JhAd6bciMK4G9IGzsUJxlTm801Ljr6/58pc1kjZGDoeVjbk5Wum739D+yAdBPLtVb4OjavtisIGJAnB9SMVK4+kiVCJNk7tCDK93nCOmfddhEc5lx/h//vXyqaNCMEAwHQYDVR0OBBYEFPtaSNCAIEDyqOkAB2kZd6fmw/TPMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMDA2gAMGUCMQC02C8Cif22TGK6Q04ThHK1rt0c3ta13FaPWEBaLd4gTCKDypOofu4SQMfWh0/434UCMBwUZOR8loMRnLDRWmFLpg9J0wD8ofzkpf9/rdcw0Md3f76BB1UwUCAU9Vc4CqgxUQ==", "be4b56cb5056c0136a526df444508daa36a0b54f42e4ac38f72af470e479654c": "MIICVTCCAdygAwIBAgIUTyNkuI6XY57GU4HBdk7LKnQV1tcwCgYIKoZIzj0EAwMwWjELMAkGA1UEBhMCQ04xJTAjBgNVBAoMHFRydXN0QXNpYSBUZWNobm9sb2dpZXMsIEluYy4xJDAiBgNVBAMMG1RydXN0QXNpYSBHbG9iYWwgUm9vdCBDQSBHNDAeFw0yMTA1MjAwMjEwMjJaFw00NjA1MTkwMjEwMjJaMFoxCzAJBgNVBAYTAkNOMSUwIwYDVQQKDBxUcnVzdEFzaWEgVGVjaG5vbG9naWVzLCBJbmMuMSQwIgYDVQQDDBtUcnVzdEFzaWEgR2xvYmFsIFJvb3QgQ0EgRzQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATxs8045CVD5d4ZCbuBeaIVXxVjAd7Cq92zphtnS4CDr5nLrBfbK5bKfFJV4hrhPVbwLxYI+hW8m7tH5j/uqOFMjPXTNvk4XatwmkcN4oFBButJ+bAp3TPsUKV/eSm4IJijYzBhMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUpbtKl86zK3+kMd6Xg1mDpm9xy94wHQYDVR0OBBYEFKW7SpfOsyt/pDHel4NZg6ZvccveMA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNnADBkAjBe8usGzEkxn0AAbbd+NvBNEU/zy4k6LHiRUKNbwMp1JvK/kF0LgoxgKJ/GcJpo5PECMFxYDlZ2z1jD1xCMuo6u47xkdUfFVZDj/bpV6wfEU6s3qe4hsiFbYI89MvHVI5TWWA==", "beb00b30839b9bc32c32e4447905950641f26421b15ed089198b518ae2ea1b99": "MIICQDCCAeWgAwIBAgIMAVRI7yH9l1kN9QQKMAoGCCqGSM49BAMCMHExCzAJBgNVBAYTAkhVMREwDwYDVQQHDAhCdWRhcGVzdDEWMBQGA1UECgwNTWljcm9zZWMgTHRkLjEXMBUGA1UEYQwOVkFUSFUtMjM1ODQ0OTcxHjAcBgNVBAMMFWUtU3ppZ25vIFJvb3QgQ0EgMjAxNzAeFw0xNzA4MjIxMjA3MDZaFw00MjA4MjIxMjA3MDZaMHExCzAJBgNVBAYTAkhVMREwDwYDVQQHDAhCdWRhcGVzdDEWMBQGA1UECgwNTWljcm9zZWMgTHRkLjEXMBUGA1UEYQwOVkFUSFUtMjM1ODQ0OTcxHjAcBgNVBAMMFWUtU3ppZ25vIFJvb3QgQ0EgMjAxNzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJbcPYrYsHtvxie+RJCxs1YVe45DJH0ahFnuY2iyxl6H0BVIHqiQrb1TotreOpCmYF9oMrWGQd+HWyx7xf58etqjYzBhMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBSHERUI0arBeAyxr87GyZDvvzAEwDAfBgNVHSMEGDAWgBSHERUI0arBeAyxr87GyZDvvzAEwDAKBggqhkjOPQQDAgNJADBGAiEAtVfd14pVCzbhhkT61NlojbjcI4qKDdQvfepz7L9NbKgCIQDLpbQS+ue16M9+k/zzNY9vTlp8tLxOsvxyqltZ+efcMQ==", "bfd88fe1101c41ae3e801bf8be56350ee9bad1a6b9bd515edc5c6d5b8711ac44": "MIIDezCCAmOgAwIBAgIBATANBgkqhkiG9w0BAQUFADBfMQswCQYDVQQGEwJUVzESMBAGA1UECgwJVEFJV0FOLUNBMRAwDgYDVQQLDAdSb290IENBMSowKAYDVQQDDCFUV0NBIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDgwODI4MDcyNDMzWhcNMzAxMjMxMTU1OTU5WjBfMQswCQYDVQQGEwJUVzESMBAGA1UECgwJVEFJV0FOLUNBMRAwDgYDVQQLDAdSb290IENBMSowKAYDVQQDDCFUV0NBIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCwfnK4pAOU5qfeCTiRShFAh6d8WWQUe7UREN3+v9XAu1bihSX0NXIP+FPQQeFEAcK0HMMxQhZHhTMidrIKbw/lJVBPhYa+v5guEGcevhEFhgWQxFnQfHgQsIBct+HHK3XLfJ+utdGdIzdjp9xCoi2SBBtQwXu4PhvJVgSLL1KbralW6cH/ralYhzC2gfeXRfwZVzsrb+RH9JlF/h3x+JejiB03HFyP4HYlmlD4oFT/RJB2I9IyxsOrBr/8+7/zrX2SYgJbKdM1o5OaQ2RgXbL6Mv87BK9NQGr5x+PvI/1ry+UPizgN7gr8/g+YnzAx3WxSZfmLgb4i4RxYA7qRG4kHAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRqOFsmjd6LWvJPelSDGRjjCDWmujANBgkqhkiG9w0BAQUFAAOCAQEAPNV3PdrfibqHDAhUaiBQkr6wQT25JmSDCi/oQMCXKCeCMErJk/9q56YAf4lCmtYR5VPOL8zy2gXE/uJQxDqGfczafhAJO5I1KlOy/usrBdlsXebQ79NqZp4VKIV66IIArB6nCWlWQtNoURi+VJq/REG6Sb4gumlc7rh3zc5sH62Dlhh9DrUUOYTxKOkto557HnpyWoOzeW/vtPzQCqVYT0bf+215WfKEIlKuD8z7fDvnaspHYcN6+NOSBB+4IIThNlQWx0DeO4pz3N/GCUzf7Nr/1FNCocnyYh0igzyXxfkZYiesZSLX0zzG5Y6yU8xJzrww/nsOM5D77dIUkR8Hrw==", @@ -135,23 +133,23 @@ var rootDER = map[string]string{ var mozillaRoots = []rootEntry{ {SHA256: "55926084ec963a64b96e2abe01ce0ba86a64fbfebcc7aab5afc155b37fd76066", Name: "Actalis Authentication Root CA", Owner: "Actalis"}, {SHA256: "2e44102ab58cb85419451c8e19d9acf3662cafbc614b6a53960a30f7d0e2eb41", Name: "TunTrust Root CA", Owner: "Agence Nationale de Certification Electronique"}, - {SHA256: "e35d28419ed02025cfa69038cd623962458da5c695fbdea3c22b0bfb25897092", Name: "Amazon Root CA 4", Owner: "Amazon Trust Services"}, - {SHA256: "18ce6cfe7bf14e60b2e347b8dfe868cb31d02ebb3ada271569f50343b46db3a4", Name: "Amazon Root CA 3", Owner: "Amazon Trust Services"}, - {SHA256: "8ecde6884f3d87b1125ba31ac3fcb13d7016de7f57cc904fe1cb97c6ae98196e", Name: "Amazon Root CA 1", Owner: "Amazon Trust Services"}, {SHA256: "568d6905a2c88708a4b3025190edcfedb1974a606a13c6e5290fcb2ae63edab5", Name: "Starfield Services Root Certificate Authority - G2", Owner: "Amazon Trust Services"}, + {SHA256: "8ecde6884f3d87b1125ba31ac3fcb13d7016de7f57cc904fe1cb97c6ae98196e", Name: "Amazon Root CA 1", Owner: "Amazon Trust Services"}, + {SHA256: "18ce6cfe7bf14e60b2e347b8dfe868cb31d02ebb3ada271569f50343b46db3a4", Name: "Amazon Root CA 3", Owner: "Amazon Trust Services"}, + {SHA256: "e35d28419ed02025cfa69038cd623962458da5c695fbdea3c22b0bfb25897092", Name: "Amazon Root CA 4", Owner: "Amazon Trust Services"}, {SHA256: "1ba5b2aa8c65401a82960118f80bec4f62304d83cec4713a19c39c011ea46db4", Name: "Amazon Root CA 2", Owner: "Amazon Trust Services"}, + {SHA256: "fe7696573855773e37a95e7ad4d9cc96c30157c15d31765ba9b15704e1ae78fd", Name: "Certum Trusted Root CA", Owner: "Asseco Data Systems S.A."}, {SHA256: "6b328085625318aa50d173c98d8bda09d57e27413d114cf787a0f5d06c030cf6", Name: "Certum EC-384 CA", Owner: "Asseco Data Systems S.A."}, {SHA256: "5c58468d55f58e497e743982d2b50010b6d165374acf83a7d4a32db768c4408e", Name: "Certum Trusted Network CA", Owner: "Asseco Data Systems S.A."}, {SHA256: "b676f2eddae8775cd36cb0f63cd1d4603961f49e6265ba013a2f0307b6d0b804", Name: "Certum Trusted Network CA 2", Owner: "Asseco Data Systems S.A."}, - {SHA256: "fe7696573855773e37a95e7ad4d9cc96c30157c15d31765ba9b15704e1ae78fd", Name: "Certum Trusted Root CA", Owner: "Asseco Data Systems S.A."}, {SHA256: "81a9088ea59fb364c548a6f85559099b6f0405efbf18e5324ec9f457ba00112f", Name: "Atos TrustedRoot Root CA RSA TLS 2021", Owner: "Atos"}, {SHA256: "b2fae53e14ccd7ab9212064701ae279c1d8988facb775fa8a008914e663988a8", Name: "Atos TrustedRoot Root CA ECC TLS 2021", Owner: "Atos"}, {SHA256: "57de0583efd2b26e0361da99da9df4648def7ee8441c3b728afa9bcde0f9b26a", Name: "Autoridad de Certificacion Firmaprofesional CIF A62634068", Owner: "Autoridad de Certificacion Firmaprofesional"}, {SHA256: "fb8fec759169b9106b1e511644c618c51304373f6c0643088d8beffd1b997599", Name: "ANF Secure Server Root CA", Owner: "Autoridad de Certificación (ANF AC)"}, {SHA256: "f3896f88fe7c0a882766a7fa6ad2749fb57a7f3e98fb769c1fa7b09c2c44d5ae", Name: "BJCA Global Root CA1", Owner: "BEIJING CERTIFICATE AUTHORITY Co., Ltd."}, {SHA256: "574df6931e278039667b720afdc1600fc27eb66dd3092979fb73856487212882", Name: "BJCA Global Root CA2", Owner: "BEIJING CERTIFICATE AUTHORITY Co., Ltd."}, - {SHA256: "9a114025197c5bb95d94e63d55cd43790847b646b23cdf11ada4a00eff15fb48", Name: "Buypass Class 2 Root CA", Owner: "Buypass"}, {SHA256: "edf7ebbca27a2a384d387b7d4010c666e2edb4843e4c29b4ae1d5b9332e6b24d", Name: "Buypass Class 3 Root CA", Owner: "Buypass"}, + {SHA256: "9a114025197c5bb95d94e63d55cd43790847b646b23cdf11ada4a00eff15fb48", Name: "Buypass Class 2 Root CA", Owner: "Buypass"}, {SHA256: "b4585f22e4ac756a4e8612a1361c5d9d031a93fd84febb778fa3068b0fc42dc2", Name: "Certainly Root E1", Owner: "Certainly LLC"}, {SHA256: "77b82cd8644c4305f7acc5cb156b45675004033d51c60c6202a8e0c33467d3a0", Name: "Certainly Root R1", Owner: "Certainly LLC"}, {SHA256: "d48d3d23eedb50a459e55197601c27774b9d7b18c94d5a059511a10250b93168", Name: "Certigna Root CA", Owner: "Certigna"}, @@ -160,100 +158,97 @@ var mozillaRoots = []rootEntry{ {SHA256: "f015ce3cc239bfef064be9f1d2c417e1a0264a0a94be1f0c8d121864eb6949cc", Name: "HiPKI Root CA - G1", Owner: "Chunghwa Telecom"}, {SHA256: "4b009c1034494f9ab56bba3ba1d62731fc4d20d8955adcec10a925607261e338", Name: "SecureSign Root CA14", Owner: "Cybertrust Japan / JCSI"}, {SHA256: "e778f0f095fe843729cd1a0082179e5314a9c291442805e1fb1d8fb6b8886c3a", Name: "SecureSign Root CA15", Owner: "Cybertrust Japan / JCSI"}, - {SHA256: "eec5496b988ce98625b934092eec2908bed0b0f316c2d4730c84eaf1f3d34881", Name: "D-TRUST Root Class 3 CA 2 EV 2009", Owner: "D-Trust"}, - {SHA256: "49e7a442acf0ea6287050054b52564b650e4f49e42e348d6aa38e039e957b1c1", Name: "D-TRUST Root Class 3 CA 2 2009", Owner: "D-Trust"}, - {SHA256: "8e8221b2e7d4007836a1672f0dcc299c33bc07d316f132fa1a206d587150f1ce", Name: "D-TRUST EV Root CA 2 2023", Owner: "D-Trust"}, {SHA256: "0552e6f83fdf65e8fa9670e666df28a4e21340b510cbe52566f97c4fb94b2bd1", Name: "D-TRUST BR Root CA 2 2023", Owner: "D-Trust"}, + {SHA256: "eec5496b988ce98625b934092eec2908bed0b0f316c2d4730c84eaf1f3d34881", Name: "D-TRUST Root Class 3 CA 2 EV 2009", Owner: "D-Trust"}, {SHA256: "e59aaa816009c22bff5b25bad37df306f049797c1f81d85ab089e657bd8f0044", Name: "D-TRUST BR Root CA 1 2020", Owner: "D-Trust"}, {SHA256: "08170d1aa36453901a2f959245e347db0c8d37abaabc56b81aa100dc958970db", Name: "D-TRUST EV Root CA 1 2020", Owner: "D-Trust"}, + {SHA256: "49e7a442acf0ea6287050054b52564b650e4f49e42e348d6aa38e039e957b1c1", Name: "D-TRUST Root Class 3 CA 2 2009", Owner: "D-Trust"}, + {SHA256: "8e8221b2e7d4007836a1672f0dcc299c33bc07d316f132fa1a206d587150f1ce", Name: "D-TRUST EV Root CA 2 2023", Owner: "D-Trust"}, {SHA256: "578af4ded0853f4e5998db4aeaf9cbea8d945f60b620a38d1a3c13b2bc7ba8e1", Name: "Telekom Security TLS ECC Root 2020", Owner: "Deutsche Telekom Security GmbH"}, + {SHA256: "efc65cadbb59adb6efe84da22311b35624b71b3b1ea0da8b6655174ec8978646", Name: "Telekom Security TLS RSA Root 2023", Owner: "Deutsche Telekom Security GmbH"}, {SHA256: "91e2f5788d5810eba7ba58737de1548a8ecacd014598bc0b143e041b17052552", Name: "T-TeleSec GlobalRoot Class 2", Owner: "Deutsche Telekom Security GmbH"}, {SHA256: "fd73dad31c644ff1b43bef0ccdda96710b9cd9875eca7e31707af3e96d522bbd", Name: "T-TeleSec GlobalRoot Class 3", Owner: "Deutsche Telekom Security GmbH"}, - {SHA256: "efc65cadbb59adb6efe84da22311b35624b71b3b1ea0da8b6655174ec8978646", Name: "Telekom Security TLS RSA Root 2023", Owner: "Deutsche Telekom Security GmbH"}, {SHA256: "371a00dc0533b3721a7eeb40e8419e70799d2b0a0f2c1d80693165f7cec4ad75", Name: "DigiCert TLS RSA4096 Root G5", Owner: "DigiCert"}, - {SHA256: "018e13f0772532cf809bd1b17281867283fc48c6e13be9c69812854a490c1b05", Name: "DigiCert TLS ECC P384 Root G5", Owner: "DigiCert"}, + {SHA256: "7e37cb8b4c47090cab36551ba6f45db840680fba166a952db100717f43053fc2", Name: "DigiCert Assured ID Root G3", Owner: "DigiCert"}, + {SHA256: "7d05ebb682339f8c9451ee094eebfefa7953a114edb2f44949452fab7d2fc185", Name: "DigiCert Assured ID Root G2", Owner: "DigiCert"}, + {SHA256: "cb3ccbb76031e5e0138f8dd39a23f9de47ffc35e43c1144cea27d46a5ab1cb5f", Name: "DigiCert Global Root G2", Owner: "DigiCert"}, {SHA256: "88ef81de202eb018452e43f864725cea5fbd1fc2d9d205730709c5d8b8690f46", Name: "QuoVadis Root CA 3 G3", Owner: "DigiCert"}, - {SHA256: "8fe4fb0af93a4d0d67db0bebb23e37c71bf325dcbcdd240ea04daf58b47e1840", Name: "QuoVadis Root CA 2 G3", Owner: "DigiCert"}, {SHA256: "8a866fd1b276b57e578e921c65828a2bed58e9f2f288054134b7f1f4bfc9cc74", Name: "QuoVadis Root CA 1 G3", Owner: "DigiCert"}, + {SHA256: "8fe4fb0af93a4d0d67db0bebb23e37c71bf325dcbcdd240ea04daf58b47e1840", Name: "QuoVadis Root CA 2 G3", Owner: "DigiCert"}, {SHA256: "552f7bdcf1a7af9e6ce672017f4f12abf77240c78e761ac203d1d9d20ac89988", Name: "DigiCert Trusted Root G4", Owner: "DigiCert"}, {SHA256: "31ad6648f8104138c738f39ea4320133393e3a18cc02296ef97c2ac9ef6731d0", Name: "DigiCert Global Root G3", Owner: "DigiCert"}, - {SHA256: "cb3ccbb76031e5e0138f8dd39a23f9de47ffc35e43c1144cea27d46a5ab1cb5f", Name: "DigiCert Global Root G2", Owner: "DigiCert"}, - {SHA256: "7e37cb8b4c47090cab36551ba6f45db840680fba166a952db100717f43053fc2", Name: "DigiCert Assured ID Root G3", Owner: "DigiCert"}, - {SHA256: "7d05ebb682339f8c9451ee094eebfefa7953a114edb2f44949452fab7d2fc185", Name: "DigiCert Assured ID Root G2", Owner: "DigiCert"}, + {SHA256: "018e13f0772532cf809bd1b17281867283fc48c6e13be9c69812854a490c1b05", Name: "DigiCert TLS ECC P384 Root G5", Owner: "DigiCert"}, {SHA256: "e23d4a036d7b70e9f595b1422079d2b91edfbb1fb651a0633eaa8a9dc5f80703", Name: "CA Disig Root R2", Owner: "Disig, a.s."}, {SHA256: "40f6af0346a99aa1cd1d555a4e9cce62c7f9634603ee406615833dc8c8d00367", Name: "emSign Root CA - G1", Owner: "eMudhra Technologies Limited"}, - {SHA256: "bc4d809b15189d78db3e1d8cf4f9726a795da1643ca5f1358e1ddb0edc0d7eb3", Name: "emSign ECC Root CA - C3", Owner: "eMudhra Technologies Limited"}, - {SHA256: "86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b", Name: "emSign ECC Root CA - G3", Owner: "eMudhra Technologies Limited"}, - {SHA256: "125609aa301da0a249b97a8239cb6a34216f44dcac9f3954b14292f2e8c8608f", Name: "emSign Root CA - C1", Owner: "eMudhra Technologies Limited"}, {SHA256: "bfff8fd04433487d6a8aa60c1a29767a9fc2bbb05e420f713a13b992891d3893", Name: "GDCA TrustAUTH R5 ROOT", Owner: "Global Digital Cybersecurity Authority Co., Ltd. (Formerly Guang Dong Certificate Authority (GDCA))"}, - {SHA256: "cbb522d7b7f127ad6a0113865bdf1cd4102e7d0759af635a7cf4720dc963c53b", Name: "GlobalSign", Owner: "GlobalSign nv-sa"}, {SHA256: "cbb9c44d84b8043e1050ea31a69f514955d7bfd2e2c6b49301019ad61d9f5058", Name: "GlobalSign Root E46", Owner: "GlobalSign nv-sa"}, {SHA256: "179fbc148a3dd00fd24ea13458cc43bfa7f59c8182d783a513f6ebec100c8924", Name: "GlobalSign", Owner: "GlobalSign nv-sa"}, - {SHA256: "4fa3126d8d3a11d1c4855a4f807cbad6cf919d3a5a88b03bea2c6372d93c40c9", Name: "GlobalSign Root R46", Owner: "GlobalSign nv-sa"}, {SHA256: "2cabeafe37d06ca22aba7391c0033d25982952c453647349763a3ab5ad6ccf69", Name: "GlobalSign", Owner: "GlobalSign nv-sa"}, + {SHA256: "4fa3126d8d3a11d1c4855a4f807cbad6cf919d3a5a88b03bea2c6372d93c40c9", Name: "GlobalSign Root R46", Owner: "GlobalSign nv-sa"}, + {SHA256: "cbb522d7b7f127ad6a0113865bdf1cd4102e7d0759af635a7cf4720dc963c53b", Name: "GlobalSign", Owner: "GlobalSign nv-sa"}, {SHA256: "45140b3247eb9cc8c5b4f0d7b53091f73292089e6e5a63e2749dd3aca9198eda", Name: "Go Daddy Root Certificate Authority - G2", Owner: "GoDaddy"}, {SHA256: "2ce1cb0bf9d2f9e102993fbe215152c3b2dd0cabde1c68e5319b839154dbb7f5", Name: "Starfield Root Certificate Authority - G2", Owner: "GoDaddy"}, {SHA256: "349dfa4058c5e263123b398ae795573c4e1313c83fe68f93556cd5e8031b3c7d", Name: "GTS Root R4", Owner: "Google Trust Services LLC"}, - {SHA256: "34d8a73ee208d9bcdb0d956520934b4e40e69482596e8b6f73c8426b010a6f48", Name: "GTS Root R3", Owner: "Google Trust Services LLC"}, - {SHA256: "b085d70b964f191a73e4af0d54ae7a0e07aafdaf9b71dd0862138ab7325a24a2", Name: "GlobalSign", Owner: "Google Trust Services LLC"}, {SHA256: "d947432abde7b7fa90fc2e6b59101b1280e0e1c7e4e40fa3c6887fff57a7f4cf", Name: "GTS Root R1", Owner: "Google Trust Services LLC"}, + {SHA256: "b085d70b964f191a73e4af0d54ae7a0e07aafdaf9b71dd0862138ab7325a24a2", Name: "GlobalSign", Owner: "Google Trust Services LLC"}, + {SHA256: "34d8a73ee208d9bcdb0d956520934b4e40e69482596e8b6f73c8426b010a6f48", Name: "GTS Root R3", Owner: "Google Trust Services LLC"}, {SHA256: "5a2fc03f0c83b090bbfa40604b0988446c7636183df9846e17101a447fb8efd6", Name: "Hongkong Post Root CA 3", Owner: "Government of Hong Kong (SAR), Hongkong Post, Certizen"}, {SHA256: "9a6ec012e1a7da9dbe34194d478ad7c0db1822fb071df12981496ed104384113", Name: "ACCVRAIZ1", Owner: "Government of Spain, Autoritat de Certificació de la Comunitat Valenciana (ACCV)"}, {SHA256: "ebc5570c29018c4d67b1aa127baf12f703b4611ebc17b7dab5573894179b93fa", Name: "AC RAIZ FNMT-RCM", Owner: "Government of Spain, Fábrica Nacional de Moneda y Timbre (FNMT)"}, {SHA256: "554153b13d2cf9ddb753bfbe1a4e0ae08d0aa4187058fe60a2b862b2e4b87bcb", Name: "AC RAIZ FNMT-RCM SERVIDORES SEGUROS", Owner: "Government of Spain, Fábrica Nacional de Moneda y Timbre (FNMT)"}, {SHA256: "46edc3689046d53a453fb3104ab80dcaec658b2660ea1629dd7e867990648716", Name: "TUBITAK Kamu SM SSL Kok Sertifikasi - Surum 1", Owner: "Government of Turkey, Kamu Sertifikasyon Merkezi (Kamu SM)", PermittedDNS: []string{"tr"}}, - {SHA256: "d95d0e8eda79525bf9beb11b14d2100d3294985f0c62d9fabd9cd999eccb7b1d", Name: "HARICA TLS RSA Root CA 2021", Owner: "HARICA"}, {SHA256: "44b545aa8a25e65a73ca15dc27fc36d24c1cb9953a066539b11582dc487b4833", Name: "Hellenic Academic and Research Institutions ECC RootCA 2015", Owner: "HARICA"}, - {SHA256: "a040929a02ce53b4acf4f2ffc6981ce4496f755e6d45fe0b2a692bcd52523f36", Name: "Hellenic Academic and Research Institutions RootCA 2015", Owner: "HARICA"}, {SHA256: "3f99cc474acfce4dfed58794665e478d1547739f2e780f1bb4ca9b133097d401", Name: "HARICA TLS ECC Root CA 2021", Owner: "HARICA"}, + {SHA256: "a040929a02ce53b4acf4f2ffc6981ce4496f755e6d45fe0b2a692bcd52523f36", Name: "Hellenic Academic and Research Institutions RootCA 2015", Owner: "HARICA"}, + {SHA256: "d95d0e8eda79525bf9beb11b14d2100d3294985f0c62d9fabd9cd999eccb7b1d", Name: "HARICA TLS RSA Root CA 2021", Owner: "HARICA"}, {SHA256: "5d56499be4d2e08bcfcad08a3e38723d50503bde706948e42f55603019e528ae", Name: "IdenTrust Commercial Root CA 1", Owner: "IdenTrust Services, LLC"}, {SHA256: "30d0895a9a448a262091635522d1f52010b5867acae12c78ef958fd4f4389f2f", Name: "IdenTrust Public Sector Root CA 1", Owner: "IdenTrust Services, LLC"}, - {SHA256: "96bcec06264976f37460779acf28c5a7cfe8a3c0aae11a8ffcee05c0bddf08c6", Name: "ISRG Root X1", Owner: "Internet Security Research Group"}, {SHA256: "69729b8e15a86efc177a57afb7171dfc64add28c2fca8cf1507e34453ccb1470", Name: "ISRG Root X2", Owner: "Internet Security Research Group"}, - {SHA256: "30fbba2c32238e2a98547af97931e550428b9b3f1c8eeb6633dcfa86c5b27dd3", Name: "vTrus ECC Root CA", Owner: "iTrusChina Co., Ltd."}, + {SHA256: "96bcec06264976f37460779acf28c5a7cfe8a3c0aae11a8ffcee05c0bddf08c6", Name: "ISRG Root X1", Owner: "Internet Security Research Group"}, {SHA256: "8a71de6559336f426c26e53880d00d88a18da4c6a91f0dcb6194e206c5c96387", Name: "vTrus Root CA", Owner: "iTrusChina Co., Ltd."}, + {SHA256: "30fbba2c32238e2a98547af97931e550428b9b3f1c8eeb6633dcfa86c5b27dd3", Name: "vTrus ECC Root CA", Owner: "iTrusChina Co., Ltd."}, {SHA256: "2530cc8e98321502bad96f9b1fba1b099e2d299e0f4548bb914f363bc0d4531f", Name: "Izenpe.com", Owner: "Izenpe S.A.", DistrustAfter: "2026-04-15"}, {SHA256: "a1339d33281a0b56e557d3d32b1ce7f9367eb094bd5fa72a7e5004c8ded7cafe", Name: "SZAFIR ROOT CA2", Owner: "Krajowa Izba Rozliczeniowa S.A. (KIR)"}, - {SHA256: "b49141502d00663d740f2e7ec340c52800962666121a36d09cf7dd2b90384fb4", Name: "e-Szigno TLS Root CA 2023", Owner: "Microsec Ltd."}, {SHA256: "3c5f81fea5fab82c64bfa2eaecafcde8e077fc8620a7cae537163df36edbf378", Name: "Microsec e-Szigno Root CA 2009", Owner: "Microsec Ltd."}, + {SHA256: "b49141502d00663d740f2e7ec340c52800962666121a36d09cf7dd2b90384fb4", Name: "e-Szigno TLS Root CA 2023", Owner: "Microsec Ltd."}, {SHA256: "beb00b30839b9bc32c32e4447905950641f26421b15ed089198b518ae2ea1b99", Name: "e-Szigno Root CA 2017", Owner: "Microsec Ltd."}, {SHA256: "c741f70f4b2a8d88bf2e71c14122ef53ef10eba0cfa5e64cfa20f418853073e0", Name: "Microsoft RSA Root Certificate Authority 2017", Owner: "Microsoft Corporation"}, {SHA256: "358df39d764af9e1b766e9c972df352ee15cfac227af6ad1d70e8e4a6edcba02", Name: "Microsoft ECC Root Certificate Authority 2017", Owner: "Microsoft Corporation"}, {SHA256: "88f438dcf8ffd1fa8f429115ffe5f82ae1e06e0c70c375faad717b34a49e7265", Name: "NAVER Global Root Certification Authority", Owner: "NAVER Cloud Trust Services"}, {SHA256: "6c61dac3a2def031506be036d2a6fe401994fbd13df9c8d466599274c446ec98", Name: "NetLock Arany (Class Gold) Főtanúsítvány", Owner: "NETLOCK"}, - {SHA256: "eec997c0c30f216f7e3b8b307d2bae42412d753fc8219dafd1520b2572850f49", Name: "OISTE Server Root ECC G1", Owner: "OISTE"}, + {SHA256: "6b9c08e86eb0f767cfad65cd98b62149e5494a67f5845e7bd1ed019f27b86bd6", Name: "OISTE WISeKey Global Root GB CA", Owner: "OISTE"}, {SHA256: "9ae36232a5189ffddb353dfd26520c015395d22777dac59db57b98c089a651e6", Name: "OISTE Server Root RSA G1", Owner: "OISTE"}, + {SHA256: "eec997c0c30f216f7e3b8b307d2bae42412d753fc8219dafd1520b2572850f49", Name: "OISTE Server Root ECC G1", Owner: "OISTE"}, {SHA256: "8560f91c3624daba9570b5fea0dbe36ff11a8323be9486854fb3f34a5571198d", Name: "OISTE WISeKey Global Root GC CA", Owner: "OISTE"}, - {SHA256: "6b9c08e86eb0f767cfad65cd98b62149e5494a67f5845e7bd1ed019f27b86bd6", Name: "OISTE WISeKey Global Root GB CA", Owner: "OISTE"}, - {SHA256: "1435f225c5d252d7a21948cc3ce62aecfa88001e3dd72d1cc3555100eb372f93", Name: "SECOM TLS RSA Root CA 2024", Owner: "SECOM Trust Systems Co., Ltd."}, - {SHA256: "6ab2ab75f51cb4f4f0156203fbf6f646232f514be059f62833308b82b4d72db1", Name: "SECOM TLS ECC Root CA 2024", Owner: "SECOM Trust Systems Co., Ltd."}, {SHA256: "513b2cecb810d4cde5dd85391adfc6c2dd60d87bb736d2b521484aa47a0ebef6", Name: "Security Communication RootCA2", Owner: "SECOM Trust Systems Co., Ltd."}, + {SHA256: "6ab2ab75f51cb4f4f0156203fbf6f646232f514be059f62833308b82b4d72db1", Name: "SECOM TLS ECC Root CA 2024", Owner: "SECOM Trust Systems Co., Ltd."}, {SHA256: "e74fbda55bd564c473a36b441aa799c8a68e077440e8288b9fa1e50e4bbaca11", Name: "Security Communication ECC RootCA1", Owner: "SECOM Trust Systems Co., Ltd."}, - {SHA256: "e793c9b02fd8aa13e21c31228accb08119643b749c898964b1746d46c3d4cbd2", Name: "USERTrust RSA Certification Authority", Owner: "Sectigo"}, - {SHA256: "52f0e1c4e58ec629291b60317f074671b85d7ea80d5b07273463534b32b40234", Name: "COMODO RSA Certification Authority", Owner: "Sectigo"}, + {SHA256: "1435f225c5d252d7a21948cc3ce62aecfa88001e3dd72d1cc3555100eb372f93", Name: "SECOM TLS RSA Root CA 2024", Owner: "SECOM Trust Systems Co., Ltd."}, + {SHA256: "c90f26f0fb1b4018b22227519b5ca2b53e2ca5b3be5cf18efe1bef47380c5383", Name: "Sectigo Public Server Authentication Root E46", Owner: "Sectigo"}, {SHA256: "7bb647a62aeeac88bf257aa522d01ffea395e0ab45c73f93f65654ec38f25a06", Name: "Sectigo Public Server Authentication Root R46", Owner: "Sectigo"}, - {SHA256: "4ff460d54b9c86dabfbcfc5712e0400d2bed3fbc4d4fbdaa86e06adcd2a9ad7a", Name: "USERTrust ECC Certification Authority", Owner: "Sectigo"}, {SHA256: "1793927a0614549789adce2f8f34f7f0b66d0f3ae3a3b84d21ec15dbba4fadc7", Name: "COMODO ECC Certification Authority", Owner: "Sectigo"}, - {SHA256: "c90f26f0fb1b4018b22227519b5ca2b53e2ca5b3be5cf18efe1bef47380c5383", Name: "Sectigo Public Server Authentication Root E46", Owner: "Sectigo"}, - {SHA256: "9bea11c976fe014764c1be56a6f914b5a560317abd9988393382e5161aa0493c", Name: "UCA Global G2 Root", Owner: "Shanghai Electronic Certification Authority Co., Ltd."}, + {SHA256: "52f0e1c4e58ec629291b60317f074671b85d7ea80d5b07273463534b32b40234", Name: "COMODO RSA Certification Authority", Owner: "Sectigo"}, + {SHA256: "e793c9b02fd8aa13e21c31228accb08119643b749c898964b1746d46c3d4cbd2", Name: "USERTrust RSA Certification Authority", Owner: "Sectigo"}, + {SHA256: "4ff460d54b9c86dabfbcfc5712e0400d2bed3fbc4d4fbdaa86e06adcd2a9ad7a", Name: "USERTrust ECC Certification Authority", Owner: "Sectigo"}, {SHA256: "d43af9b35473755c9684fc06d7d8cb70ee5c28e773fb294eb41ee71722924d24", Name: "UCA Extended Validation Root", Owner: "Shanghai Electronic Certification Authority Co., Ltd."}, + {SHA256: "9bea11c976fe014764c1be56a6f914b5a560317abd9988393382e5161aa0493c", Name: "UCA Global G2 Root", Owner: "Shanghai Electronic Certification Authority Co., Ltd."}, + {SHA256: "3417bb06cc6007da1b961c920b8ab4ce3fad820e4aa30b9acbc4a74ebdcebc65", Name: "SSL.com Root Certification Authority ECC", Owner: "SSL.com"}, + {SHA256: "22a2c1f7bded704cc1e701b5f408c310880fe956b5de2a4a44f99c873a25a7c8", Name: "SSL.com EV Root Certification Authority ECC", Owner: "SSL.com"}, {SHA256: "2e7bf16cc22485a7bbe2aa8696750761b0ae39be3b2fe9d0cc6d4ef73491425c", Name: "SSL.com EV Root Certification Authority RSA R2", Owner: "SSL.com"}, {SHA256: "c32ffd9f46f936d16c3673990959434b9ad60aafbb9e7cf33654f144cc1ba143", Name: "SSL.com TLS ECC Root CA 2022", Owner: "SSL.com"}, {SHA256: "8faf7d2e2cb4709bb8e0b33666bf75a5dd45b5de480f8ea8d4bfe6bebc17f2ed", Name: "SSL.com TLS RSA Root CA 2022", Owner: "SSL.com"}, - {SHA256: "22a2c1f7bded704cc1e701b5f408c310880fe956b5de2a4a44f99c873a25a7c8", Name: "SSL.com EV Root Certification Authority ECC", Owner: "SSL.com"}, - {SHA256: "3417bb06cc6007da1b961c920b8ab4ce3fad820e4aa30b9acbc4a74ebdcebc65", Name: "SSL.com Root Certification Authority ECC", Owner: "SSL.com"}, {SHA256: "85666a562ee0be5ce925c1d8890a6f76a87ec16d4d7d5f29ea7419cf20123b69", Name: "SSL.com Root Certification Authority RSA", Owner: "SSL.com"}, {SHA256: "193144f431e0fddb740717d4de926a571133884b4360d30e272913cbe660ce41", Name: "SwissSign RSA TLS Root CA 2022 - 1", Owner: "SwissSign AG"}, - {SHA256: "59769007f7685d0fcd50872f9f95d5755a5b2b457d81f3692b610a98672f0e1b", Name: "TWCA Global Root CA", Owner: "Taiwan-CA Inc. (TWCA)"}, {SHA256: "bfd88fe1101c41ae3e801bf8be56350ee9bad1a6b9bd515edc5c6d5b8711ac44", Name: "TWCA Root Certification Authority", Owner: "Taiwan-CA Inc. (TWCA)"}, + {SHA256: "59769007f7685d0fcd50872f9f95d5755a5b2b457d81f3692b610a98672f0e1b", Name: "TWCA Global Root CA", Owner: "Taiwan-CA Inc. (TWCA)"}, {SHA256: "3f63bb2814be174ec8b6439cf08d6d56f0b7c405883a5648a334424d6b3ec558", Name: "TWCA CYBER Root CA", Owner: "Taiwan-CA Inc. (TWCA)"}, + {SHA256: "098e08a91dbbf77478b96cceb89b1413a5da37b7c862606a955deb07179f4326", Name: "Telia EC TLS Root CA v3", Owner: "Telia Company"}, {SHA256: "d13db1294c45ebc6fc86c6bbf69fa29bdfe692dff7c713c243c7a956c6a2284c", Name: "Telia RSA TLS Root CA v3", Owner: "Telia Company"}, {SHA256: "242b69742fcb1e5b2abf98898b94572187544e5b4d9911786573621f6a74b82c", Name: "Telia Root CA v2", Owner: "Telia Company"}, - {SHA256: "098e08a91dbbf77478b96cceb89b1413a5da37b7c862606a955deb07179f4326", Name: "Telia EC TLS Root CA v3", Owner: "Telia Company"}, - {SHA256: "e0d3226aeb1163c2e48ff9be3b50b4c6431be7bb1eacc5c36b5d5ec509039a08", Name: "TrustAsia Global Root CA G3", Owner: "TrustAsia Technologies, Inc."}, + {SHA256: "06c08d7dafd876971eb1124fe67f847ec0c7a158d3ea53cbe940e2ea9791f4c3", Name: "TrustAsia TLS RSA Root CA", Owner: "TrustAsia Technologies, Inc."}, {SHA256: "be4b56cb5056c0136a526df444508daa36a0b54f42e4ac38f72af470e479654c", Name: "TrustAsia Global Root CA G4", Owner: "TrustAsia Technologies, Inc."}, {SHA256: "c0076b9ef0531fb1a656d67c4ebe97cd5dbaa41ef44598acc2489878c92d8711", Name: "TrustAsia TLS ECC Root CA", Owner: "TrustAsia Technologies, Inc."}, - {SHA256: "06c08d7dafd876971eb1124fe67f847ec0c7a158d3ea53cbe940e2ea9791f4c3", Name: "TrustAsia TLS RSA Root CA", Owner: "TrustAsia Technologies, Inc."}, + {SHA256: "e0d3226aeb1163c2e48ff9be3b50b4c6431be7bb1eacc5c36b5d5ec509039a08", Name: "TrustAsia Global Root CA G3", Owner: "TrustAsia Technologies, Inc."}, } var chromeRoots = []rootEntry{ @@ -269,7 +264,7 @@ var chromeRoots = []rootEntry{ {SHA256: "fe7696573855773e37a95e7ad4d9cc96c30157c15d31765ba9b15704e1ae78fd", Name: "Certum Trusted Root CA", Owner: "Asseco Data Systems S.A."}, {SHA256: "b2fae53e14ccd7ab9212064701ae279c1d8988facb775fa8a008914e663988a8", Name: "Atos TrustedRoot Root CA ECC TLS 2021", Owner: "Atos"}, {SHA256: "81a9088ea59fb364c548a6f85559099b6f0405efbf18e5324ec9f457ba00112f", Name: "Atos TrustedRoot Root CA RSA TLS 2021", Owner: "Atos"}, - {SHA256: "57de0583efd2b26e0361da99da9df4648def7ee8441c3b728afa9bcde0f9b26a", Name: "Autoridad de Certificacion Firmaprofesional CIF A62634068", Owner: "Autoridad de Certificacion Firmaprofesional", SCTNotAfter: 1790812799}, + {SHA256: "57de0583efd2b26e0361da99da9df4648def7ee8441c3b728afa9bcde0f9b26a", Name: "Autoridad de Certificacion Firmaprofesional CIF A62634068", Owner: "Autoridad de Certificacion Firmaprofesional", SCTNotAfter: 1785999664}, {SHA256: "fb8fec759169b9106b1e511644c618c51304373f6c0643088d8beffd1b997599", Name: "ANF Secure Server Root CA", Owner: "Autoridad de Certificación (ANF AC)", SCTNotAfter: 1799971199}, {SHA256: "edf7ebbca27a2a384d387b7d4010c666e2edb4843e4c29b4ae1d5b9332e6b24d", Name: "Buypass Class 3 Root CA", Owner: "Buypass", SCTNotAfter: 1761955199}, {SHA256: "9a114025197c5bb95d94e63d55cd43790847b646b23cdf11ada4a00eff15fb48", Name: "Buypass Class 2 Root CA", Owner: "Buypass", SCTNotAfter: 1761955199}, diff --git a/internal/server/trust_test.go b/internal/server/trust_test.go index 3bceaa6..72ed2fc 100644 --- a/internal/server/trust_test.go +++ b/internal/server/trust_test.go @@ -191,7 +191,7 @@ func TestReferenceDataAndServerBundle(t *testing.T) { } `json:"lists"` Attribution string `json:"attribution"` } - if s := e.call("GET", "/api/v1/reference-data", nil, &ref); s != 200 || len(ref.Lists) != 5 || !strings.Contains(ref.Attribution, "CCADB") { + if s := e.call("GET", "/api/v1/reference-data", nil, &ref); s != 200 || len(ref.Lists) != 6 || !strings.Contains(ref.Attribution, "CCADB") { t.Fatalf("reference data: %d %+v", s, ref) } for _, l := range ref.Lists { diff --git a/internal/store/priority.go b/internal/store/priority.go index e22047d..9b51ef0 100644 --- a/internal/store/priority.go +++ b/internal/store/priority.go @@ -3,16 +3,21 @@ package store import ( "encoding/json" "strconv" + "strings" "time" "github.com/offsetsecurity/cipherhorizon/internal/eval" ) // Facts describes the certificate for the certificate rules. -func (c CertificateRow) Facts() eval.CertificateFacts { return certFacts(c.Attrs) } +func (c CertificateRow) Facts() eval.CertificateFacts { + f := certFacts(c.Attrs) + f.SHA256 = c.Fingerprint + return f +} func certFacts(a map[string]string) eval.CertificateFacts { - f := eval.CertificateFacts{Subject: a["subject"], KeyAlgorithm: a["keyAlg"], SigName: a["sigAlg"], IsCA: a["isCA"] == "true", SelfSigned: a["selfSigned"] == "true"} + f := eval.CertificateFacts{Subject: a["subject"], KeyAlgorithm: a["keyAlg"], SigName: a["sigAlg"], IsCA: a["isCA"] == "true", SelfSigned: a["selfSigned"] == "true", SPKISHA256: a["spkiSha256"]} if t, err := time.Parse("2006-01-02T15:04:05Z", a["notBefore"]); err == nil { f.NotBefore = t } @@ -70,7 +75,9 @@ func (s *Store) PriorityInputs() ([]eval.PriorityInput, error) { var a map[string]string _ = json.Unmarshal([]byte(attrs), &a) if in := byID[sid]; in != nil && a["isCA"] != "true" { - in.Certs = append(in.Certs, certFacts(a)) + f := certFacts(a) + f.SHA256 = strings.TrimPrefix(key, "cert:") + in.Certs = append(in.Certs, f) } } _ = rows.Close() diff --git a/internal/x509view/x509view_test.go b/internal/x509view/x509view_test.go index a68781d..fa8c2fb 100644 --- a/internal/x509view/x509view_test.go +++ b/internal/x509view/x509view_test.go @@ -70,7 +70,7 @@ func richChain(t testing.TB) (leafDER []byte, ca *x509.Certificate) { if err != nil { t.Fatal(err) } - logID, _ := base64.StdEncoding.DecodeString("1219ENGn9XfCx+lf1wC/+YLJM1pl4dCzAXMXwMjFaXc=") // Google 'Argon2026h2' log + logID, _ := base64.StdEncoding.DecodeString("1219ENGn9XfCx+lf1wC/+YLJM1pl4dCzAXMXwMjFaXc=") // Google Argon2026h2 mustStaple, _ := asn1.Marshal([]int{5}) u, _ := url.Parse("spiffe://example.test/web") leafTmpl := &x509.Certificate{ @@ -139,7 +139,7 @@ func TestX509DecodeShowsEveryExtension(t *testing.T) { "CRL distribution points": "http://crl.example.test/ca.crl", "Certificate policies": "2.23.140.1.2.2 (organization validated (OV))", "TLS feature": "status_request (OCSP must-staple)", - "Signed certificate timestamps": "Google 'Argon2026h2' log (Google), 2026-10-01 12:00 UTC", + "Signed certificate timestamps": "Google Argon2026h2 (Google), 2026-10-01 12:00 UTC", } for name, value := range want { e, ok := byName[name] From d05fc0c0635e13df1a4c24bee1369749f6cb1b0c Mon Sep 17 00:00:00 2001 From: Khalid Saifullah Khan Date: Sat, 24 Oct 2026 13:42:51 +0530 Subject: [PATCH 2/2] feat: opt-in CRL revocation checks up the served chain (CRYPTO-REVIEW-004 P1b) With --revocation (tls scan, serve), the revocation stage fetches the CRL each certificate on the served path names and checks it against its own issuer, up to the root (stopping at a served copy of a known anchor). Revocation is a status beside trust, never a gate. - pki.JudgeCRL (pure): issuer name, issuer signature, dates, delta, indirect and IDP scope (user/CA-only, partial reasons, partitions). - internal/revocation: guarded fetcher. http only, no credentials, 16 MiB, timeouts, re-checked redirects, no env proxy; loopback, link-local, private and metadata addresses refused unless --revocation-private; cache until nextUpdate. - Report.CRLLookups and CertResult.CRL; rules REV.REVOKED, REV.CRL-BAD-SIGNATURE, REV.CRL-STALE, REV.UNKNOWN (not-checked when lookups are off). A current CRL for the leaf makes revocation known. - OpenSSL-made fixtures (testing/pki/make-crl.sh), FuzzJudgeCRL in CI. - UI: CRL tile and table, Settings and Help; fixed contradictory pass texts (CERT.PARTIAL-TRUST, OCSP.REVOKED) and the hygiene note. - OCSP responders are still never contacted. --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 16 + docs/methodology.md | 32 +- .../CRYPTO-REVIEW-004-emudhra-weak-ca-keys.md | 18 + internal/app/serve.go | 12 +- internal/app/tls.go | 15 + internal/eval/crl_test.go | 167 ++++++++++ internal/eval/eval.go | 6 + internal/eval/eval_t1_test.go | 7 +- internal/eval/hygiene.go | 4 +- internal/eval/passtext.go | 8 +- internal/eval/pkiview.go | 9 +- internal/eval/rules.go | 2 +- internal/eval/rules_crl.go | 131 ++++++++ internal/eval/sections.go | 14 +- internal/pki/crl.go | 218 ++++++++++++ internal/pki/crl_test.go | 142 ++++++++ internal/pki/fuzz_test.go | 32 ++ internal/pki/pkitest/pkitest.go | 19 +- internal/pki/testdata/crl/bad.crl | Bin 0 -> 272 bytes internal/pki/testdata/crl/bad.txt | 23 ++ internal/pki/testdata/crl/impostor.pem | 11 + internal/pki/testdata/crl/int-base.crl | Bin 0 -> 285 bytes internal/pki/testdata/crl/int-delta.crl | Bin 0 -> 335 bytes internal/pki/testdata/crl/int-delta.txt | 30 ++ internal/pki/testdata/crl/int-empty.crl | Bin 0 -> 284 bytes internal/pki/testdata/crl/int-empty.txt | 23 ++ internal/pki/testdata/crl/int-partition.crl | Bin 0 -> 285 bytes internal/pki/testdata/crl/int-partition.txt | 22 ++ internal/pki/testdata/crl/int-revoked.crl | Bin 0 -> 322 bytes internal/pki/testdata/crl/int-revoked.txt | 28 ++ internal/pki/testdata/crl/int.pem | 12 + internal/pki/testdata/crl/leaf.pem | 13 + internal/pki/testdata/crl/other.pem | 13 + internal/pki/testdata/crl/root-revoked.crl | Bin 0 -> 314 bytes internal/pki/testdata/crl/root-revoked.txt | 28 ++ internal/pki/testdata/crl/root.pem | 11 + internal/refdata/refdata.go | 17 + internal/revocation/revocation.go | 314 ++++++++++++++++++ internal/revocation/revocation_test.go | 196 +++++++++++ internal/server/api.go | 1 + internal/server/guard_internal_test.go | 19 ++ internal/server/runner.go | 11 + internal/server/settings.go | 1 + internal/server/starttls_test.go | 3 +- internal/tlsprobe/crl.go | 135 ++++++++ internal/tlsprobe/crl_test.go | 146 ++++++++ internal/tlsprobe/probe.go | 9 + internal/tlsprobe/report.go | 8 + internal/tlsprobe/scan.go | 12 +- internal/tlsprobe/stages.go | 3 +- testing/pki/make-crl.sh | 143 ++++++++ web/static/js/views/help.js | 6 +- web/static/js/views/report.js | 24 +- web/static/js/views/settings.js | 1 + web/static/js/views/tls-config.js | 36 +- 56 files changed, 2123 insertions(+), 30 deletions(-) create mode 100644 internal/eval/crl_test.go create mode 100644 internal/eval/rules_crl.go create mode 100644 internal/pki/crl.go create mode 100644 internal/pki/crl_test.go create mode 100644 internal/pki/testdata/crl/bad.crl create mode 100644 internal/pki/testdata/crl/bad.txt create mode 100644 internal/pki/testdata/crl/impostor.pem create mode 100644 internal/pki/testdata/crl/int-base.crl create mode 100644 internal/pki/testdata/crl/int-delta.crl create mode 100644 internal/pki/testdata/crl/int-delta.txt create mode 100644 internal/pki/testdata/crl/int-empty.crl create mode 100644 internal/pki/testdata/crl/int-empty.txt create mode 100644 internal/pki/testdata/crl/int-partition.crl create mode 100644 internal/pki/testdata/crl/int-partition.txt create mode 100644 internal/pki/testdata/crl/int-revoked.crl create mode 100644 internal/pki/testdata/crl/int-revoked.txt create mode 100644 internal/pki/testdata/crl/int.pem create mode 100644 internal/pki/testdata/crl/leaf.pem create mode 100644 internal/pki/testdata/crl/other.pem create mode 100644 internal/pki/testdata/crl/root-revoked.crl create mode 100644 internal/pki/testdata/crl/root-revoked.txt create mode 100644 internal/pki/testdata/crl/root.pem create mode 100644 internal/revocation/revocation.go create mode 100644 internal/revocation/revocation_test.go create mode 100644 internal/server/guard_internal_test.go create mode 100644 internal/tlsprobe/crl.go create mode 100644 internal/tlsprobe/crl_test.go create mode 100644 testing/pki/make-crl.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5ede6b..fd10c56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,6 +123,8 @@ jobs: done - name: Fuzz the PE version-resource parser (reads untrusted files from the host) run: go test ./internal/sense/pefiles -run '^$' -fuzz '^FuzzReadVersion$' -fuzztime 30s + - name: Fuzz the CRL judgement (CRLs come from addresses a target's certificates name) + run: go test ./internal/pki -run '^$' -fuzz '^FuzzJudgeCRL$' -fuzztime 60s - name: Fuzz what people upload (facts files, systems CSV) run: | go test ./internal/machinefacts -run '^$' -fuzz '^FuzzParseFacts$' -fuzztime 60s diff --git a/CHANGELOG.md b/CHANGELOG.md index 86d9638..2aad2fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,22 @@ the CBOM/report/eval schema versions, the database) can change in a minor releas builds signature-valid paths itself. Each store's result keeps the path, names the listed certificate and its position, and says why the store does not trust it. Every other rule still runs. Evaluation version `eval/13`. +### Added + +- **CRL revocation checks, opt-in** (`--revocation` on `tls scan` and `serve`; CRYPTO-REVIEW-004 P1b). Every + certificate on the served path is checked against the CRL it names, signed by its own issuer, up to the root. + New rules: `REV.REVOKED` (critical), `REV.CRL-BAD-SIGNATURE` (high), `REV.CRL-STALE` (medium) and + `REV.UNKNOWN` (low). They read *not checked* when lookups are off. + - **Status, not gate:** a revoked certificate stays on the path, and trust and every other rule are unchanged. + - **Hostile URLs are guarded:** fetches are plain HTTP only, with a 16 MiB size cap and timeouts, and every + connection's real address is checked. Private, loopback and cloud-metadata addresses are refused unless + `--revocation-private` is set. + - **Caching:** CRLs are cached until their nextUpdate. + - **Not OCSP:** OCSP responders are still never contacted. + - **Tested:** fixtures are produced by OpenSSL (`testing/pki/make-crl.sh`), including IDP scope, partitions, + delta and wrong-key CRLs, and a fuzz target runs in CI. + - Settings and Help show whether lookups are on. + ### Changed - Root stores refreshed from CCADB (2026-10-23). Mozilla has removed emSign ECC Root CA - G3, emSign ECC Root CA - diff --git a/docs/methodology.md b/docs/methodology.md index 92855c4..b57a9f0 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -120,6 +120,33 @@ lifetime limit and the CT requirement. The readiness level is unaffected by trus (cross-signs, reissues), and by certificate SHA-256 for certificates known only by fingerprint (host collectors). First entry: the eight eMudhra intermediate CAs of October 2026, whose P-384 private keys are below 256. +| Rule | Severity | Fires when | Source | +|---|---|---|---| +| `REV.REVOKED` | critical | a certificate on the served path is listed on its issuer's CRL (signature verified, in scope) | RFC 5280 §5, CA/B Forum BR §4.9, §7.2 | +| `REV.CRL-BAD-SIGNATURE` | high | a CRL a certificate names is under its issuer's name but does not verify under the issuer's key | as above | +| `REV.CRL-STALE` | medium | the CRL that answered is past its nextUpdate at evaluation time | as above | +| `REV.UNKNOWN` | low | no CRL answered for a certificate: unreachable, refused by the address policy, too large, not a CRL, out of scope, a delta, indirect, issuer unknown | as above | + +- **CRL lookups are opt-in** (`--revocation` on `tls scan` and `serve`; off by default). They are the only part of + a scan that contacts a host other than the target. With them off, the four checks read *not checked*, never + *passed*. +- **Chain-wide, issuer-signed.** Each certificate on the served path, leaf first, is checked against a CRL it + names, signed by the certificate above it (or, for the top, a bundled root or the system's private anchor). + The walk stops at a self-signed root or a served cross-sign of a known root. A forged leaf's own revocation + data would be signed by the compromised key, which is why the parent's CRL is the one that counts. +- **What a CRL must be to answer:** issued under the certificate's issuer name, signed by the issuer's key, not + dated in the future, a complete CRL (not a delta), not indirect, and in scope by its issuing distribution point + (user-only, CA-only, only-some-reasons and partition URLs are honoured). Otherwise the status is *unknown*. +- **Fetching is guarded.** Plain `http` URLs only, no credentials, at most 4 per certificate, 16 MiB per CRL, 10 s + per fetch, 90 s for the stage, 3 redirects (each re-checked), no environment proxy. Every connection's real + address is checked after DNS: loopback, link-local (cloud metadata), private (RFC 1918, fc00::/7, + 100.64.0.0/10), unspecified and multicast are refused unless `--revocation-private` is set (an internal CA's + CRL server). Results are cached per URL until the CRL's nextUpdate (at most a day), failures for 5 minutes. +- **Revocation is a status, not a gate.** A revoked certificate stays on the path; the trust verdict and every + other rule are unchanged. A current CRL that does not list the leaf counts, like a verified staple, as + revocation being known. OCSP responders are never contacted: a lookup would tell the CA which site is checked, + and CRLs are mandatory for every publicly trusted CA. + ### Rules and changes in eval/12 (PRD-015) | Rule | Severity | Fires when | Source | @@ -134,8 +161,9 @@ lifetime limit and the CT requirement. The readiness level is unaffected by trus - **A missing issuer** is called a missing intermediate only when the served path stops at a certificate that names where its issuer can be downloaded (AIA caIssuers); otherwise it is a root the stores checked do not hold: a private CA, or a public root since removed. -- **Revocation** is *good* only when a verified stapled OCSP response says so. Otherwise its status is *unknown*: the - scan does not contact the CA's OCSP responder or CRL. The revocation checks still count as run. +- **Revocation** is *good* only when a verified stapled OCSP response says so, or (from eval/13, with CRL lookups + on) a current CRL from the leaf's issuer does not list it. Otherwise its status is *unknown*. The staple checks + still count as run. ### Rules added in eval/3 (PRD-004) diff --git a/docs/product/CRYPTO-REVIEW-004-emudhra-weak-ca-keys.md b/docs/product/CRYPTO-REVIEW-004-emudhra-weak-ca-keys.md index 2812dc8..c452b78 100644 --- a/docs/product/CRYPTO-REVIEW-004-emudhra-weak-ca-keys.md +++ b/docs/product/CRYPTO-REVIEW-004-emudhra-weak-ca-keys.md @@ -12,6 +12,24 @@ P0 as built, and where it differs from the plan below: - Superfish, eDellRoot and DSDTestProvider stay matched by name in `CERT.COMPROMISED-ROOT`. No key hash for them was verified, and guessing one would be worse than the name match. - Host-collected certificates match by certificate fingerprint. Collectors do not record the key hash yet (P2). + +P1b (online revocation) as built, same branch, still eval/13 (unreleased): +- **CRL only, no OCSP.** CRLs are mandatory for every public CA, and an OCSP lookup would tell the CA which site is + being checked. OCSP responders stay uncontacted. +- **Where the code is.** `pki.JudgeCRL` is pure. `internal/revocation` does the guarded fetching and caching. + `tlsprobe` walks the served path in the revocation stage and records `CertResult.CRL` and `Report.CRLLookups`. + The `REV.*` rules read those records. +- **Flags.** `--revocation` and `--revocation-private` on `tls scan` and `serve`. +- **Real servers, 2026-10-23.** + - eMudhra test host: `REV.REVOKED`. G3B is revoked (cACompromise) on emSign Root TLS CA - G3's own CRL, and the + signature verifies. + - revoked.badssl.com: the leaf is revoked (keyCompromise). The chain was checked up to ISRG Root X1. + - www.google.com: not revoked. The first run raised `REV.UNKNOWN` for a served cross-sign of GTS Root R4 whose + issuer we do not hold. Fixed: the walk now stops at a served copy of a known anchor. +- **Not done.** + - Per-system anchors are used as CRL issuers only in `serve` (`tls scan` has no `--ca-bundle`). + - No CRLite or OneCRL feed yet (P3). + - No UI toggle: the setting is a server flag, shown in Settings. Trigger: IFF letter of 2026-10-22 on the compromise of eMudhra CA keys. ## 1. The incident in brief diff --git a/internal/app/serve.go b/internal/app/serve.go index 13d3359..97af291 100644 --- a/internal/app/serve.go +++ b/internal/app/serve.go @@ -18,6 +18,7 @@ import ( "github.com/offsetsecurity/cipherhorizon/internal/auth" "github.com/offsetsecurity/cipherhorizon/internal/eval" + "github.com/offsetsecurity/cipherhorizon/internal/revocation" "github.com/offsetsecurity/cipherhorizon/internal/server" "github.com/offsetsecurity/cipherhorizon/internal/store" "github.com/offsetsecurity/cipherhorizon/internal/tlsprobe" @@ -191,6 +192,10 @@ func serveCmd(ctx context.Context, args []string, env Env) int { httpChecks := fl.Bool("http-checks", true, "after each scan's handshake, send one plain HTTP GET / and read the Strict-Transport-Security header. "+ "This is the only check that sends anything beyond a TLS handshake: one real HTTP request to every scanned target, read for headers only. "+ "On by default, server-wide; --http-checks=false turns it off") + crlLookups := fl.Bool("revocation", false, "after each scan's handshakes, fetch the CRL each certificate on the served path names and report whether it is revoked. "+ + "Off by default, server-wide: it contacts each CA's CRL server (plain HTTP), hosts other than the scanned targets. Private and loopback "+ + "addresses are refused unless --revocation-private is set") + crlPrivate := fl.Bool("revocation-private", false, "with --revocation, also fetch CRLs from private (RFC 1918, unique-local) and loopback addresses: an internal CA's CRL server") caBundle := fl.String("ca-bundle", "", "PEM file of private CA certificates every scan is also judged against (\"trusted by your CA bundle\"). "+ "Certificates only: a file containing a private-key block is refused") var allowedHosts, trustedProxies, scanAllow stringList @@ -331,9 +336,14 @@ func serveCmd(ctx context.Context, args []string, env Env) int { return fatalf(env, "%v", err) } guard := server.Guard{AllowLoopback: *allowLoopback, Scope: scope} + scanOpts := tlsprobe.Options{Timeout: *timeout, Concurrency: *concurrency, HTTPChecks: *httpChecks} + if *crlLookups { + // One fetcher for the whole server, so its cache spans scans. + scanOpts.CRL = revocation.New(revocation.Policy{AllowPrivate: *crlPrivate, AllowLoopback: *crlPrivate}) + } runner := server.NewRunner(st, guard, server.RunnerOptions{ Workers: *workers, - Scan: tlsprobe.Options{Timeout: *timeout, Concurrency: *concurrency, HTTPChecks: *httpChecks}, + Scan: scanOpts, CABundle: bundle, Log: log, }) diff --git a/internal/app/tls.go b/internal/app/tls.go index 0fb354f..ed247f1 100644 --- a/internal/app/tls.go +++ b/internal/app/tls.go @@ -3,6 +3,7 @@ package app import ( "bytes" "context" + "crypto/x509" "encoding/json" "errors" "flag" @@ -14,6 +15,8 @@ import ( "github.com/offsetsecurity/cipherhorizon/internal/cbom" "github.com/offsetsecurity/cipherhorizon/internal/eval" "github.com/offsetsecurity/cipherhorizon/internal/pqc" + "github.com/offsetsecurity/cipherhorizon/internal/refdata" + "github.com/offsetsecurity/cipherhorizon/internal/revocation" "github.com/offsetsecurity/cipherhorizon/internal/tlscbom" "github.com/offsetsecurity/cipherhorizon/internal/tlsprobe" "github.com/offsetsecurity/cipherhorizon/internal/version" @@ -52,6 +55,10 @@ func tlsCmd(ctx context.Context, args []string, env Env) int { "On by default; --http-checks=false turns it off") osTrust := fl.Bool("os-trust", false, "also check each chain against this machine's trust store. Off by default: on some systems the OS verifier "+ "fetches missing intermediates from the network, contacting hosts other than the target") + crl := fl.Bool("revocation", false, "fetch the CRL each certificate on the served path names and report whether it is revoked. "+ + "Off by default: it contacts the CA's CRL server (plain HTTP), a host other than the target. Private and loopback addresses are refused "+ + "unless --revocation-private is set") + crlPrivate := fl.Bool("revocation-private", false, "with --revocation, also fetch CRLs from private (RFC 1918, unique-local) and loopback addresses: an internal CA's CRL server") fl.Usage = func() { _, _ = fmt.Fprintln(env.Stderr, "usage: cipherhorizon tls scan [flags] HOST[:PORT]... (flags must come before targets)") fl.PrintDefaults() @@ -92,6 +99,13 @@ func tlsCmd(ctx context.Context, args []string, env Env) int { targets = append(targets, t) } + var crlFetcher tlsprobe.CRLFetcher + var crlIssuers []*x509.Certificate + if *crl { + crlFetcher = revocation.New(revocation.Policy{AllowPrivate: *crlPrivate, AllowLoopback: *crlPrivate}) + crlIssuers = refdata.RootCertificates() + } + _, _ = fmt.Fprint(env.Stderr, tlsNotice) code := ExitOK @@ -111,6 +125,7 @@ func tlsCmd(ctx context.Context, args []string, env Env) int { rep, err := tlsprobe.Scan(ctx, t, tlsprobe.Options{ Timeout: *timeout, Concurrency: *concurrency, MaxConnections: *maxConns, MinInterval: *interval, SkipCertificates: *noCerts, HTTPChecks: *httpChecks, OSTrust: *osTrust, + CRL: crlFetcher, CRLIssuers: crlIssuers, Progress: func(msg string) { _, _ = fmt.Fprintf(env.Stderr, " %s\n", msg) }, Now: env.Now, }) diff --git a/internal/eval/crl_test.go b/internal/eval/crl_test.go new file mode 100644 index 0000000..817702c --- /dev/null +++ b/internal/eval/crl_test.go @@ -0,0 +1,167 @@ +package eval_test + +import ( + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/offsetsecurity/cipherhorizon/internal/eval" + "github.com/offsetsecurity/cipherhorizon/internal/pki" + "github.com/offsetsecurity/cipherhorizon/internal/tlsprobe" +) + +// The CRL fixtures are OpenSSL's (testing/pki/make-crl.sh); the results are what the scanner's +// CRL step records: pki.JudgeCRL over each fetched CRL. + +var crlEvalAt = time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + +func crlPEM(t *testing.T, name string) *x509.Certificate { + t.Helper() + b, err := os.ReadFile(filepath.Join("..", "pki", "testdata", "crl", name+".pem")) + if err != nil { + t.Fatal(err) + } + blk, _ := pem.Decode(b) + c, err := x509.ParseCertificate(blk.Bytes) + if err != nil { + t.Fatal(err) + } + return c +} + +func judged(t *testing.T, crl, url string, pos int, subject, issuer *x509.Certificate) pki.CRLResult { + t.Helper() + b, err := os.ReadFile(filepath.Join("..", "pki", "testdata", "crl", crl+".crl")) + if err != nil { + t.Fatal(err) + } + rl, err := x509.ParseRevocationList(b) + if err != nil { + t.Fatal(err) + } + r := pki.JudgeCRL(rl, url, subject, issuer, crlEvalAt) + r.Position, r.SHA256 = pos, pki.Fingerprint(subject) + return r +} + +// crlEval evaluates leaf+int from the CRL fixture PKI with the given CRL results, against a +// store holding the fixture root. +func crlEval(t *testing.T, lookups bool, results ...pki.CRLResult) eval.Evaluation { + t.Helper() + root, inter, leaf := crlPEM(t, "root"), crlPEM(t, "int"), crlPEM(t, "leaf") + st, err := pki.NewStore("Mozilla", "2026-06-01", true, []pki.Root{{DER: root.Raw}}) + if err != nil { + t.Fatal(err) + } + cr := chainResult("tls-default", leaf, inter) + cr.CRL = results + rep := v2(cr) + rep.Target = tlsprobe.Target{Host: "leaf.test", Port: 443, SNI: "leaf.test"} + rep.CRLLookups = lookups + return eval.EvaluateWith(rep, eval.Input{Now: crlEvalAt, Stores: []*pki.Store{st}}) +} + +// crlEvals fire every CRL rule (for the rule matrix). +func crlEvals(t *testing.T) []eval.Evaluation { + root, inter, leaf := crlPEM(t, "root"), crlPEM(t, "int"), crlPEM(t, "leaf") + stale := pki.CRLResult{Position: 1, SHA256: pki.Fingerprint(inter), URL: "http://crl.pki.test/root.crl", Status: pki.CRLGood, + Signature: pki.CRLSigValid, NextUpdate: crlEvalAt.AddDate(0, 0, -3)} + return []eval.Evaluation{ + crlEval(t, true, judged(t, "int-revoked", "http://crl.pki.test/int.crl", 0, leaf, inter), judged(t, "root-revoked", "http://crl.pki.test/root.crl", 1, inter, root)), + crlEval(t, true, judged(t, "bad", "http://crl.pki.test/int.crl", 0, leaf, inter), stale), + } +} + +func findingsOf(ev eval.Evaluation, id string) []eval.Finding { + var out []eval.Finding + for _, f := range ev.Findings { + if f.ID == id { + out = append(out, f) + } + } + return out +} + +func checkResult(ev eval.Evaluation, id string) string { + for _, c := range ev.Checks { + if c.ID == id { + return c.Result + } + } + return "" +} + +// TestRevokedChainIsAFindingNotAGate: a leaf on its issuer's CRL and an intermediate on the +// root's CRL are each reported, with reason and date; the chain is still trusted by the store +// (revocation is a status beside trust) and every other rule still ran. +func TestRevokedChainIsAFindingNotAGate(t *testing.T) { + ev := crlEvals(t)[0] + rv := findingsOf(ev, "REV.REVOKED") + if len(rv) != 1 || rv[0].Severity != eval.Critical || !rv[0].Availability { + t.Fatalf("REV.REVOKED: %+v", rv) + } + for _, want := range []string{"the leaf is revoked (keyCompromise) since 2026-05-01", `intermediate "CipherHorizon CRL Test Intermediate" is revoked (cACompromise) since 2026-05-15`, + "its private key is reported compromised", "everything below it on the chain", "http://crl.pki.test/root.crl"} { + if !strings.Contains(rv[0].Detail, want) { + t.Errorf("detail lacks %q: %s", want, rv[0].Detail) + } + } + if tr := ev.PKI[0].Trust; len(tr) != 1 || !tr[0].Trusted { + t.Errorf("revocation changed the trust verdict: %+v", tr) + } + if len(findingsOf(ev, "REV.UNKNOWN")) != 0 || checkResult(ev, "REV.UNKNOWN") != "not-found" { + t.Errorf("a revoked answer is an answer: %+v", findingsOf(ev, "REV.UNKNOWN")) + } + if checkResult(ev, "PROTO.TLS10") == "" || checkResult(ev, "CERT.EXPIRING") == "" { + t.Error("other checks did not run") + } +} + +func TestBadSignatureStaleAndUnknown(t *testing.T) { + ev := crlEvals(t)[1] + if f := findingsOf(ev, "REV.CRL-BAD-SIGNATURE"); len(f) != 1 || !strings.Contains(f[0].Detail, "for the leaf does not verify") { + t.Errorf("bad signature: %+v", f) + } + if f := findingsOf(ev, "REV.CRL-STALE"); len(f) != 1 || !strings.Contains(f[0].Detail, "due to be replaced on 2026-09-28") { + t.Errorf("stale: %+v", f) + } + if f := findingsOf(ev, "REV.UNKNOWN"); len(f) != 1 || !strings.Contains(f[0].Detail, "the leaf: http://crl.pki.test/int.crl: the CRL's signature does not verify") { + t.Errorf("unknown: %+v", f) + } + if len(findingsOf(ev, "REV.REVOKED")) != 0 { + t.Error("an unverified CRL revoked something") + } +} + +// TestCurrentCRLVouchesForTheLeaf: a good, signed, current CRL for the leaf makes revocation +// known, without a staple. +func TestCurrentCRLVouchesForTheLeaf(t *testing.T) { + inter, leaf := crlPEM(t, "int"), crlPEM(t, "leaf") + ev := crlEval(t, true, judged(t, "int-empty", "http://crl.pki.test/int.crl", 0, leaf, inter)) + for _, s := range ev.Sections { + if s.ID == eval.SecRevocation && s.Status == eval.StatusUnknown { + t.Errorf("a current CRL should vouch: %+v", s) + } + } + for _, id := range []string{"REV.REVOKED", "REV.CRL-BAD-SIGNATURE", "REV.CRL-STALE", "REV.UNKNOWN"} { + if r := checkResult(ev, id); r != "not-found" { + t.Errorf("%s: %s", id, r) + } + } + // Lookups off: the same chain's CRL checks are not checked, and the section says why. + off := crlEval(t, false) + for _, id := range []string{"REV.REVOKED", "REV.UNKNOWN"} { + if r := checkResult(off, id); r != "not-checked" { + t.Errorf("lookups off, %s: %s", id, r) + } + } + for _, s := range off.Sections { + if s.ID == eval.SecRevocation && s.Status == eval.StatusUnknown && !strings.Contains(s.Reason, "CRL lookups were off") { + t.Errorf("lookups off, section: %+v", s) + } + } +} diff --git a/internal/eval/eval.go b/internal/eval/eval.go index 3d0aeee..d75077d 100644 --- a/internal/eval/eval.go +++ b/internal/eval/eval.go @@ -10,6 +10,7 @@ package eval import ( "sort" + "strings" "time" "github.com/offsetsecurity/cipherhorizon/internal/refdata" @@ -268,6 +269,11 @@ func EvaluateWith(rep *tlsprobe.Report, in Input) Evaluation { if r := skipped[checks[i].Section]; checks[i].Result == "not-found" && r != "" { checks[i].Result = r } + // The CRL checks ran only if the scan looked CRLs up: otherwise "nothing found" would + // read as "not revoked". + if strings.HasPrefix(checks[i].ID, "REV.") && checks[i].Result == "not-found" && !rep.CRLLookups { + checks[i].Result = "not-checked" + } } ev.Checks = checks ev.FixFirst = fixFirst(findings) diff --git a/internal/eval/eval_t1_test.go b/internal/eval/eval_t1_test.go index 81e157e..947f9d0 100644 --- a/internal/eval/eval_t1_test.go +++ b/internal/eval/eval_t1_test.go @@ -655,6 +655,7 @@ func t1Firing(t *testing.T) []eval.Evaluation { out = append(out, evalAt(r, epoch, f.stores)) } out = append(out, weakKeyEval(t)) + out = append(out, crlEvals(t)...) at, st := osslAt(t), osslStore(t) for _, c := range []struct { leaf, staple string @@ -698,8 +699,12 @@ func TestRevocationIsUnknownWithoutAStaple(t *testing.T) { t.Errorf("no staple: section %+v", s) } for _, c := range ev.Checks { - if c.Section == eval.SecRevocation && c.Result == "not-checked" { + crl := strings.HasPrefix(c.ID, "REV.") + switch { + case c.Section == eval.SecRevocation && !crl && c.Result == "not-checked": t.Errorf("check %s reads not-checked; it ran", c.ID) + case crl && c.Result != "not-checked": + t.Errorf("CRL check %s reads %s, but the scan did not look CRLs up", c.ID, c.Result) } } good := evalAt(stapled(t, "leaf.pem", osslStaple(t, "ocsp-good")), osslAt(t), osslStore(t)) diff --git a/internal/eval/hygiene.go b/internal/eval/hygiene.go index d98ab8b..03d872c 100644 --- a/internal/eval/hygiene.go +++ b/internal/eval/hygiene.go @@ -101,9 +101,11 @@ const hygieneMethodology = "TLS hygiene score: start at 100 and take off points func computeHygiene(c *ctx, findings []Finding) Hygiene { h := Hygiene{Deductions: []Deduction{}, Methodology: hygieneMethodology} h.NotMeasured = []string{ - "Certificate revocation status.", "Active exploit probes (Heartbleed, ROBOT, CCS injection, ...): not implemented; only weaknesses inferable from what is enabled are counted.", } + if !c.rep.CRLLookups { + h.NotMeasured = append([]string{"Certificate revocation status: CRL lookups were off for this scan (--revocation), so only a stapled OCSP response could report it."}, h.NotMeasured...) + } if c.rep.HSTS == nil { h.NotMeasured = append(h.NotMeasured, "HTTP Strict Transport Security: not checked on this scan (the opt-in --http-checks flag was off).") diff --git a/internal/eval/passtext.go b/internal/eval/passtext.go index a57eca9..aa709ae 100644 --- a/internal/eval/passtext.go +++ b/internal/eval/passtext.go @@ -29,6 +29,10 @@ var passText = map[string]string{ "KX.DH-NOT-SAFE-PRIME": "The Diffie-Hellman prime is a safe prime or a published group", "CERT.COMPROMISED-ROOT": "The chain does not end at a root whose private key is public", "CERT.DISTRUSTED-KEY": "No certificate on the chain uses a key on the distrust list", + "REV.REVOKED": "No certificate on the chain is listed on its issuer's CRL", + "REV.CRL-BAD-SIGNATURE": "Every CRL read was signed by the certificate's issuer", + "REV.CRL-STALE": "Every CRL read is current", + "REV.UNKNOWN": "A CRL from each certificate's issuer answered for it", "CERT.EXPIRED": "The certificate has not expired", "CERT.NOT-YET-VALID": "The certificate is already valid", @@ -44,11 +48,11 @@ var passText = map[string]string{ "CERT.LIFETIME": "The validity period is within the limit for public certificates", "CERT.NO-SERVERAUTH": "The certificate is valid for TLS servers", "CERT.CLIENTAUTH-EKU": "The certificate is for servers only", - "CERT.PARTIAL-TRUST": "Every root store checked trusts the certificate", + "CERT.PARTIAL-TRUST": "The root stores checked agree on whether to trust the certificate", "CERT.WEAK-EXPONENT": "The RSA public exponent is at least 65537", "CERT.NO-SNI-DIFFERENT": "Clients without a server name get the same certificate", - "OCSP.REVOKED": "The certificate is not reported revoked", + "OCSP.REVOKED": "No stapled OCSP response says the certificate is revoked", "OCSP.MISMATCH": "A stapled OCSP response, if any, is for this certificate", "OCSP.STALE": "A stapled OCSP response, if any, is current", "OCSP.UNKNOWN": "A stapled OCSP response, if any, gives a status", diff --git a/internal/eval/pkiview.go b/internal/eval/pkiview.go index 1aefbec..445ff99 100644 --- a/internal/eval/pkiview.go +++ b/internal/eval/pkiview.go @@ -51,10 +51,14 @@ type ChainView struct { Lifetime LifetimeView `json:"lifetime"` OCSP *OCSPView `json:"ocsp,omitempty"` SCTs []SCTView `json:"scts"` - // Revocation sources the certificate names; T1 never contacts them. + // Revocation sources the leaf names. CRLs are contacted only when the scan's CRL + // lookups were on (CRLLookups); OCSP responders never are. OCSPURLs []string `json:"ocspUrls,omitempty"` CRLURLs []string `json:"crlUrls,omitempty"` - Error string `json:"error,omitempty"` // the chain could not be analysed + // CRL is what the CRL lookups found for each certificate on the served path. + CRL []pki.CRLResult `json:"crl,omitempty"` + CRLLookups bool `json:"crlLookups,omitempty"` + Error string `json:"error,omitempty"` // the chain could not be analysed } // ServedView is the chain as the server sent it. @@ -165,6 +169,7 @@ func (c *ctx) analyseChains() { } ch.view.Leaf = cr.Chain[0].SHA256 ch.view.OCSPURLs, ch.view.CRLURLs = cr.Chain[0].OCSPServers, cr.Chain[0].CRLPoints + ch.view.CRL, ch.view.CRLLookups = cr.CRL, c.rep.CRLLookups var der [][]byte for _, ci := range cr.Chain { b, err := base64.StdEncoding.DecodeString(ci.DER) diff --git a/internal/eval/rules.go b/internal/eval/rules.go index 5e25dbf..1577f4c 100644 --- a/internal/eval/rules.go +++ b/internal/eval/rules.go @@ -30,7 +30,7 @@ type rule struct { legacyOnly, v2Only bool } -func allRules() []rule { return append(append([]rule(nil), rules...), t1Rules...) } +func allRules() []rule { return append(append(append([]rule(nil), rules...), t1Rules...), crlRules...) } // RuleIDs lists the IDs of the built-in rules (PQC readiness findings, which come // from the readiness assessment, are not included). diff --git a/internal/eval/rules_crl.go b/internal/eval/rules_crl.go new file mode 100644 index 0000000..bda8e4f --- /dev/null +++ b/internal/eval/rules_crl.go @@ -0,0 +1,131 @@ +package eval + +import ( + "fmt" + "strings" + + "github.com/offsetsecurity/cipherhorizon/internal/pki" +) + +// The CRL rules (CRYPTO-REVIEW-004 P1b) read what the scan's opt-in CRL lookups recorded +// (CertResult.CRL). Revocation is a status beside the trust verdict, never a gate on it: a revoked +// certificate stays on the path, and every other rule still runs. When the scan did not look CRLs +// up, these checks are "not checked", never "passed". + +var citBRCRL = Citation{Title: "CA/Browser Forum Baseline Requirements §4.9 (revocation) and §7.2 (CRL profile)", + URL: "https://cabforum.org/working-groups/server/baseline-requirements/requirements/"} + +var crlRules = []rule{ + chainRule("REV.REVOKED", "A certificate on the chain is revoked", "revocation", Critical, []Citation{citRFC5280, citBRCRL}, + "Replace the revoked certificate now: issue a new one (under a CA that is not revoked, when an intermediate is the one revoked) and deploy it with its current chain. Clients that check revocation refuse this chain; those that do not are trusting a certificate its CA has withdrawn.", + func(c *ctx, ch *chain) (string, Severity, bool) { + var parts []string + for _, r := range ch.view.CRL { + if r.Status != pki.CRLRevoked { + continue + } + s := fmt.Sprintf("%s is revoked", ch.certAt(r.Position)) + if r.Reason != "" { + s += " (" + r.Reason + ")" + } + if !r.RevokedAt.IsZero() { + s += " since " + fmtDate(r.RevokedAt) + } + s += " by the CRL at " + r.URL + ", signed by its issuer" + if r.Reason == "keyCompromise" || r.Reason == "cACompromise" { + s += "; its private key is reported compromised, so anyone holding it can sign in its name" + } + if r.Position > 0 { + s += "; everything below it on the chain, the leaf included, is issued under a revoked CA" + } + parts = append(parts, s) + } + return ch.label() + ": " + strings.Join(parts, "; "), "", len(parts) > 0 + }), + chainRule("REV.CRL-BAD-SIGNATURE", "A CRL the chain names is not signed by the certificate's issuer", "revocation", High, []Citation{citRFC5280, citBRCRL}, + "Ask the CA: the CRL its certificate points to does not verify under the CA's key, so no client can use it to check revocation. If the address is yours (a private CA), publish there the CRL the CA signs.", + func(c *ctx, ch *chain) (string, Severity, bool) { + var parts []string + for _, r := range ch.view.CRL { + if r.Signature == pki.CRLSigInvalid { + parts = append(parts, fmt.Sprintf("the CRL at %s for %s does not verify under its issuer's key", r.URL, ch.certAt(r.Position))) + } + } + return ch.label() + ": " + strings.Join(parts, "; "), "", len(parts) > 0 + }), + chainRule("REV.CRL-STALE", "A CRL the chain relies on is out of date", "revocation", Medium, []Citation{citRFC5280, citBRCRL}, + "Ask the CA (or, for a private CA, publish a new CRL): the CRL is past the date by which its issuer promised a newer one, so clients that require a current CRL cannot use it.", + func(c *ctx, ch *chain) (string, Severity, bool) { + var parts []string + for _, r := range ch.view.CRL { + if r.Status == pki.CRLGood && !r.NextUpdate.IsZero() && r.NextUpdate.Before(c.now) { + parts = append(parts, fmt.Sprintf("the CRL at %s for %s was due to be replaced on %s (nextUpdate)", r.URL, ch.certAt(r.Position), fmtDate(r.NextUpdate))) + } + } + return ch.label() + ": " + strings.Join(parts, "; "), "", len(parts) > 0 + }), + chainRule("REV.UNKNOWN", "Revocation status could not be determined from a CRL", "revocation", Low, []Citation{citRFC5280, citBRCRL}, + "Check that the CRL address the certificate names is reachable over plain HTTP from where clients run, and that it serves a current CRL its issuer signed. For a private CA on an internal address, allow private CRL addresses (--revocation-private).", + func(c *ctx, ch *chain) (string, Severity, bool) { + var parts []string + for _, pos := range ch.crlPositions() { + answered, why := false, "" + for _, r := range ch.view.CRL { + if r.Position != pos { + continue + } + if r.Status != pki.CRLUnknown { + answered = true + } + if r.Problem != "" { + why = r.Problem + if r.URL != "" { + why = r.URL + ": " + why + } + } + } + if !answered { + parts = append(parts, fmt.Sprintf("%s: %s", ch.certAt(pos), why)) + } + } + return ch.label() + ": " + strings.Join(parts, "; "), "", len(parts) > 0 + }), +} + +// certAt names the served certificate at index pos for a sentence. +func (ch *chain) certAt(pos int) string { + if pos == 0 { + return "the leaf" + } + if pos < len(ch.cr.Chain) { + if cn := commonName(ch.cr.Chain[pos].Subject); cn != "" { + return fmt.Sprintf("intermediate %q", cn) + } + return fmt.Sprintf("intermediate %q", ch.cr.Chain[pos].Subject) + } + return fmt.Sprintf("served certificate %d", pos) +} + +// crlPositions lists, in order, the served positions the CRL lookups recorded anything for. +func (ch *chain) crlPositions() []int { + var out []int + seen := map[int]bool{} + for _, r := range ch.view.CRL { + if !seen[r.Position] { + seen[r.Position] = true + out = append(out, r.Position) + } + } + return out +} + +// crlVouches reports whether a CRL lookup says the leaf is not revoked: a CRL signed by its +// issuer, in scope, current at the evaluation time, that does not list it. +func (ch *chain) crlVouches(c *ctx) bool { + for _, r := range ch.view.CRL { + if r.Position == 0 && r.Status == pki.CRLGood && r.Signature == pki.CRLSigValid && (r.NextUpdate.IsZero() || !r.NextUpdate.Before(c.now)) { + return true + } + } + return false +} diff --git a/internal/eval/sections.go b/internal/eval/sections.go index ba71a00..8a02268 100644 --- a/internal/eval/sections.go +++ b/internal/eval/sections.go @@ -103,7 +103,7 @@ func sectionOf(f Finding) string { // availabilityRules make clients fail to connect (REQ-TUI-2). var availabilityRules = map[string]bool{ "CERT.EXPIRED": true, "CERT.UNTRUSTED": true, "CERT.NAME-MISMATCH": true, "CERT.CHAIN-INCOMPLETE": true, - "CERT.NO-SERVERAUTH": true, "OCSP.MUST-STAPLE-MISSING": true, "OCSP.REVOKED": true, "CERT.COMPROMISED-ROOT": true, + "CERT.NO-SERVERAUTH": true, "OCSP.MUST-STAPLE-MISSING": true, "OCSP.REVOKED": true, "CERT.COMPROMISED-ROOT": true, "REV.REVOKED": true, } // attackLabels name the attacks a finding enables (REQ-TVLN-1). The detail @@ -182,7 +182,10 @@ func sections(c *ctx, findings []Finding) []Section { if id == SecRevocation && s.Status == StatusGood && !c.legacy && !c.revocationVouched() { s.Status, s.ReasonCode = StatusUnknown, "not-contacted" - s.Reason = "No stapled OCSP response vouches for the certificate, and this scan does not contact the CA's OCSP responder or CRL, so whether it has been revoked is not known." + s.Reason = "No stapled OCSP response vouches for the certificate, and CRL lookups were off for this scan, so whether it has been revoked is not known." + if c.rep.CRLLookups { + s.Reason = "No stapled OCSP response vouches for the certificate, and no CRL from its issuer could say it is not revoked (see the revocation checks), so whether it has been revoked is not known." + } } if c.legacy { switch id { @@ -327,8 +330,8 @@ func referenceData(now time.Time) []RefInfo { return out } -// revocationVouched reports whether a stapled OCSP response that verifies says the served leaf is -// good: the only revocation status this scan can know without contacting the CA. +// revocationVouched reports whether a stapled OCSP response that verifies, or a current CRL from +// the leaf's issuer (when the scan looked CRLs up), says the served leaf is not revoked. func (c *ctx) revocationVouched() bool { for _, ch := range c.chains { if ch.cr.NoSNI { @@ -337,6 +340,9 @@ func (c *ctx) revocationVouched() bool { if o := ch.view.OCSP; o != nil && o.ForThisCert && o.Error == "" && o.Status == "good" && o.Signature == "valid" { return true } + if ch.crlVouches(c) { + return true + } } return false } diff --git a/internal/pki/crl.go b/internal/pki/crl.go new file mode 100644 index 0000000..a7a4fe0 --- /dev/null +++ b/internal/pki/crl.go @@ -0,0 +1,218 @@ +package pki + +import ( + "bytes" + "crypto/x509" + "encoding/asn1" + "fmt" + "strings" + "time" +) + +// CRL status values. Unknown is never a pass: it says the CRL could not answer. +const ( + CRLGood = "good" + CRLRevoked = "revoked" + CRLUnknown = "unknown" +) + +// CRL signature results. +const ( + CRLSigValid = "valid" + CRLSigInvalid = "invalid" + CRLSigNoIssuer = "no-issuer" +) + +// CRLResult is what one CRL says about one certificate on a served path. It is +// a status beside the trust verdict, never a gate on it. +type CRLResult struct { + Position int `json:"position"` // served index of the certificate checked + SHA256 string `json:"sha256"` // the certificate checked + URL string `json:"url,omitempty"` + + Status string `json:"status"` // good | revoked | unknown + Reason string `json:"reason,omitempty"` // the CRL's reason code, when revoked + RevokedAt time.Time `json:"revokedAt,omitempty"` // when revoked + // Signature is valid (the certificate's issuer signed the CRL), invalid, or + // no-issuer (the issuer was not available to check it); empty when the CRL + // was never read. + Signature string `json:"signature,omitempty"` + ThisUpdate time.Time `json:"thisUpdate,omitempty"` + NextUpdate time.Time `json:"nextUpdate,omitempty"` + Number string `json:"crlNumber,omitempty"` + // Issuer is the SHA-256 of the certificate whose key verified the CRL. + Issuer string `json:"issuer,omitempty"` + + FetchedAt time.Time `json:"fetchedAt,omitempty"` + Size int `json:"size,omitempty"` + CRLSHA256 string `json:"crlSha256,omitempty"` + // Problem says why the status is unknown (or what else was wrong). + Problem string `json:"problem,omitempty"` +} + +// CRLReasons names RFC 5280 §5.3.1 reason codes. +var CRLReasons = map[int]string{0: "unspecified", 1: "keyCompromise", 2: "cACompromise", 3: "affiliationChanged", + 4: "superseded", 5: "cessationOfOperation", 6: "certificateHold", 8: "removeFromCRL", 9: "privilegeWithdrawn", 10: "aACompromise"} + +var ( + oidDeltaCRLIndicator = asn1.ObjectIdentifier{2, 5, 29, 27} + oidIssuingDistribution = asn1.ObjectIdentifier{2, 5, 29, 28} + oidCRLNumber = asn1.ObjectIdentifier{2, 5, 29, 20} + oidAuthorityKeyID = asn1.ObjectIdentifier{2, 5, 29, 35} + oidFreshestCRL = asn1.ObjectIdentifier{2, 5, 29, 46} + oidExpiredCertsOnCRL = asn1.ObjectIdentifier{2, 5, 29, 60} + understoodCRLExtensions = []asn1.ObjectIdentifier{oidDeltaCRLIndicator, oidIssuingDistribution, oidCRLNumber, oidAuthorityKeyID, oidFreshestCRL, oidExpiredCertsOnCRL} +) + +// clockSkew is how far in the future a CRL's thisUpdate may be before it is +// treated as not yet issued. +const clockSkew = 5 * time.Minute + +// JudgeCRL says what the CRL rl, fetched from url, says about subject, whose +// issuer is issuer (nil when the evaluation does not have it). It checks, in +// order: that the CRL is issued under the subject's issuer name, that the +// issuer's key signed it, that it is not dated in the future, that it is a +// complete CRL whose scope (issuing distribution point) covers the subject, +// and then whether the subject's serial is listed. Anything that stops the CRL +// from answering gives CRLUnknown with the reason in Problem. Staleness +// (nextUpdate passed) is left to evaluation, which knows its own clock. +// +// The caller fills Position, SHA256, FetchedAt, Size and CRLSHA256. +func JudgeCRL(rl *x509.RevocationList, url string, subject, issuer *x509.Certificate, now time.Time) CRLResult { + r := CRLResult{URL: url, Status: CRLUnknown, ThisUpdate: rl.ThisUpdate, NextUpdate: rl.NextUpdate} + if rl.Number != nil { + r.Number = rl.Number.String() + } + if !bytes.Equal(rl.RawIssuer, subject.RawIssuer) { + r.Problem = fmt.Sprintf("the CRL is issued by %q, not by the certificate's issuer; indirect CRLs are not supported", rl.Issuer.String()) + return r + } + if issuer == nil { + r.Signature = CRLSigNoIssuer + r.Problem = "the certificate's issuer is not available to this scan, so the CRL's signature cannot be checked" + return r + } + if err := rl.CheckSignatureFrom(issuer); err != nil { + r.Signature = CRLSigInvalid + r.Problem = "the CRL's signature does not verify under the issuer's key: " + err.Error() + return r + } + r.Signature, r.Issuer = CRLSigValid, Fingerprint(issuer) + if rl.ThisUpdate.After(now.Add(clockSkew)) { + r.Problem = fmt.Sprintf("the CRL is dated %s, in the future", rl.ThisUpdate.UTC().Format(time.RFC3339)) + return r + } + for _, e := range rl.Extensions { + switch { + case e.Id.Equal(oidDeltaCRLIndicator): + r.Problem = "this is a delta CRL; the complete CRL it updates was not found" + return r + case e.Id.Equal(oidIssuingDistribution): + if why := idpScope(e.Value, url, subject); why != "" { + r.Problem = why + return r + } + case e.Critical && !understood(e.Id): + r.Problem = fmt.Sprintf("the CRL has a critical extension %s this scan does not understand", e.Id) + return r + } + } + for _, e := range rl.RevokedCertificateEntries { + if e.SerialNumber != nil && subject.SerialNumber != nil && e.SerialNumber.Cmp(subject.SerialNumber) == 0 { + if e.ReasonCode == 8 { // removeFromCRL: only meaningful in a delta CRL, which is refused above + continue + } + r.Status, r.RevokedAt = CRLRevoked, e.RevocationTime + if e.ReasonCode != 0 { + r.Reason = CRLReasons[e.ReasonCode] + if r.Reason == "" { + r.Reason = fmt.Sprintf("reason %d", e.ReasonCode) + } + } + return r + } + } + r.Status = CRLGood + return r +} + +func understood(id asn1.ObjectIdentifier) bool { + for _, u := range understoodCRLExtensions { + if id.Equal(u) { + return true + } + } + return false +} + +// issuingDistributionPoint is RFC 5280 §5.2.5. distributionPoint is a tagged +// CHOICE (so its [0] wraps the choice element); the booleans are implicitly tagged. +type issuingDistributionPoint struct { + DistributionPoint asn1.RawValue `asn1:"optional,tag:0"` // the [0] wrapper; Bytes is the CHOICE + OnlyUser bool `asn1:"optional,tag:1"` + OnlyCA bool `asn1:"optional,tag:2"` + OnlySomeReasons asn1.BitString `asn1:"optional,tag:3"` + Indirect bool `asn1:"optional,tag:4"` + OnlyAttribute bool `asn1:"optional,tag:5"` +} + +// idpScope returns why a CRL with this issuing distribution point does not +// answer for subject fetched from url, or "" when it does. +func idpScope(der []byte, url string, subject *x509.Certificate) string { + var idp issuingDistributionPoint + if rest, err := asn1.Unmarshal(der, &idp); err != nil || len(rest) > 0 { + return "the CRL's issuing distribution point extension cannot be read" + } + switch { + case idp.Indirect: + return "the CRL is an indirect CRL; indirect CRLs are not supported" + case idp.OnlyAttribute: + return "the CRL covers only attribute certificates" + case idp.OnlyUser && subject.IsCA: + return "the CRL covers only end-entity certificates, and this is a CA certificate" + case idp.OnlyCA && !subject.IsCA: + return "the CRL covers only CA certificates, and this is an end-entity certificate" + case idp.OnlySomeReasons.BitLength > 0: + return "the CRL covers only some revocation reasons, so it cannot say the certificate is not revoked" + } + if len(idp.DistributionPoint.Bytes) > 0 { + uris, ok := fullNameURIs(idp.DistributionPoint.Bytes) + if !ok { + return "" // a relative name or other form: the scope is the issuer's, which already matched + } + if len(uris) > 0 && !containsFold(uris, url) { + return fmt.Sprintf("the CRL is the partition for %s, not for %s", strings.Join(uris, ", "), url) + } + } + return "" +} + +// fullNameURIs reads the URIs of a DistributionPointName's fullName ([0] +// IMPLICIT GeneralNames). ok is false for nameRelativeToCRLIssuer. +func fullNameURIs(choice []byte) (uris []string, ok bool) { + var dpn asn1.RawValue + if _, err := asn1.Unmarshal(choice, &dpn); err != nil || dpn.Class != asn1.ClassContextSpecific || dpn.Tag != 0 { + return nil, false + } + rest := dpn.Bytes + for len(rest) > 0 { + var gn asn1.RawValue + var err error + if rest, err = asn1.Unmarshal(rest, &gn); err != nil { + return uris, true + } + if gn.Class == asn1.ClassContextSpecific && gn.Tag == 6 { // uniformResourceIdentifier + uris = append(uris, string(gn.Bytes)) + } + } + return uris, true +} + +func containsFold(list []string, s string) bool { + for _, x := range list { + if strings.EqualFold(x, s) { + return true + } + } + return false +} diff --git a/internal/pki/crl_test.go b/internal/pki/crl_test.go new file mode 100644 index 0000000..d6bd16b --- /dev/null +++ b/internal/pki/crl_test.go @@ -0,0 +1,142 @@ +package pki_test + +import ( + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/offsetsecurity/cipherhorizon/internal/pki" +) + +// The CRL fixtures are made by OpenSSL (testing/pki/make-crl.sh): the producer +// is independent of the judgement under test. + +func crlCert(t testing.TB, name string) *x509.Certificate { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "crl", name+".pem")) + if err != nil { + t.Fatal(err) + } + blk, _ := pem.Decode(b) + c, err := x509.ParseCertificate(blk.Bytes) + if err != nil { + t.Fatal(err) + } + return c +} + +func crlFile(t *testing.T, name string) *x509.RevocationList { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "crl", name+".crl")) + if err != nil { + t.Fatal(err) + } + rl, err := x509.ParseRevocationList(b) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + return rl +} + +// crlAt is a time inside every fixture CRL's validity. +var crlAt = time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + +const ( + intURL = "http://crl.pki.test/int.crl" + rootURL = "http://crl.pki.test/root.crl" +) + +func TestJudgeCRLAgainstOpenSSLFixtures(t *testing.T) { + root, inter, leaf, other := crlCert(t, "root"), crlCert(t, "int"), crlCert(t, "leaf"), crlCert(t, "other") + cases := []struct { + name string + crl, url string + subject, issuer *x509.Certificate + status, sig string + reason, problemHas string + }{ + {"leaf revoked", "int-revoked", intURL, leaf, inter, pki.CRLRevoked, pki.CRLSigValid, "keyCompromise", ""}, + {"other leaf not listed", "int-revoked", intURL, other, inter, pki.CRLGood, pki.CRLSigValid, "", ""}, + {"empty CRL", "int-empty", intURL, leaf, inter, pki.CRLGood, pki.CRLSigValid, "", ""}, + {"intermediate revoked by the root", "root-revoked", rootURL, inter, root, pki.CRLRevoked, pki.CRLSigValid, "cACompromise", ""}, + {"user-only CRL does not cover a CA", "int-empty", intURL, inter, root, pki.CRLUnknown, "", "", "not by the certificate's issuer"}, + {"CA-only CRL does not cover a leaf", "root-revoked", rootURL, leaf, inter, pki.CRLUnknown, "", "", "not by the certificate's issuer"}, + {"another partition", "int-partition", intURL, leaf, inter, pki.CRLUnknown, pki.CRLSigValid, "", "the partition for http://crl.pki.test/int-2.crl"}, + {"delta CRL", "int-delta", intURL, leaf, inter, pki.CRLUnknown, pki.CRLSigValid, "", "delta CRL"}, + {"right name, wrong key", "bad", intURL, leaf, inter, pki.CRLUnknown, pki.CRLSigInvalid, "", "does not verify"}, + {"issuer not available", "int-revoked", intURL, leaf, nil, pki.CRLUnknown, pki.CRLSigNoIssuer, "", "cannot be checked"}, + } + for _, c := range cases { + r := pki.JudgeCRL(crlFile(t, c.crl), c.url, c.subject, c.issuer, crlAt) + if r.Status != c.status || r.Signature != c.sig || r.Reason != c.reason || (c.problemHas != "" && !strings.Contains(r.Problem, c.problemHas)) { + t.Errorf("%s: %+v", c.name, r) + } + if c.status != pki.CRLUnknown && (r.ThisUpdate.IsZero() || r.NextUpdate.IsZero() || r.Number == "" || r.Issuer != pki.Fingerprint(c.issuer)) { + t.Errorf("%s: missing CRL facts %+v", c.name, r) + } + } + // Scope by certificate type, with the right issuer: the CA-only root CRL + // judged for a leaf the root signed directly would be refused by IDP. + r := pki.JudgeCRL(crlFile(t, "root-revoked"), rootURL, rootSignedLeaf(t, root), root, crlAt) + if r.Status != pki.CRLUnknown || !strings.Contains(r.Problem, "only CA certificates") { + t.Errorf("CA-only CRL for an end-entity certificate: %+v", r) + } + // The user-only int CRL for a CA int issued (int has pathlen 0, so build the subject by hand). + if r := pki.JudgeCRL(crlFile(t, "int-empty"), intURL, asCA(leaf), inter, crlAt); r.Status != pki.CRLUnknown || !strings.Contains(r.Problem, "only end-entity") { + t.Errorf("user-only CRL for a CA certificate: %+v", r) + } + // Not yet issued. + if r := pki.JudgeCRL(crlFile(t, "int-empty"), intURL, leaf, inter, time.Date(2026, 5, 31, 0, 0, 0, 0, time.UTC)); r.Status != pki.CRLUnknown || !strings.Contains(r.Problem, "in the future") { + t.Errorf("future CRL: %+v", r) + } +} + +// TestRevokedEntriesMatchOpenSSL compares serials, dates and reasons with +// `openssl crl -text`. +func TestRevokedEntriesMatchOpenSSL(t *testing.T) { + inter, leaf := crlCert(t, "int"), crlCert(t, "leaf") + root := crlCert(t, "root") + for _, c := range []struct { + crl string + subject *x509.Certificate + issuer *x509.Certificate + url string + }{{"int-revoked", leaf, inter, intURL}, {"root-revoked", inter, root, rootURL}} { + txt, err := os.ReadFile(filepath.Join("testdata", "crl", c.crl+".txt")) + if err != nil { + t.Fatal(err) + } + m := regexp.MustCompile(`Serial Number: ([0-9A-F]+)\s+Revocation Date: ([A-Za-z]+ +\d+ [\d:]+ \d+) GMT`).FindStringSubmatch(string(txt)) + if m == nil { + t.Fatalf("%s: no revoked entry in the OpenSSL text", c.crl) + } + at, err := time.Parse("Jan _2 15:04:05 2006", m[2]) + if err != nil { + t.Fatal(err) + } + r := pki.JudgeCRL(crlFile(t, c.crl), c.url, c.subject, c.issuer, crlAt) + if !strings.EqualFold(c.subject.SerialNumber.Text(16), strings.TrimLeft(m[1], "0")) || !r.RevokedAt.Equal(at) { + t.Errorf("%s: OpenSSL says %s at %s, we say %s at %s", c.crl, m[1], at, c.subject.SerialNumber.Text(16), r.RevokedAt) + } + } +} + +// rootSignedLeaf is a copy of an end-entity certificate presented as if the +// root issued it: only the name fields JudgeCRL reads are changed. +func rootSignedLeaf(t *testing.T, root *x509.Certificate) *x509.Certificate { + t.Helper() + c := *crlCert(t, "other") + c.RawIssuer = root.RawSubject + return &c +} + +func asCA(c *x509.Certificate) *x509.Certificate { + cp := *c + cp.IsCA = true + return &cp +} diff --git a/internal/pki/fuzz_test.go b/internal/pki/fuzz_test.go index 634c52c..0f7481d 100644 --- a/internal/pki/fuzz_test.go +++ b/internal/pki/fuzz_test.go @@ -1,6 +1,7 @@ package pki_test import ( + "crypto/x509" "os" "path/filepath" "testing" @@ -77,3 +78,34 @@ func FuzzServedChain(f *testing.F) { _, _, _ = s.MissingIssuer() }) } + +// FuzzJudgeCRL feeds hostile CRLs (a scan fetches them from addresses a target's certificates +// name) through the parser and the judgement: no panic, and an answer is one of the three +// statuses, with "good" or "revoked" only under a valid signature. +func FuzzJudgeCRL(f *testing.F) { + for _, n := range []string{"int-revoked", "int-empty", "root-revoked", "int-partition", "int-delta", "bad"} { + if b, err := os.ReadFile(filepath.Join("testdata", "crl", n+".crl")); err == nil { + f.Add(b) + } + } + leaf, inter := crlCert(f, "leaf"), crlCert(f, "int") + f.Fuzz(func(t *testing.T, b []byte) { + rl, err := x509.ParseRevocationList(b) + if err != nil { + return + } + r := pki.JudgeCRL(rl, "http://crl.pki.test/int.crl", leaf, inter, crlAt) + switch r.Status { + case pki.CRLGood, pki.CRLRevoked: + if r.Signature != pki.CRLSigValid { + t.Fatalf("%s without a valid signature: %+v", r.Status, r) + } + case pki.CRLUnknown: + if r.Problem == "" { + t.Fatal("unknown without a reason") + } + default: + t.Fatalf("status %q", r.Status) + } + }) +} diff --git a/internal/pki/pkitest/pkitest.go b/internal/pki/pkitest/pkitest.go index ed16364..331fe2f 100644 --- a/internal/pki/pkitest/pkitest.go +++ b/internal/pki/pkitest/pkitest.go @@ -39,6 +39,7 @@ type CAOpts struct { NotBefore, NotAfter time.Time // default Epoch-1y .. Epoch+10y PermittedDNS []string EKU []x509.ExtKeyUsage + CRL []string // CRL distribution points the CA certificate names } func newKey(t testing.TB) *ecdsa.PrivateKey { @@ -74,7 +75,7 @@ func caTemplate(name string, o CAOpts) *x509.Certificate { return &x509.Certificate{ SerialNumber: big.NewInt(serial.Add(1)), Subject: pkix.Name{CommonName: name, Organization: []string{"pkitest"}}, NotBefore: nb, NotAfter: na, IsCA: true, BasicConstraintsValid: true, - KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, PermittedDNSDomains: o.PermittedDNS, ExtKeyUsage: o.EKU, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, PermittedDNSDomains: o.PermittedDNS, ExtKeyUsage: o.EKU, CRLDistributionPoints: o.CRL, } } @@ -266,3 +267,19 @@ func DER(certs ...*x509.Certificate) [][]byte { func (ca *CA) selfSigned() bool { return bytes.Equal(ca.Cert.RawIssuer, ca.Cert.RawSubject) } + +// CRL issues a CRL under ca listing revoked, valid from thisUpdate to nextUpdate. +func (ca *CA) CRL(t testing.TB, revoked []x509.RevocationListEntry, thisUpdate, nextUpdate time.Time) *x509.RevocationList { + t.Helper() + der, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(serial.Add(1)), ThisUpdate: thisUpdate, NextUpdate: nextUpdate, RevokedCertificateEntries: revoked, + }, ca.Cert, ca.Key) + if err != nil { + t.Fatalf("pkitest: CRL: %v", err) + } + rl, err := x509.ParseRevocationList(der) + if err != nil { + t.Fatal(err) + } + return rl +} diff --git a/internal/pki/testdata/crl/bad.crl b/internal/pki/testdata/crl/bad.crl new file mode 100644 index 0000000000000000000000000000000000000000..897fe1765068a062f098c1b60260342bbf75d96c GIT binary patch literal 272 zcmXqLV&pMs+|0ztXu!qBq1EPb&X$Fl$w1Fg$3TmXIh2K&N7*^EAS1QNBfluKDnC!b zImkyLB(=Ci!85NUwJ0|=B{Q)kRh-wz%)rdR&;Sgg#Cc6jfgBjuK-oZ%iAjMGtO_In zQDwjbw30bgR+NRAi;;1GzJacRJczH%B4HrbAX4$N-bX~$`mgrtoko{mZ~w_!eWo6w zK?LYRMkZwUGJ7x>xH2g+Y<1r~`L_SNk_t(eU7VIpQ&XNsXTANvb$^1+NvpqsyL^}w mqSnu2QG5C9-1{QYUgn*J3%`73o|YHxo_zaG#OK)71wH`om{8yV literal 0 HcmV?d00001 diff --git a/internal/pki/testdata/crl/bad.txt b/internal/pki/testdata/crl/bad.txt new file mode 100644 index 0000000..ee4a12c --- /dev/null +++ b/internal/pki/testdata/crl/bad.txt @@ -0,0 +1,23 @@ +Certificate Revocation List (CRL): + Version 2 (0x1) + Signature Algorithm: ecdsa-with-SHA256 + Issuer: CN=CipherHorizon CRL Test Intermediate + Last Update: Jun 1 00:00:00 2026 GMT + Next Update: Jan 1 00:00:00 2045 GMT + CRL extensions: + X509v3 Authority Key Identifier: + 78:E9:7F:4C:14:25:3B:FD:2B:AB:B9:32:D3:EB:B7:F9:05:7B:CC:7F + X509v3 CRL Number: + 2 +Revoked Certificates: + Serial Number: 2001 + Revocation Date: May 1 00:00:00 2026 GMT + CRL entry extensions: + X509v3 CRL Reason Code: + Key Compromise + Signature Algorithm: ecdsa-with-SHA256 + Signature Value: + 30:45:02:21:00:b5:47:bb:93:db:4f:ee:74:78:19:44:ba:09: + 39:82:95:64:e5:5b:6a:ed:f0:0a:df:90:2c:c9:3a:fd:51:ba: + 4c:02:20:5a:af:9e:04:26:e9:e6:ce:ef:72:15:8d:03:b9:71: + a1:f4:f3:03:96:6e:57:47:63:db:dc:58:f3:5d:85:a0:4c diff --git a/internal/pki/testdata/crl/impostor.pem b/internal/pki/testdata/crl/impostor.pem new file mode 100644 index 0000000..f23a1ba --- /dev/null +++ b/internal/pki/testdata/crl/impostor.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBjDCCATOgAwIBAgIBAzAKBggqhkjOPQQDAjAuMSwwKgYDVQQDDCNDaXBoZXJI +b3Jpem9uIENSTCBUZXN0IEludGVybWVkaWF0ZTAeFw0yNTAxMDEwMDAwMDBaFw00 +NTAxMDEwMDAwMDBaMC4xLDAqBgNVBAMMI0NpcGhlckhvcml6b24gQ1JMIFRlc3Qg +SW50ZXJtZWRpYXRlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEQ2J4ZLFTaRhs +e+cBs81sIVBFzEpj5zVnIY6ONaHSiZ/L+A7VRdkxHrDYZ9IGApQAvJBS58dXholU +HmVkEgjd/qNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYD +VR0OBBYEFHjpf0wUJTv9K6u5MtPrt/kFe8x/MAoGCCqGSM49BAMCA0cAMEQCIHEV +cPePvMV/2yQj4lyzfbS/9+oy5UzN+GwYEXJBAIqCAiAGnDTbgYtUgilCbdCr6pHu +vvDyiCr/JUCNs60N/3IcwA== +-----END CERTIFICATE----- diff --git a/internal/pki/testdata/crl/int-base.crl b/internal/pki/testdata/crl/int-base.crl new file mode 100644 index 0000000000000000000000000000000000000000..e79ca0b1d3d4bb68cf964ab69dfc835dc426515b GIT binary patch literal 285 zcmXqLVw5y!Jix@rXu!qBq1EPb&X$Fl$w1Fg$3TmXIh2K&N7*^EAS1QNBfluKDnC!b zImkyLB(=Ci!85NUwJ0|=B{Q)kRh-wz%)r#Z&;Sgg#Cc6X92j>&qCvcYJkT^*Wflno zu?CUerJN~wN6ahEzOQR-*Q%df70>5qU;t7e!^rrbMa@8Ef&2p5HtCF#k^(Dz{p6w? zy@Kpay%M0c`k8qpdO(3j#{WPE19gZ1or~&tW)B7fS0+V`;=HY{x@83+{RVV)Y G!D|2~nq0*I literal 0 HcmV?d00001 diff --git a/internal/pki/testdata/crl/int-delta.crl b/internal/pki/testdata/crl/int-delta.crl new file mode 100644 index 0000000000000000000000000000000000000000..1a43a1eed80044e1958d859b82981b081efe5a86 GIT binary patch literal 335 zcmXqLV)Qm>{LIA2Xu!$FuGQvo&X$GIK+jOeK#Pqzl!ci`**UWyBelpQzbLaRKTp9q z$VVY0wYWsVGp{7IC^t1FGqEI9oY%R$bi(V8K^9fUm)8iol#O!V5P60T$H0%ke#Vl0(7c=W?qRNP@s|V zKg326knv1N{$=)HFmPp3a1LwYopYaMnd;rOd$^kNKeb67cQ|&G^V$|`=|#CAU#>7I oG8}lH9bhB?VRGSQewdOYwq<;hMUhT8rD4k0M=e@oCbCWXY6o6oCtw>El5GQ4YAdOU8v_(GGg{!*9z{~at- IzBYdW0GIGu5&!@I literal 0 HcmV?d00001 diff --git a/internal/pki/testdata/crl/int-empty.txt b/internal/pki/testdata/crl/int-empty.txt new file mode 100644 index 0000000..292dd70 --- /dev/null +++ b/internal/pki/testdata/crl/int-empty.txt @@ -0,0 +1,23 @@ +Certificate Revocation List (CRL): + Version 2 (0x1) + Signature Algorithm: ecdsa-with-SHA256 + Issuer: CN=CipherHorizon CRL Test Intermediate + Last Update: Jun 1 00:00:00 2026 GMT + Next Update: Jan 1 00:00:00 2045 GMT + CRL extensions: + X509v3 Authority Key Identifier: + 8D:A5:09:64:6E:C4:37:78:CD:EF:7E:85:87:2A:7F:93:7A:5F:0E:4E + X509v3 Issuing Distribution Point: critical + Full Name: + URI:http://crl.pki.test/int.crl + Only User Certificates + + X509v3 CRL Number: + 2 +No Revoked Certificates. + Signature Algorithm: ecdsa-with-SHA256 + Signature Value: + 30:44:02:20:0f:b5:4e:93:13:e8:47:b4:8e:b2:da:fe:eb:31: + 2c:33:11:4d:2c:46:93:16:88:cf:92:06:f0:ee:ff:b4:52:96: + 02:20:61:a9:b3:cf:26:8b:85:81:4b:59:00:ee:82:a5:c7:5e: + 9f:17:a1:34:56:8f:75:44:8f:ff:88:04:94:f5:83:f4 diff --git a/internal/pki/testdata/crl/int-partition.crl b/internal/pki/testdata/crl/int-partition.crl new file mode 100644 index 0000000000000000000000000000000000000000..8ace23fecc444e0231965658e18033cfe5bbc916 GIT binary patch literal 285 zcmXqLVw5y!+|R_wXu!qBq1EPb&X$Fl$w1Fg$3TmXIh2K&N7*^EAS1QNBfluKDnC!b zImkyLB(=Ci!85NUwJ0|=B{Q)kRh-wz%)rdR&;Sgg#Cc6jfgBijL4rY?fjrPOS!EUp z1F;5?-ld!=c}L7E&c3f}ZP%)wToupfXP^&KAj8P`pGDO`d4b{r`8L^%l9B=|ef{L3 z9KC|Kp~)eff_`Bu4QCGc002tgMk~9BE!^u6ML_*+;aLM8zy9$ zdr7P2>?5npDJQwkt9xZ>x)tjKm7J)k)Z1mTWV68i+gH!X={rZ6JZ+e;dijnBsr;0R Hzk+c9PI^|? literal 0 HcmV?d00001 diff --git a/internal/pki/testdata/crl/int-partition.txt b/internal/pki/testdata/crl/int-partition.txt new file mode 100644 index 0000000..632710b --- /dev/null +++ b/internal/pki/testdata/crl/int-partition.txt @@ -0,0 +1,22 @@ +Certificate Revocation List (CRL): + Version 2 (0x1) + Signature Algorithm: ecdsa-with-SHA256 + Issuer: CN=CipherHorizon CRL Test Intermediate + Last Update: Jun 1 00:00:00 2026 GMT + Next Update: Jan 1 00:00:00 2045 GMT + CRL extensions: + X509v3 Authority Key Identifier: + 8D:A5:09:64:6E:C4:37:78:CD:EF:7E:85:87:2A:7F:93:7A:5F:0E:4E + X509v3 Issuing Distribution Point: critical + Full Name: + URI:http://crl.pki.test/int-2.crl + + X509v3 CRL Number: + 2 +No Revoked Certificates. + Signature Algorithm: ecdsa-with-SHA256 + Signature Value: + 30:46:02:21:00:95:be:91:8d:d6:04:da:42:f8:1d:56:12:35: + 6d:d2:2a:7c:cd:e2:3a:69:94:c9:0a:cf:27:4a:6a:29:46:73: + 2f:02:21:00:c8:78:79:2e:ba:38:a4:b3:10:df:db:d5:cc:1e: + 2f:43:5a:34:e5:80:90:ab:a7:b8:58:1a:6f:64:78:fd:11:5e diff --git a/internal/pki/testdata/crl/int-revoked.crl b/internal/pki/testdata/crl/int-revoked.crl new file mode 100644 index 0000000000000000000000000000000000000000..0620392b5831d96c92ad8e77aa9229fe4672dc82 GIT binary patch literal 322 zcmXqLVze`8e9FYgXu!qBq1EPb&X$Fl$w1Fg$3TmXIh2K&N7*^EAS1QNBfluKDnC!b zImkyLB(=Ci!85NUwJ0|=B{Q)kRh-wz%)rdR&;Sgg#Cc6jfgBjuK-oZ%iAjMGtO_In zQDwjbw30bgR+NRAi;;0bqCvcYJczH%B4HrbAkw>(GbQhcdBxfHb*=4M^^>dO`TPtF zKni3S8UM4W8K^9fUm)8iol#O!V5P60T$H0%ke#Vl0(6^xW?qRNP@s|VKg0?Vpr06- zkbTJP!C>IZq{wjj@PQ*Waqpk_-AGc}qx9yr*Z*9l-(SzvEn;Q6xot{EB9nrSX!d8# g&fZX&>GN5i#u&PM5wfvg9CVoyvxMMXu!qBq1EPb&X$Fl$w19e#XyOTIh2K&N7^~FAS1QNBfluKDnC!b zImkyLB(=CiAt*n;M4Z>i%)rdR&;Sgg#Cc6jfgBjuK*>OXiIEAc$kfmjs>px`XcKd& ztSAdJ7bDYxB!dJ4c@STjMZ!R=K_vI}uDQ2={Lgu{(CwC=;EtF4a=}Lp3_%KH7#aVw zs2iv*P*@<>CX-Q8QedU8pInrqSCE~lR|0gBei6_~dO(pT#{UpIM1X!_WJ2~Cvj>BL zE0aQve*4Rf3d~b#jdg3H&#(!+-f*}2AiqJyk@rc<|EAsE&!ouk>2hbT`9jz8S7Me- antw%d`%kw|w+m+b&t|ybf9dLFs}KOej$riw literal 0 HcmV?d00001 diff --git a/internal/pki/testdata/crl/root-revoked.txt b/internal/pki/testdata/crl/root-revoked.txt new file mode 100644 index 0000000..b8cfd56 --- /dev/null +++ b/internal/pki/testdata/crl/root-revoked.txt @@ -0,0 +1,28 @@ +Certificate Revocation List (CRL): + Version 2 (0x1) + Signature Algorithm: ecdsa-with-SHA256 + Issuer: CN=CipherHorizon CRL Test Root + Last Update: Jun 1 00:00:00 2026 GMT + Next Update: Jan 1 00:00:00 2045 GMT + CRL extensions: + X509v3 Authority Key Identifier: + 6D:EB:BA:9D:DB:F8:FF:6C:EA:A1:46:DA:4E:11:B8:E9:0F:1E:53:C5 + X509v3 Issuing Distribution Point: critical + Full Name: + URI:http://crl.pki.test/root.crl + Only CA Certificates + + X509v3 CRL Number: + 2 +Revoked Certificates: + Serial Number: 02 + Revocation Date: May 15 00:00:00 2026 GMT + CRL entry extensions: + X509v3 CRL Reason Code: + CA Compromise + Signature Algorithm: ecdsa-with-SHA256 + Signature Value: + 30:45:02:20:7c:2f:87:e9:b1:20:03:94:7d:33:2d:7c:5b:cc: + 06:10:eb:b0:dd:7b:c1:0f:30:78:c4:ef:62:a7:fd:66:db:bf: + 02:21:00:f2:d3:89:6d:37:a1:45:cf:d4:5c:a4:92:9f:d4:19: + b7:f9:46:f2:db:70:9b:4f:9b:00:d0:4f:d2:d5:d3:3a:54 diff --git a/internal/pki/testdata/crl/root.pem b/internal/pki/testdata/crl/root.pem new file mode 100644 index 0000000..eb01361 --- /dev/null +++ b/internal/pki/testdata/crl/root.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBfTCCASOgAwIBAgIBATAKBggqhkjOPQQDAjAmMSQwIgYDVQQDDBtDaXBoZXJI +b3Jpem9uIENSTCBUZXN0IFJvb3QwHhcNMjUwMTAxMDAwMDAwWhcNNDUwMTAxMDAw +MDAwWjAmMSQwIgYDVQQDDBtDaXBoZXJIb3Jpem9uIENSTCBUZXN0IFJvb3QwWTAT +BgcqhkjOPQIBBggqhkjOPQMBBwNCAASTwQgJlD3twjMsstgRWv7kdRowANoTUNSg +/sGn91+lPBZ+3+DymP2cUSfcADMdBiRpgA/II54Y12DxkbBJcSdlo0IwQDAPBgNV +HRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUbeu6ndv4/2zq +oUbaThG46Q8eU8UwCgYIKoZIzj0EAwIDSAAwRQIhAN6VlAtcZrG4T/04tKiJoMGY +kG5F2Jyc3D/ukahwexQWAiB7gGR+NBv1EAhdqv6EcbMFpDv/OKo0TVLFS1/FlFCS +rA== +-----END CERTIFICATE----- diff --git a/internal/refdata/refdata.go b/internal/refdata/refdata.go index 329a8e5..2050779 100644 --- a/internal/refdata/refdata.go +++ b/internal/refdata/refdata.go @@ -20,6 +20,7 @@ package refdata import ( + "crypto/x509" "encoding/base64" "sync" "time" @@ -177,3 +178,19 @@ func Sources() []Source { m.Count, c.Count, d.Count, l.Count, t.Count = len(mozillaRoots), len(chromeRoots), len(dhPrimes), len(ctLogs), len(lifetimeLimits) return []Source{m, c, l, d, t, distrustSource()} } + +// RootCertificates returns every root of the bundled stores once, for checking +// the signature of a CRL issued by a root. +func RootCertificates() []*x509.Certificate { + seen := map[string]bool{} + var out []*x509.Certificate + for _, s := range Stores() { + for _, c := range s.Certificates() { + if fp := pki.Fingerprint(c); !seen[fp] { + seen[fp] = true + out = append(out, c) + } + } + } + return out +} diff --git a/internal/revocation/revocation.go b/internal/revocation/revocation.go new file mode 100644 index 0000000..4c6c39b --- /dev/null +++ b/internal/revocation/revocation.go @@ -0,0 +1,314 @@ +// Package revocation fetches the CRLs that certificates name, for the opt-in +// revocation stage of a TLS scan (CRYPTO-REVIEW-004 P1b). It is the only code +// in a scan that contacts a host other than the target, so it is careful: +// +// - The addresses come from certificates the target sent, so they are +// hostile. Only plain http URLs (RFC 5280 §4.2.1.13; the CA/Browser Forum +// requires http for CRLs) without credentials are fetched, and Policy is +// enforced on the real remote address of every connection, redirects +// included: loopback, link-local (cloud metadata), private, shared and +// unspecified addresses are refused unless the operator allows them. +// - Bodies are capped (MaxBytes), requests are timed out, no proxy from the +// environment is used, and nothing from the response is shown verbatim. +// - Results are cached by URL until the CRL's nextUpdate (at most a day), and +// failures for a few minutes, so repeated scans do not hammer a CA. +// +// It only fetches and parses; pki.JudgeCRL decides what a CRL says. +package revocation + +import ( + "context" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "syscall" + "time" +) + +// Defaults. +const ( + DefaultMaxBytes = 16 << 20 + DefaultTimeout = 10 * time.Second + maxURLLen = 2048 + maxRedirects = 3 + maxCache = 512 + errorTTL = 5 * time.Minute + maxTTL = 24 * time.Hour + minTTL = 5 * time.Minute +) + +// ErrBlocked is wrapped by every refusal of an address or URL. +var ErrBlocked = errors.New("CRL address not allowed") + +// Policy says which remote addresses a CRL fetch may connect to. +type Policy struct { + // AllowPrivate allows RFC 1918, unique-local (fc00::/7) and shared + // (100.64.0.0/10) addresses: an internal CA's CRL server. Off by default, + // because any certificate a scanned server presents can name one. + AllowPrivate bool + // AllowLoopback allows this machine (tests, and a CA run on the same host). + AllowLoopback bool +} + +// metadata addresses outside the always-refused ranges (kept in step with the +// scan guard's list in internal/server/guard.go). +var metadata = []net.IP{ + net.ParseIP("fd00:ec2::254"), net.ParseIP("100.100.100.200"), net.ParseIP("192.0.0.192"), + net.ParseIP("255.255.255.255"), net.ParseIP("168.63.129.16"), net.ParseIP("fd00:ec2::23"), +} + +var ( + thisNetwork = mustCIDR("0.0.0.0/8") + shared = mustCIDR("100.64.0.0/10") + nat64 = mustCIDR("64:ff9b::/96") +) + +func mustCIDR(s string) *net.IPNet { + _, n, err := net.ParseCIDR(s) + if err != nil { + panic(err) + } + return n +} + +// CheckIP returns nil when a CRL fetch may connect to ip. +func (p Policy) CheckIP(ip net.IP) error { + if ip != nil && ip.To4() == nil && nat64.Contains(ip) { + return p.CheckIP(ip.To16()[12:16]) + } + switch { + case ip == nil: + return fmt.Errorf("%w: not an IP address", ErrBlocked) + case ip.IsUnspecified() || thisNetwork.Contains(ip): + return fmt.Errorf("%w: %s is the unspecified address", ErrBlocked, ip) + case ip.IsMulticast() || ip.IsInterfaceLocalMulticast() || ip.IsLinkLocalMulticast(): + return fmt.Errorf("%w: %s is a multicast address", ErrBlocked, ip) + case ip.IsLinkLocalUnicast(): + return fmt.Errorf("%w: %s is link-local (this range includes cloud metadata services)", ErrBlocked, ip) + case ip.IsLoopback() && !p.AllowLoopback: + return fmt.Errorf("%w: %s is a loopback address", ErrBlocked, ip) + case (ip.IsPrivate() || shared.Contains(ip)) && !p.AllowPrivate: + return fmt.Errorf("%w: %s is a private address (allow private CRL addresses to fetch an internal CA's CRL)", ErrBlocked, ip) + } + for _, m := range metadata { + if m.Equal(ip) { + return fmt.Errorf("%w: %s is a known cloud-metadata address", ErrBlocked, ip) + } + } + return nil +} + +func (p Policy) control(_, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return fmt.Errorf("%w: %w", ErrBlocked, err) + } + return p.CheckIP(net.ParseIP(host)) +} + +// CheckURL refuses anything but a plain http URL with a host and no credentials. +func CheckURL(raw string) (*url.URL, error) { + if len(raw) > maxURLLen { + return nil, fmt.Errorf("%w: the URL is longer than %d bytes", ErrBlocked, maxURLLen) + } + u, err := url.Parse(raw) + switch { + case err != nil: + return nil, fmt.Errorf("%w: not a URL", ErrBlocked) + case !strings.EqualFold(u.Scheme, "http"): + return nil, fmt.Errorf("%w: only http CRL addresses are fetched (this one is %q)", ErrBlocked, u.Scheme) + case u.User != nil: + return nil, fmt.Errorf("%w: the URL carries credentials", ErrBlocked) + case u.Hostname() == "": + return nil, fmt.Errorf("%w: the URL has no host", ErrBlocked) + } + return u, nil +} + +// Fetched is one CRL as retrieved. +type Fetched struct { + List *x509.RevocationList + SHA256 string + Size int + FetchedAt time.Time +} + +// Fetcher retrieves CRLs. It is safe for concurrent use; share one per process +// so its cache works across scans. +type Fetcher struct { + Policy Policy + MaxBytes int64 + Timeout time.Duration + Now func() time.Time + + once sync.Once + client *http.Client + + mu sync.Mutex + cache map[string]cacheEntry + inflight map[string]chan struct{} +} + +type cacheEntry struct { + f *Fetched + err error + expires time.Time +} + +// New returns a Fetcher with the default limits. +func New(p Policy) *Fetcher { return &Fetcher{Policy: p} } + +func (f *Fetcher) init() { + f.once.Do(func() { + if f.MaxBytes <= 0 { + f.MaxBytes = DefaultMaxBytes + } + if f.Timeout <= 0 { + f.Timeout = DefaultTimeout + } + if f.Now == nil { + f.Now = time.Now + } + d := &net.Dialer{Timeout: f.Timeout, Control: f.Policy.control} + tr := &http.Transport{ + Proxy: nil, // never an environment proxy: the guard must see the real address + DialContext: d.DialContext, + ResponseHeaderTimeout: f.Timeout, + MaxResponseHeaderBytes: 64 << 10, + MaxIdleConns: 8, + IdleConnTimeout: 30 * time.Second, + DisableCompression: true, + } + f.client = &http.Client{Transport: tr, Timeout: f.Timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= maxRedirects { + return fmt.Errorf("%w: more than %d redirects", ErrBlocked, maxRedirects) + } + _, err := CheckURL(req.URL.String()) + return err + }} + f.cache = map[string]cacheEntry{} + f.inflight = map[string]chan struct{}{} + }) +} + +// Get returns the CRL at raw, from the cache when it is still fresh. Callers +// fetching the same URL at once share one request. +func (f *Fetcher) Get(ctx context.Context, raw string) (*Fetched, error) { + f.init() + for { + f.mu.Lock() + if e, ok := f.cache[raw]; ok && f.Now().Before(e.expires) { + f.mu.Unlock() + return e.f, e.err + } + if ch, busy := f.inflight[raw]; busy { + f.mu.Unlock() + select { + case <-ch: + continue + case <-ctx.Done(): + return nil, ctx.Err() + } + } + ch := make(chan struct{}) + f.inflight[raw] = ch + f.mu.Unlock() + + got, err := f.fetch(ctx, raw) + f.mu.Lock() + delete(f.inflight, raw) + close(ch) + if ctx.Err() == nil { // a cancelled scan says nothing about the CA + f.store(raw, got, err) + } + f.mu.Unlock() + return got, err + } +} + +// store caches a result; f.mu is held. +func (f *Fetcher) store(raw string, got *Fetched, err error) { + now := f.Now() + exp := now.Add(errorTTL) + if err == nil { + exp = now.Add(maxTTL) + if nu := got.List.NextUpdate; !nu.IsZero() && nu.Before(exp) { + exp = nu + } + if exp.Before(now.Add(minTTL)) { + exp = now.Add(minTTL) + } + } + if len(f.cache) >= maxCache { + var oldest string + var at time.Time + for k, e := range f.cache { + if oldest == "" || e.expires.Before(at) { + oldest, at = k, e.expires + } + } + delete(f.cache, oldest) + } + f.cache[raw] = cacheEntry{f: got, err: err, expires: exp} +} + +func (f *Fetcher) fetch(ctx context.Context, raw string) (*Fetched, error) { + u, err := CheckURL(raw) + if err != nil { + return nil, err + } + if ip := net.ParseIP(u.Hostname()); ip != nil { + if err := f.Policy.CheckIP(ip); err != nil { + return nil, err + } + } + ctx, cancel := context.WithTimeout(ctx, f.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrBlocked, err) + } + req.Header.Set("Accept", "application/pkix-crl, application/x-pkcs7-crl;q=0.5, */*;q=0.1") + resp, err := f.client.Do(req) + if err != nil { + var ue *url.Error + if errors.As(err, &ue) { + err = ue.Err // the URL is already known; keep the cause + } + return nil, fmt.Errorf("fetching the CRL failed: %w", err) + } + defer resp.Body.Close() //nolint:errcheck // read-only + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("the CRL server answered HTTP %d", resp.StatusCode) + } + if resp.ContentLength > f.MaxBytes { + return nil, fmt.Errorf("the CRL is %d bytes, more than the %d-byte limit", resp.ContentLength, f.MaxBytes) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, f.MaxBytes+1)) + if err != nil { + return nil, fmt.Errorf("reading the CRL failed: %w", err) + } + if int64(len(body)) > f.MaxBytes { + return nil, fmt.Errorf("the CRL is more than the %d-byte limit", f.MaxBytes) + } + der := body + if blk, _ := pem.Decode(body); blk != nil && blk.Type == "X509 CRL" { + der = blk.Bytes + } + rl, err := x509.ParseRevocationList(der) + if err != nil { + return nil, fmt.Errorf("the response is not a CRL (%d bytes)", len(body)) + } + sum := sha256.Sum256(der) + return &Fetched{List: rl, SHA256: hex.EncodeToString(sum[:]), Size: len(der), FetchedAt: f.Now().UTC()}, nil +} diff --git a/internal/revocation/revocation_test.go b/internal/revocation/revocation_test.go new file mode 100644 index 0000000..f70cb34 --- /dev/null +++ b/internal/revocation/revocation_test.go @@ -0,0 +1,196 @@ +package revocation + +import ( + "context" + "encoding/pem" + "errors" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func fixture(t *testing.T, name string) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join("..", "pki", "testdata", "crl", name+".crl")) + if err != nil { + t.Fatal(err) + } + return b +} + +func TestPolicy(t *testing.T) { + strict, private, loop := Policy{}, Policy{AllowPrivate: true}, Policy{AllowLoopback: true} + for _, c := range []struct { + ip string + strict, private, loopOK bool + }{ + {"8.8.8.8", true, true, true}, + {"2001:4860:4860::8888", true, true, true}, + {"10.1.2.3", false, true, false}, + {"172.16.0.1", false, true, false}, + {"192.168.1.1", false, true, false}, + {"100.64.0.1", false, true, false}, + {"fd00::1", false, true, false}, + {"64:ff9b::a01:203", false, true, false}, // NAT64 of 10.1.2.3 + {"127.0.0.1", false, false, true}, + {"::1", false, false, true}, + {"169.254.169.254", false, false, false}, + {"fe80::1", false, false, false}, + {"0.0.0.0", false, false, false}, + {"0.1.2.3", false, false, false}, + {"224.0.0.1", false, false, false}, + {"168.63.129.16", false, false, false}, + {"100.100.100.200", false, false, false}, + } { + ip := net.ParseIP(c.ip) + for _, p := range []struct { + pol Policy + want bool + name string + }{{strict, c.strict, "strict"}, {private, c.private, "private"}, {loop, c.loopOK, "loopback"}} { + err := p.pol.CheckIP(ip) + if (err == nil) != p.want || (err != nil && !errors.Is(err, ErrBlocked)) { + t.Errorf("%s under %s policy: %v", c.ip, p.name, err) + } + } + } +} + +func TestCheckURL(t *testing.T) { + for _, ok := range []string{"http://crl.example.com/a.crl", "HTTP://crl.example.com:8080/x?y=1"} { + if _, err := CheckURL(ok); err != nil { + t.Errorf("%s: %v", ok, err) + } + } + for _, bad := range []string{"https://crl.example.com/a.crl", "ldap://ldap.example.com/cn=CA?certificateRevocationList", "file:///etc/passwd", + "http://user:pw@crl.example.com/a.crl", "http:///nohost.crl", "gopher://x/", "http://" + strings.Repeat("a", 2100) + ".com/"} { + if _, err := CheckURL(bad); !errors.Is(err, ErrBlocked) { + t.Errorf("%.60s: accepted (%v)", bad, err) + } + } +} + +// server serves body (counting requests) at /crl and redirects elsewhere. +func server(t *testing.T, body []byte) (*httptest.Server, *atomic.Int32) { + t.Helper() + var n atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/crl", func(w http.ResponseWriter, _ *http.Request) { n.Add(1); _, _ = w.Write(body) }) + mux.HandleFunc("/pem", func(w http.ResponseWriter, _ *http.Request) { + n.Add(1) + _ = pem.Encode(w, &pem.Block{Type: "X509 CRL", Bytes: body}) + }) + mux.HandleFunc("/missing", func(w http.ResponseWriter, _ *http.Request) { n.Add(1); http.NotFound(w, nil) }) + mux.HandleFunc("/html", func(w http.ResponseWriter, _ *http.Request) { + n.Add(1) + _, _ = w.Write([]byte("not a crl")) + }) + mux.HandleFunc("/to-https", func(w http.ResponseWriter, r *http.Request) { + n.Add(1) + http.Redirect(w, r, "https://crl.example.com/a.crl", http.StatusFound) + }) + mux.HandleFunc("/to-crl", func(w http.ResponseWriter, r *http.Request) { n.Add(1); http.Redirect(w, r, "/crl", http.StatusFound) }) + mux.HandleFunc("/loop", func(w http.ResponseWriter, r *http.Request) { n.Add(1); http.Redirect(w, r, "/loop", http.StatusFound) }) + s := httptest.NewServer(mux) + t.Cleanup(s.Close) + return s, &n +} + +func TestFetcherRefusesLoopbackByDefault(t *testing.T) { + s, n := server(t, fixture(t, "int-empty")) + _, err := New(Policy{}).Get(context.Background(), s.URL+"/crl") + if !errors.Is(err, ErrBlocked) || n.Load() != 0 { + t.Fatalf("loopback fetched under the default policy: %v (requests %d)", err, n.Load()) + } + // A name that resolves to loopback is refused at connect time, after DNS. + _, port, _ := net.SplitHostPort(strings.TrimPrefix(s.URL, "http://")) + if _, err := New(Policy{}).Get(context.Background(), "http://localhost:"+port+"/crl"); !errors.Is(err, ErrBlocked) || n.Load() != 0 { + t.Fatalf("localhost fetched under the default policy: %v", err) + } +} + +func TestFetcherFetchesParsesAndCaches(t *testing.T) { + der := fixture(t, "int-empty") + s, n := server(t, der) + f := New(Policy{AllowLoopback: true}) + now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + f.Now = func() time.Time { return now } + + got, err := f.Get(context.Background(), s.URL+"/crl") + if err != nil || got.List == nil || got.Size != len(der) || len(got.SHA256) != 64 || !got.FetchedAt.Equal(now) { + t.Fatalf("fetch: %+v %v", got, err) + } + if _, err := f.Get(context.Background(), s.URL+"/crl"); err != nil || n.Load() != 1 { + t.Fatalf("second fetch not cached: %v, %d requests", err, n.Load()) + } + now = now.Add(maxTTL + time.Minute) // past the cache's one-day ceiling + if _, err := f.Get(context.Background(), s.URL+"/crl"); err != nil || n.Load() != 2 { + t.Fatalf("expired entry not refetched: %d requests", n.Load()) + } + if p, err := f.Get(context.Background(), s.URL+"/pem"); err != nil || p.SHA256 != got.SHA256 { + t.Errorf("PEM CRL: %+v %v", p, err) + } + if r, err := f.Get(context.Background(), s.URL+"/to-crl"); err != nil || r.SHA256 != got.SHA256 { + t.Errorf("redirect within http: %v", err) + } +} + +func TestFetcherFailures(t *testing.T) { + s, n := server(t, fixture(t, "int-empty")) + f := New(Policy{AllowLoopback: true}) + for path, want := range map[string]string{ + "/missing": "HTTP 404", + "/html": "not a CRL", + "/to-https": "only http", + "/loop": "redirects", + } { + if _, err := f.Get(context.Background(), s.URL+path); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: %v, want %q", path, err, want) + } + } + before := n.Load() + if _, err := f.Get(context.Background(), s.URL+"/missing"); err == nil || n.Load() != before { + t.Errorf("a failure was not cached briefly: %d -> %d", before, n.Load()) + } + small := New(Policy{AllowLoopback: true}) + small.MaxBytes = 100 + if _, err := small.Get(context.Background(), s.URL+"/crl"); err == nil || !strings.Contains(err.Error(), "limit") { + t.Errorf("oversized CRL: %v", err) + } +} + +func TestFetcherSharesOneRequest(t *testing.T) { + der := fixture(t, "int-empty") + var n atomic.Int32 + release := make(chan struct{}) + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + n.Add(1) + <-release + _, _ = w.Write(der) + })) + defer s.Close() + f := New(Policy{AllowLoopback: true}) + var wg sync.WaitGroup + for range 5 { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := f.Get(context.Background(), s.URL+"/crl"); err != nil { + t.Error(err) + } + }() + } + time.Sleep(50 * time.Millisecond) + close(release) + wg.Wait() + if n.Load() != 1 { + t.Errorf("%d requests for one URL", n.Load()) + } +} diff --git a/internal/server/api.go b/internal/server/api.go index b6dad99..1cdbcce 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -84,6 +84,7 @@ func (s *Server) about(w http.ResponseWriter, _ *http.Request) { "authRequired": s.signInOn() || s.cfg.Token != "", "signInRequired": s.signInOn(), "automationToken": s.cfg.Token != "", "httpChecks": s.cfg.Runner.HTTPChecks(), + "crlLookups": s.cfg.Runner.CRLLookups(), "intervals": []int{0, 3600, 21600, 86400, 604800}, "notice": "This tool connects to the endpoints you add and makes a few hundred short TLS handshakes to each. " + "Scan only systems you own or are authorised to test.", diff --git a/internal/server/guard_internal_test.go b/internal/server/guard_internal_test.go new file mode 100644 index 0000000..e743849 --- /dev/null +++ b/internal/server/guard_internal_test.go @@ -0,0 +1,19 @@ +package server + +import ( + "testing" + + "github.com/offsetsecurity/cipherhorizon/internal/revocation" +) + +// The CRL fetcher keeps its own copy of the cloud-metadata addresses (it must not import the +// server). Even with private and loopback addresses allowed it must refuse every address the +// scan guard refuses as metadata. +func TestCRLPolicyRefusesEveryMetadataAddress(t *testing.T) { + loose := revocation.Policy{AllowPrivate: true, AllowLoopback: true} + for _, ip := range extraBlocked { + if err := loose.CheckIP(ip); err == nil { + t.Errorf("the CRL policy allows %s, which the scan guard blocks", ip) + } + } +} diff --git a/internal/server/runner.go b/internal/server/runner.go index 5884bba..d766ded 100644 --- a/internal/server/runner.go +++ b/internal/server/runner.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/offsetsecurity/cipherhorizon/internal/refdata" "github.com/offsetsecurity/cipherhorizon/internal/store" "github.com/offsetsecurity/cipherhorizon/internal/tlsprobe" ) @@ -90,6 +91,9 @@ func (r *Runner) Wait() { r.wg.Wait() } // check (HSTS), so the API can tell callers about a server-wide setting. func (r *Runner) HTTPChecks() bool { return r.opts.Scan.HTTPChecks } +// CRLLookups reports whether this runner's scans fetch the CRLs certificates name (--revocation). +func (r *Runner) CRLLookups() bool { return r.opts.Scan.CRL != nil } + // CABundle returns the server-wide private CA bundle. func (r *Runner) CABundle() []AnchorCert { return r.opts.CABundle } @@ -331,6 +335,13 @@ func (r *Runner) execute(parent context.Context, sc store.ScanSummary) { o.Now = r.opts.Now o.Progress = prog.message o.Tick = prog.tick + if o.CRL != nil { + // A CRL issued by a root is checked against the bundled roots and this asset's private anchors. + o.CRLIssuers = refdata.RootCertificates() + if a := r.anchorsFor(asset.ID); a != nil { + o.CRLIssuers = append(o.CRLIssuers, a.Certificates()...) + } + } r.note(sc.ID, r.st.AddEvent(sc.ID, "scan started: "+asset.Address())) rep, err := tlsprobe.Scan(ctx, target, o) diff --git a/internal/server/settings.go b/internal/server/settings.go index b48d4fa..0e2b419 100644 --- a/internal/server/settings.go +++ b/internal/server/settings.go @@ -63,6 +63,7 @@ func (s *Server) getSettings(w http.ResponseWriter, _ *http.Request) { "automationToken": s.cfg.Token != "", "allowLoopback": s.cfg.Guard.AllowLoopback, "scanScope": append([]string{}, s.cfg.Guard.Scope.Entries()...), "httpChecks": s.cfg.Runner.HTTPChecks(), + "crlLookups": s.cfg.Runner.CRLLookups(), "maxConcurrentScans": s.cfg.MaxConcurrentScans, "retentionPerAsset": store.Retention, "sessionIdleMinutes": int(s.sessionIdle().Minutes()), "sessionMaxHours": int(s.sessionMax().Hours()), }, diff --git a/internal/server/starttls_test.go b/internal/server/starttls_test.go index 8e38323..2526472 100644 --- a/internal/server/starttls_test.go +++ b/internal/server/starttls_test.go @@ -98,7 +98,8 @@ func TestEndpointKeepsItsSTARTTLS(t *testing.T) { if c.Section == "http" && c.Result != "not-applicable" { t.Errorf("check %s: %q, want not-applicable", c.ID, c.Result) } - if c.Result == "not-checked" { + // The CRL checks are not checked whenever CRL lookups are off (the default), STARTTLS or not. + if c.Result == "not-checked" && !strings.HasPrefix(c.ID, "REV.") { t.Errorf("check %s was not checked over STARTTLS", c.ID) } } diff --git a/internal/tlsprobe/crl.go b/internal/tlsprobe/crl.go new file mode 100644 index 0000000..e0c9cbb --- /dev/null +++ b/internal/tlsprobe/crl.go @@ -0,0 +1,135 @@ +package tlsprobe + +import ( + "context" + "crypto/x509" + "encoding/base64" + "strings" + "time" + + "github.com/offsetsecurity/cipherhorizon/internal/pki" + "github.com/offsetsecurity/cipherhorizon/internal/revocation" +) + +// CRLFetcher retrieves a CRL by URL; *revocation.Fetcher is the implementation. +type CRLFetcher interface { + Get(ctx context.Context, url string) (*revocation.Fetched, error) +} + +// Bounds on the CRL step, which follows addresses a target's certificates name. +const ( + maxCRLURLsPerCert = 4 + crlStageBudget = 90 * time.Second +) + +var probesCRL = "fetched the CRL each certificate on the served path names (plain HTTP to the CA's CRL server, never the target; private and loopback addresses refused unless allowed), checked its signature against the certificate's issuer, its scope and its dates, and looked up the certificate's serial; no OCSP responder is contacted" + +// crlChecks fills CertResult.CRL for every chain: each certificate on the +// served path, leaf first, is checked against a CRL it names, signed by the +// next certificate up (or, for the top, by an issuer from opts.CRLIssuers). A +// self-signed root, or a served cross-sign of a known anchor, ends the walk: roots +// are not revoked, and nothing above an anchor is on the path clients trust. A CRL that +// cannot answer gives an unknown result, and the next URL is tried. A +// certificate seen in several chains is checked once. +func (p *prober) crlChecks(ctx context.Context, certs []CertResult) { + ctx, cancel := context.WithTimeout(ctx, crlStageBudget) + defer cancel() + done := map[string][]pki.CRLResult{} + // A served certificate with the name and key of a known anchor (a cross-signed copy of a + // root) is where clients' paths end: roots are not revoked, and what is above it is off the + // path they trust. + anchors := map[string]bool{} + for _, r := range p.opts.CRLIssuers { + anchors[string(r.RawSubject)+"\x00"+pki.SPKIFingerprint(r)] = true + } + for i := range certs { + cr := &certs[i] + var der [][]byte + for _, ci := range cr.Chain { + b, err := base64.StdEncoding.DecodeString(ci.DER) + if err != nil || len(b) == 0 { + der = nil + break + } + der = append(der, b) + } + if der == nil { + continue + } + s, err := pki.AnalyzeServed(der) + if err != nil { + continue + } + for pos, idx := range s.Path { + c := s.Certs[idx] + if pki.SelfSigned(c) || (pos > 0 && anchors[string(c.RawSubject)+"\x00"+pki.SPKIFingerprint(c)]) { + break + } + fp := pki.Fingerprint(c) + if prev, ok := done[fp]; ok { + for _, r := range prev { + r.Position = idx + cr.CRL = append(cr.CRL, r) + } + continue + } + var issuer *x509.Certificate + if pos+1 < len(s.Path) { + issuer = s.Certs[s.Path[pos+1]] + } else { + for _, r := range p.opts.CRLIssuers { + if pki.Issued(r, c) { + issuer = r + break + } + } + } + res := p.crlFor(ctx, c, issuer) + for j := range res { + res[j].Position, res[j].SHA256 = idx, fp + } + done[fp] = res + cr.CRL = append(cr.CRL, res...) + } + } +} + +// crlFor checks one certificate against the CRLs it names, stopping at the +// first that answers. +func (p *prober) crlFor(ctx context.Context, c, issuer *x509.Certificate) []pki.CRLResult { + var urls []string + for _, u := range c.CRLDistributionPoints { + if strings.HasPrefix(strings.ToLower(u), "http://") { + urls = append(urls, u) + } + } + if len(urls) == 0 { + if !c.IsCA && len(c.CRLDistributionPoints) == 0 { + return nil // an end-entity certificate may rely on OCSP alone; nothing to look up + } + why := "the certificate names no CRL distribution point" + if len(c.CRLDistributionPoints) > 0 { + why = "the certificate names no http CRL address (only " + strings.Join(c.CRLDistributionPoints, ", ") + ")" + } + return []pki.CRLResult{{Status: pki.CRLUnknown, Problem: why}} + } + if len(urls) > maxCRLURLsPerCert { + urls = urls[:maxCRLURLsPerCert] + } + var out []pki.CRLResult + for _, u := range urls { + p.progress("checking the CRL at " + u) + f, err := p.opts.CRL.Get(ctx, u) + if err != nil { + out = append(out, pki.CRLResult{URL: u, Status: pki.CRLUnknown, Problem: err.Error()}) + continue + } + r := pki.JudgeCRL(f.List, u, c, issuer, p.opts.Now()) + r.FetchedAt, r.Size, r.CRLSHA256 = f.FetchedAt, f.Size, f.SHA256 + out = append(out, r) + if r.Status != pki.CRLUnknown { + break + } + } + return out +} diff --git a/internal/tlsprobe/crl_test.go b/internal/tlsprobe/crl_test.go new file mode 100644 index 0000000..ff7cb66 --- /dev/null +++ b/internal/tlsprobe/crl_test.go @@ -0,0 +1,146 @@ +package tlsprobe_test + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/offsetsecurity/cipherhorizon/internal/pki" + "github.com/offsetsecurity/cipherhorizon/internal/pki/pkitest" + "github.com/offsetsecurity/cipherhorizon/internal/revocation" + "github.com/offsetsecurity/cipherhorizon/internal/tlsprobe" + "github.com/offsetsecurity/cipherhorizon/internal/tlsprobe/testserver" +) + +// fakeCRLs serves CRLs by URL and counts requests. +type fakeCRLs struct { + mu sync.Mutex + lists map[string]*x509.RevocationList + calls map[string]int +} + +func (f *fakeCRLs) Get(_ context.Context, url string) (*revocation.Fetched, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls[url]++ + rl, ok := f.lists[url] + if !ok { + return nil, errors.New("fetching the CRL failed: connection refused") + } + return &revocation.Fetched{List: rl, SHA256: strings.Repeat("ab", 32), Size: len(rl.Raw), FetchedAt: time.Now().UTC()}, nil +} + +func crlTarget(t *testing.T) (*testserver.Server, *pkitest.CA, *fakeCRLs) { + t.Helper() + root := pkitest.NewRoot(t, "CRL Root", pkitest.CAOpts{}) + inter := root.Intermediate(t, "CRL Intermediate", pkitest.CAOpts{CRL: []string{"http://crl.test/root.crl"}}) + now := time.Now() + tc := inter.TLS(t, pkitest.LeafOpts{CN: "127.0.0.1", IPs: nil, DNS: []string{"leaf.test"}, NotBefore: now.Add(-time.Hour), NotAfter: now.Add(24 * time.Hour), + CRL: []string{"ldap://ldap.test/cn=x", "http://crl.test/int.crl"}}, inter.Cert) + fake := &fakeCRLs{calls: map[string]int{}, lists: map[string]*x509.RevocationList{ + "http://crl.test/int.crl": inter.CRL(t, nil, now.Add(-time.Hour), now.Add(7*24*time.Hour)), + "http://crl.test/root.crl": root.CRL(t, []x509.RevocationListEntry{{SerialNumber: inter.Cert.SerialNumber, RevocationTime: now.Add(-48 * time.Hour), ReasonCode: 2}}, + now.Add(-time.Hour), now.Add(7*24*time.Hour)), + }} + srv := testserver.Start(t, testserver.Config{MinVersion: tls.VersionTLS12, Chain: []tls.Certificate{tc}}) + return srv, root, fake +} + +// TestCRLStageChecksTheWholePath: with lookups on, the leaf is checked against its issuer's CRL +// and the intermediate against the root's; the root itself is not checked; a certificate served +// in several chains is fetched once; only the http address is used. +func TestCRLStageChecksTheWholePath(t *testing.T) { + srv, root, fake := crlTarget(t) + o := opts() + o.CRL, o.CRLIssuers = fake, []*x509.Certificate{root.Cert} + rep := scan(t, srv, o) + if !rep.CRLLookups { + t.Fatal("CRLLookups not set") + } + rev := stageMap(rep)["revocation"] + if rev.Outcome != tlsprobe.OutcomeDone || !strings.Contains(strings.Join(rev.Probes, " "), "fetched the CRL") { + t.Errorf("revocation stage %+v", rev) + } + if len(rep.Certificates) == 0 { + t.Fatal("no certificates") + } + for _, cr := range rep.Certificates { + if len(cr.CRL) != 2 { + t.Fatalf("%s: %d CRL results: %+v", cr.Source, len(cr.CRL), cr.CRL) + } + leaf, in := cr.CRL[0], cr.CRL[1] + if leaf.Position != 0 || leaf.Status != pki.CRLGood || leaf.Signature != pki.CRLSigValid || leaf.URL != "http://crl.test/int.crl" || leaf.SHA256 != cr.Chain[0].SHA256 { + t.Errorf("%s leaf: %+v", cr.Source, leaf) + } + if in.Position != 1 || in.Status != pki.CRLRevoked || in.Reason != "cACompromise" || in.Issuer != pki.Fingerprint(root.Cert) { + t.Errorf("%s intermediate: %+v", cr.Source, in) + } + } + for u, n := range fake.calls { + if n != 1 { + t.Errorf("%s fetched %d times", u, n) + } + } +} + +func TestCRLStageWithoutIssuerOrServer(t *testing.T) { + srv, _, fake := crlTarget(t) + delete(fake.lists, "http://crl.test/int.crl") + o := opts() + o.CRL = fake // no CRLIssuers: the root that signs the intermediate's CRL is unknown + rep := scan(t, srv, o) + cr := rep.Certificates[0] + if len(cr.CRL) != 2 { + t.Fatalf("%+v", cr.CRL) + } + if r := cr.CRL[0]; r.Status != pki.CRLUnknown || !strings.Contains(r.Problem, "connection refused") { + t.Errorf("unreachable CRL: %+v", r) + } + if r := cr.CRL[1]; r.Status != pki.CRLUnknown || r.Signature != pki.CRLSigNoIssuer { + t.Errorf("no issuer: %+v", r) + } +} + +// Off by default: no CRL is fetched and the report says lookups did not happen. +func TestCRLStageOffByDefault(t *testing.T) { + srv, _, _ := crlTarget(t) + rep := scan(t, srv, opts()) + if rep.CRLLookups || len(rep.Certificates) == 0 || len(rep.Certificates[0].CRL) != 0 { + t.Errorf("lookups off, yet: %v %+v", rep.CRLLookups, rep.Certificates) + } + if rev := stageMap(rep)["revocation"]; !strings.Contains(rev.Reason, "CRL lookups are off") { + t.Errorf("revocation stage %+v", rev) + } +} + +// A served cross-signed copy of a known root ends the walk: the root is the anchor clients use, +// so the cross-sign's CRL (whose issuer may be unknown) is not looked up. +func TestCRLStageStopsAtAServedAnchor(t *testing.T) { + root := pkitest.NewRoot(t, "CRL Root", pkitest.CAOpts{}) + other := pkitest.NewRoot(t, "Old Root", pkitest.CAOpts{}) + inter := root.Intermediate(t, "CRL Intermediate", pkitest.CAOpts{CRL: []string{"http://crl.test/root.crl"}}) + cross := other.CrossSign(t, root, pkitest.CAOpts{CRL: []string{"http://crl.test/old.crl"}}) + now := time.Now() + tc := inter.TLS(t, pkitest.LeafOpts{DNS: []string{"leaf.test"}, NotBefore: now.Add(-time.Hour), NotAfter: now.Add(24 * time.Hour), + CRL: []string{"http://crl.test/int.crl"}}, inter.Cert, cross) + fake := &fakeCRLs{calls: map[string]int{}, lists: map[string]*x509.RevocationList{ + "http://crl.test/int.crl": inter.CRL(t, nil, now.Add(-time.Hour), now.Add(24*time.Hour)), + "http://crl.test/root.crl": root.CRL(t, nil, now.Add(-time.Hour), now.Add(24*time.Hour)), + }} + srv := testserver.Start(t, testserver.Config{MinVersion: tls.VersionTLS12, Chain: []tls.Certificate{tc}}) + o := opts() + o.CRL, o.CRLIssuers = fake, []*x509.Certificate{root.Cert} + rep := scan(t, srv, o) + cr := rep.Certificates[0] + if len(cr.CRL) != 2 || cr.CRL[0].Status != pki.CRLGood || cr.CRL[1].Status != pki.CRLGood { + t.Errorf("results %+v", cr.CRL) + } + if fake.calls["http://crl.test/old.crl"] != 0 { + t.Error("the cross-sign's CRL was fetched") + } +} diff --git a/internal/tlsprobe/probe.go b/internal/tlsprobe/probe.go index 7280255..dbf010c 100644 --- a/internal/tlsprobe/probe.go +++ b/internal/tlsprobe/probe.go @@ -3,6 +3,7 @@ package tlsprobe import ( "context" "crypto/rand" + "crypto/x509" "errors" "fmt" "io" @@ -41,6 +42,14 @@ type Options struct { // fetches missing intermediates from the network, which would contact a // host other than the target. OSTrust bool + // CRL, when set, makes the revocation stage fetch the CRL each certificate on a + // served path names and judge it (CRYPTO-REVIEW-004 P1b). Off (nil) by default: it + // contacts the CA's CRL server, a host other than the target. The fetcher applies + // its own address policy; Dial and LookupIP are for the target only. + CRL CRLFetcher + // CRLIssuers are CA certificates (root stores, private anchors) that may have + // issued the top of a served path, so its CRL's signature can be checked. + CRLIssuers []*x509.Certificate // Dial opens a connection. Default: net.Dialer. The server's SSRF guard and tests // replace it. STARTTLS runs on top of whatever it returns (Target.StartTLS). diff --git a/internal/tlsprobe/report.go b/internal/tlsprobe/report.go index 69ea9ba..83d3814 100644 --- a/internal/tlsprobe/report.go +++ b/internal/tlsprobe/report.go @@ -11,6 +11,8 @@ package tlsprobe import ( "time" + + "github.com/offsetsecurity/cipherhorizon/internal/pki" ) // SchemaVersion identifies the JSON shape of Report. It changes only with a @@ -83,6 +85,9 @@ type Report struct { // leaf as with SNI), "different" (a separate certificate result with NoSNI // set), "failed", or empty when not attempted (the target is an address). NoSNI string `json:"noSni,omitempty"` + // CRLLookups is set when the scan fetched the CRLs the served certificates name (Options.CRL). + // Without it, an absent CRL result means "not looked up", never "not revoked". + CRLLookups bool `json:"crlLookups,omitempty"` // Stages record, for each part of the scan (the sections of the endpoint // page), whether it ran, why not, and what it cost. @@ -288,6 +293,9 @@ type CertResult struct { OCSPStaple string `json:"ocspStaple,omitempty"` // TLSSCTs are the raw SCTs from the TLS extension (base64), at most 32. TLSSCTs []string `json:"tlsScts,omitempty"` + // CRL is what the CRLs named by each certificate on the served path said, when + // Report.CRLLookups is set: one result per CRL tried, in path order. + CRL []pki.CRLResult `json:"crl,omitempty"` } // CertInfo describes one certificate. diff --git a/internal/tlsprobe/scan.go b/internal/tlsprobe/scan.go index 8b48eac..0013ad3 100644 --- a/internal/tlsprobe/scan.go +++ b/internal/tlsprobe/scan.go @@ -264,8 +264,16 @@ func Scan(ctx context.Context, t Target, opts Options) (rep *Report, err error) rep.Stages = append(rep.Stages, sc.end(StageCerts, OutcomeInconclusive, why, ReasonNotReached, probes), sc.skip(StageRevocation, "no certificate was received", ReasonNotReached)) } else { - rep.Stages = append(rep.Stages, sc.end(StageCerts, OutcomeDone, "", "", probes), - Stage{Name: StageRevocation, Outcome: OutcomeDone, Probes: probesRevocation}) + rev := Stage{Name: StageRevocation, Outcome: OutcomeDone, Probes: probesRevocation} + if opts.CRL != nil { + p.progress("checking CRLs") + p.crlChecks(ctx, rep.Certificates) + rep.CRLLookups = true + rev.Probes = []string{probesRevocationStaple, probesCRL} + } else { + rev.Reason = "CRL lookups are off for this scan, so only a stapled OCSP response can say whether the certificate is revoked" + } + rep.Stages = append(rep.Stages, sc.end(StageCerts, OutcomeDone, "", "", probes), rev) } } diff --git a/internal/tlsprobe/stages.go b/internal/tlsprobe/stages.go index 50177f7..73d2184 100644 --- a/internal/tlsprobe/stages.go +++ b/internal/tlsprobe/stages.go @@ -34,7 +34,8 @@ var ( probesRevocation = []string{ "reads the stapled OCSP response and the SCTs the certificate handshakes returned; no CA, OCSP responder or CT log is contacted", } - probesHTTP = []string{ + probesRevocationStaple = "reads the stapled OCSP response and the SCTs the certificate handshakes returned; no OCSP responder or CT log is contacted" + probesHTTP = []string{ "one GET / over a fresh TLS connection; reads response headers only and follows no redirect", } ) diff --git a/testing/pki/make-crl.sh b/testing/pki/make-crl.sh new file mode 100644 index 0000000..2edfa75 --- /dev/null +++ b/testing/pki/make-crl.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# make-crl.sh builds the CRL test fixtures in internal/pki/testdata/crl with OpenSSL, so the +# producer of every CRL is independent of the code that judges it (CRYPTO-REVIEW-004 P1b). The +# outputs are committed; re-run only to change them. +# +# testing/pki/make-crl.sh # needs OpenSSL 3.x on PATH +# +# PKI (EC P-256, certificates valid 2025-01-01 .. 2045-01-01): +# root.pem the root +# int.pem an intermediate, CRL DP http://crl.pki.test/root.crl +# leaf.pem leaf 0x2001 from int, CRL DP http://crl.pki.test/int.crl +# other.pem leaf 0x2002 from int, same CRL DP +# impostor.pem a CA with int's name and a different key (signs bad.crl) +# CRLs (thisUpdate 2026-06-01, nextUpdate 2045-01-01): +# int-revoked.crl by int: 0x2001 revoked (keyCompromise, 2026-05-01); IDP fullName int.crl, onlyContainsUserCerts +# int-empty.crl by int: nothing revoked; same IDP +# root-revoked.crl by root: int revoked (cACompromise, 2026-05-15); IDP fullName root.crl, onlyContainsCACerts +# int-partition.crl by int: nothing revoked; IDP fullName int-2.crl (a different partition) +# int-delta.crl by int: a delta CRL (openssl crl -gendelta, int-base to int-revoked) +# bad.crl by impostor: int's name, wrong key +# int-base.crl by int: the older CRL (2026-05-01) the delta is computed from +# *.txt `openssl crl -text` of each, the oracle the tests compare against +set -euo pipefail +export MSYS_NO_PATHCONV=1 + +out="$(cd "$(dirname "$0")/../.." && pwd)/internal/pki/testdata/crl" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$out" +cd "$work" + +start=20250101000000Z +end=20450101000000Z +last=20260601000000Z + +key() { openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$1.key"; } + +cat > ext.cnf <<'EOF' +[root] +basicConstraints = critical,CA:TRUE +keyUsage = critical,keyCertSign,cRLSign +subjectKeyIdentifier = hash +[int] +basicConstraints = critical,CA:TRUE,pathlen:0 +keyUsage = critical,keyCertSign,cRLSign +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid +crlDistributionPoints = URI:http://crl.pki.test/root.crl +[leaf] +basicConstraints = critical,CA:FALSE +keyUsage = critical,digitalSignature +extendedKeyUsage = serverAuth +subjectAltName = DNS:leaf.test +authorityKeyIdentifier = keyid +crlDistributionPoints = URI:http://crl.pki.test/int.crl +[other] +basicConstraints = critical,CA:FALSE +keyUsage = critical,digitalSignature +extendedKeyUsage = serverAuth +subjectAltName = DNS:other.test +authorityKeyIdentifier = keyid +crlDistributionPoints = URI:http://crl.pki.test/int.crl +EOF + +sign() { # name subject issuer section serial + key "$1" + openssl req -new -key "$1.key" -subj "$2" -out "$1.csr" + if [ "$3" = self ]; then + openssl x509 -req -in "$1.csr" -signkey "$1.key" -not_before $start -not_after $end \ + -extfile ext.cnf -extensions "$4" -set_serial "$5" -out "$1.pem" + else + openssl x509 -req -in "$1.csr" -CA "$3.pem" -CAkey "$3.key" -not_before $start -not_after $end \ + -extfile ext.cnf -extensions "$4" -set_serial "$5" -out "$1.pem" + fi +} + +sign root "/CN=CipherHorizon CRL Test Root" self root 0x01 +sign int "/CN=CipherHorizon CRL Test Intermediate" root int 0x02 +sign leaf "/CN=leaf.test" int leaf 0x2001 +sign other "/CN=other.test" int other 0x2002 +sign impostor "/CN=CipherHorizon CRL Test Intermediate" self root 0x03 + +subj() { openssl x509 -in "$1" -noout -subject -nameopt compat | sed 's/^subject=//'; } +exp=450101000000Z + +# gencrl NAME CA_CERT CA_KEY INDEX CRL_EXT_SECTION [LAST_UPDATE] [CRL_NUMBER] +gencrl() { + cat > ca-$1.cnf < crlnumber-$1 + openssl ca -gencrl -config ca-$1.cnf -cert "$2" -keyfile "$3" -crl_lastupdate "${6:-$last}" -crl_nextupdate $end -out $1.pem + openssl crl -in $1.pem -outform DER -out $1.crl +} + +printf 'R\t%s\t260501000000Z,keyCompromise\t2001\tunknown\t%s\n' $exp "$(subj leaf.pem)" > idx-int-revoked +printf 'V\t%s\t\t2002\tunknown\t%s\n' $exp "$(subj other.pem)" >> idx-int-revoked +printf 'V\t%s\t\t2001\tunknown\t%s\n' $exp "$(subj leaf.pem)" > idx-int-empty +printf 'R\t%s\t260515000000Z,CACompromise\t02\tunknown\t%s\n' $exp "$(subj int.pem)" > idx-root +: > idx-none + +gencrl int-revoked int.pem int.key idx-int-revoked idp_int +gencrl int-empty int.pem int.key idx-int-empty idp_int +gencrl root-revoked root.pem root.key idx-root idp_root +gencrl int-partition int.pem int.key idx-none idp_part +gencrl int-base int.pem int.key idx-int-empty idp_int 20260501000000Z 01 +# A real delta CRL: what changed between int-base and int-revoked (-in is the base, -gendelta the newer). +openssl crl -in int-base.pem -gendelta int-revoked.pem -key int.key -out int-delta.pem +openssl crl -in int-delta.pem -outform DER -out int-delta.crl +gencrl bad impostor.pem impostor.key idx-int-revoked plain + +# Paths handed to openssl stay relative: on Windows (Git Bash) a native openssl +# cannot open MSYS-style absolute paths. +for f in int-revoked int-empty root-revoked int-partition int-delta bad; do + openssl crl -in $f.crl -inform DER -noout -text > $f.txt +done +cp root.pem int.pem leaf.pem other.pem impostor.pem ./*.crl ./*.txt "$out/" +echo "wrote fixtures to $out" diff --git a/web/static/js/views/help.js b/web/static/js/views/help.js index 0afc84a..dd157d6 100644 --- a/web/static/js/views/help.js +++ b/web/static/js/views/help.js @@ -127,13 +127,15 @@ export async function render(root) { h('dd', null, b.allowed[k].map((e, i) => [i ? ', ' : '', code(e.name)]))])))) : []), h('p', { class: 'text-2' }, 'Every entry’s source and the OpenSSH version that first offers it: ', code('cipherhorizon ssh baselines'), '. The baseline is this product’s own; BSI TR-02102-4 is planned as a second one.')), sec('limits', 'What is and is not checked', - h('p', null, 'Every protocol from SSLv2 to TLS 1.3, every cipher suite and key-exchange group, the certificate chain against the bundled root stores, OCSP stapling, Certificate Transparency, and session features: 58 checks, each listed on the endpoint’s page as found, passed or not applicable. Revocation is shown as status unknown unless the server staples a response that vouches for the certificate: this product never contacts the CA.'), + h('p', null, 'Every protocol from SSLv2 to TLS 1.3, every cipher suite and key-exchange group, the certificate chain against the bundled root stores, OCSP stapling, CRL revocation, Certificate Transparency, and session features: 63 checks, each listed on the endpoint’s page as found, passed, not checked or not applicable. Revocation is shown as status unknown unless a stapled response or, with CRL lookups on, a current CRL from the issuer vouches for the certificate.'), h('p', null, h('b', null, 'One HTTP request. '), 'Every check exchanges only TLS handshake messages except one: after the handshake, a plain ', code('GET /'), ' is sent to read the ', code('Strict-Transport-Security'), ' header (at most 8 KiB, no body kept, no redirect followed). This server has it ', h('b', null, a.httpChecks === false ? 'off' : 'on'), '; ', code('--http-checks=false'), ' turns it off.'), + h('p', null, h('b', null, 'CRL lookups (optional). '), 'With ', code('--revocation'), ' a scan also fetches the CRL each certificate on the served chain names, from the CA’s CRL server: plain HTTP only, at most 16 MiB, never from private, loopback or cloud-metadata addresses unless ', code('--revocation-private'), ' is set. Each certificate is checked against its own issuer’s CRL, up to the root. A revoked certificate is a finding beside the trust verdict, not a reason to stop. This server has them ', + h('b', null, a.crlLookups ? 'on' : 'off'), '.'), h('p', null, h('b', null, 'Not checked:')), h('ul', null, [ - 'Live revocation: the CA’s OCSP responder and CRL are not contacted; a scan talks only to its target.', + 'Live OCSP: the CA’s OCSP responder is never contacted (it would tell the CA which site is being checked). CRLs are fetched only with CRL lookups on.', 'Behavioural vulnerabilities such as Heartbleed or ROBOT: no exploit traffic is sent. Attacks such as POODLE or Sweet32 are inferred from the configuration and labelled as such.', 'Anything reachable only with a client certificate, or only from another network.', ].map((t) => h('li', null, t))), diff --git a/web/static/js/views/report.js b/web/static/js/views/report.js index a30466d..2dc6419 100644 --- a/web/static/js/views/report.js +++ b/web/static/js/views/report.js @@ -6,7 +6,7 @@ import { h, svg, clear, fmtDate, fmtDay, daysUntil } from '../dom.js'; import { levelClass, levelName } from '../charts.js'; import { badge, sevBadge, evidenceBadge, citations, bandTone, bandWord, button } from '../ui.js'; -import { tlsConfiguration, weakestAlgorithm, certHref } from './tls-config.js'; +import { tlsConfiguration, weakestAlgorithm, certHref, crlSummary } from './tls-config.js'; import { bitsBar, gauge, levelStepper, lifetimeBar, stackBar, statusTile } from '../viz.js'; export const SECTIONS = [ @@ -556,10 +556,12 @@ function revocationBody(ctx) { const leaf = cr ? cr.chain[0] : {}; const o = v.ocsp; let ocspLine; + const crl = crlSummary(v, cr); if (!o) { - // Nothing vouches for the certificate, and this scan never contacts the CA: say the status is unknown. + // Nothing stapled vouches for the certificate: the CRL lookups, when they ran, are the only other source. ocspLine = leaf.ocspMustStaple ? 'No OCSP response was stapled, although the certificate requires one (Must-Staple).' - : 'Revocation status unknown: no OCSP response was stapled, and this scan does not contact the CA.'; + : v.crlLookups ? `No OCSP response was stapled. CRL: ${crl.verdict.text}.` + : 'Revocation status unknown: no OCSP response was stapled, and CRL lookups were off for this scan.'; } else if (o.error) { ocspLine = `A stapled OCSP response was sent but is not usable: ${o.error}.`; } else if (!o.forThisCert) { @@ -577,11 +579,19 @@ function revocationBody(ctx) { : v.public ? 'No Certificate Transparency proof was found in any of the three places it can travel.' : 'No Certificate Transparency proof; not required for a privately trusted certificate.', ]; const urls = [...(v.ocspUrls || []).map((u) => ['OCSP', u]), ...(v.crlUrls || []).map((u) => ['CRL', u])]; + const crlTable = crl.rows.length ? h('div', { class: 'tablewrap' }, h('table', { class: 'compact' }, + h('thead', null, h('tr', null, ['Certificate', 'CRL', 'Status', 'Signature'].map((c) => h('th', { scope: 'col' }, c)))), + h('tbody', null, crl.rows.map((r) => h('tr', null, h('td', null, r.who), h('td', null, r.url ? h('code', null, r.url) : 'none named'), + h('td', null, badge(r.tone === 'good' ? 'ok' : r.tone === 'problem' ? 'revoked' : 'check', r.tone === 'good' ? 'good' : r.tone === 'problem' ? 'bad' : 'warn'), ' ', r.text), + h('td', null, { valid: 'signed by the issuer', invalid: 'does NOT verify', 'no-issuer': 'issuer not available' }[r.signature] || 'not read')))))) + : h('p', { class: 'muted' }, 'The certificates name no CRL to check.'); const lead = h('div', null, - h('h3', null, 'Revocation sources the certificate names'), - urls.length ? h('ul', { class: 'urls' }, urls.map(([k, u]) => h('li', null, badge(k, 'neutral'), ' ', h('code', null, u), ' ', h('span', { class: 'hint' }, 'not looked up')))) - : h('p', { class: 'muted' }, 'None.'), - h('p', { class: 'hint' }, 'This scan contacts only the endpoint, so the CA’s OCSP responder and CRL were not queried. Live lookups are planned as an option, off by default.'), + v.crlLookups ? [h('h3', null, 'CRL checks, up the served chain'), crlTable, + h('p', { class: 'hint' }, 'Each certificate was checked against the CRL it names, signed by the certificate above it (or by a bundled root). Revocation is reported beside trust, not instead of it. OCSP responders are not contacted.')] + : [h('h3', null, 'Revocation sources the certificate names'), + urls.length ? h('ul', { class: 'urls' }, urls.map(([k, u]) => h('li', null, badge(k, 'neutral'), ' ', h('code', null, u), ' ', h('span', { class: 'hint' }, 'not looked up')))) + : h('p', { class: 'muted' }, 'None.'), + h('p', { class: 'hint' }, 'CRL lookups were off for this scan, so the CA was not contacted. Turn them on with --revocation (server or CLI); they fetch only plain-HTTP CRLs and refuse private addresses unless allowed.')], h('h3', null, 'Certificate Transparency'), scts.length ? h('div', { class: 'tablewrap' }, h('table', { class: 'compact' }, h('thead', null, h('tr', null, ['Source', 'Log', 'Timestamp'].map((c) => h('th', { scope: 'col' }, c)))), diff --git a/web/static/js/views/settings.js b/web/static/js/views/settings.js index b9999ce..af15f37 100644 --- a/web/static/js/views/settings.js +++ b/web/static/js/views/settings.js @@ -110,6 +110,7 @@ export async function render(root) { ['What may be scanned', scope, '--scan-allow'], ['This machine (loopback)', v.allowLoopback ? 'may be scanned' : 'may not be scanned', '--allow-loopback'], ['HTTP check (HSTS)', v.httpChecks ? 'on: one GET / after the handshake' : 'off', '--http-checks'], + ['CRL lookups', v.crlLookups ? 'on: each certificate’s CRL is fetched from its CA (plain HTTP)' : 'off: revocation is known only from stapled OCSP', '--revocation'], ['Automation token', v.automationToken ? 'accepted for scripts and CI' : 'not configured', '--token'], ['Scans at once', String(v.maxConcurrentScans), '--max-concurrent-scans'], ['History kept', `the last ${v.retentionPerAsset} scans of each endpoint`], diff --git a/web/static/js/views/tls-config.js b/web/static/js/views/tls-config.js index 27e2608..527631a 100644 --- a/web/static/js/views/tls-config.js +++ b/web/static/js/views/tls-config.js @@ -252,6 +252,36 @@ function keyExchangeSection(ctx, tone) { // ---- 4. revocation, 5. features ------------------------------------------------------------- +const cnOf = (dn) => { const m = /(?:^|,\s*)CN=([^,]+)/.exec(dn || ''); return m ? m[1] : (dn || ''); }; + +// crlSummary reads the CRL lookups of a chain view: one line per certificate on the served path, +// and an overall verdict. Revocation is a status beside trust: this never changes the trust chips. +export function crlSummary(v, cr) { + const results = v.crl || []; + const chain = (cr && cr.chain) || []; + const who = (pos) => (pos === 0 ? 'Leaf' : `Intermediate ${cnOf((chain[pos] || {}).subject) || pos}`); + const byPos = new Map(); + for (const r of results) { if (!byPos.has(r.position)) byPos.set(r.position, []); byPos.get(r.position).push(r); } + const rows = [...byPos.entries()].sort((a, b) => a[0] - b[0]).map(([pos, rs]) => { + const ans = rs.find((r) => r.status !== 'unknown') || rs[rs.length - 1]; + const stale = ans.status === 'good' && ans.nextUpdate && new Date(ans.nextUpdate) < new Date(); + const text = ans.status === 'revoked' ? `revoked${ans.reason ? ` (${ans.reason})` : ''}${ans.revokedAt ? ` since ${fmtDay(ans.revokedAt)}` : ''}` + : ans.status === 'good' ? (stale ? `not listed, but the CRL is past its next update (${fmtDay(ans.nextUpdate)})` : `not revoked${ans.nextUpdate ? `; CRL current until ${fmtDay(ans.nextUpdate)}` : ''}`) + : `unknown: ${ans.problem || 'no CRL answered'}`; + const tone = ans.status === 'revoked' ? 'problem' : ans.status === 'good' && !stale ? 'good' : 'attention'; + return { pos, who: who(pos), url: ans.url || '', text, tone, signature: ans.signature || '' }; + }); + const revoked = rows.filter((r) => r.tone === 'problem'); + const leaf = rows.find((r) => r.pos === 0); + const verdict = !v.crlLookups ? { text: 'CRL lookups were off for this scan: revocation status unknown', tone: 'na' } + : revoked.length ? { text: `Revoked: ${revoked.map((r) => r.who).join(', ')}`, tone: 'problem' } + : rows.length && rows.every((r) => r.tone === 'good') ? { text: `Not revoked: ${rows.length === 1 ? 'the leaf was' : `all ${rows.length} certificates were`} checked against a current CRL from their issuer`, tone: 'good' } + : leaf && leaf.tone === 'good' ? { text: 'Leaf not revoked; a CRL higher up could not answer', tone: 'attention' } + : rows.length ? { text: 'A CRL could not answer: revocation status unknown', tone: 'attention' } + : { text: 'The certificates name no CRL to check', tone: 'na' }; + return { rows, verdict }; +} + function revocationSection(ctx, tone) { const { ev, rep } = ctx; const v = (ev.pki || []).find((x) => !x.noSni) || (ev.pki || [])[0]; @@ -269,11 +299,13 @@ function revocationSection(ctx, tone) { : o.error || !o.forThisCert ? statusTile('OCSP stapling', o.error ? `Stapled but unusable: ${o.error}` : 'Stapled, for another certificate', 'problem') : statusTile('OCSP stapling', `Stapled: ${o.status}${o.nextUpdate ? `, until ${fmtDay(o.nextUpdate)}` : ''}`, o.status === 'good' && o.signature === 'valid' ? 'good' : 'problem'), statusTile('Must-staple', leaf.ocspMustStaple ? 'Required by the certificate' : 'Not set', 'na'), - statusTile('OCSP and CRL', urls.length ? 'Named by the certificate, not contacted: revocation status unknown' : 'The certificate names none', urls.length ? 'na' : (v.public ? 'attention' : 'na'), null, null, urls.length ? urls.join('\n') : null), + v.crlLookups ? (() => { const s = crlSummary(v, cr); return statusTile('CRL', s.verdict.text, s.verdict.tone, null, null, s.rows.map((r) => `${r.who}: ${r.text}`).join('\n') || null); })() + : statusTile('OCSP and CRL', urls.length ? 'Named by the certificate, not contacted (CRL lookups off): revocation status unknown' : 'The certificate names none', urls.length ? 'na' : (v.public ? 'attention' : 'na'), null, null, urls.length ? urls.join('\n') : null), statusTile('Certificate Transparency', scts.length ? `${plural(scts.length, 'SCT')}: ${scts.map((s) => s.logName || 'unknown log').join(', ')}` : v.public ? 'No SCT in any of the three places' : 'No SCT: not needed for a private certificate', scts.length ? 'good' : v.public ? 'problem' : 'na'), ]; - return section('sec-revocation', 'Revocation and transparency', tone, h('span', { class: 'cfg-aside' }, 'The CA’s responder and CRL are not contacted.'), h('div', { class: 'stiles' }, tiles)); + const aside = v.crlLookups ? 'CRLs were fetched from the CA; OCSP responders are not contacted.' : 'The CA’s responder and CRL are not contacted.'; + return section('sec-revocation', 'Revocation and transparency', tone, h('span', { class: 'cfg-aside' }, aside), h('div', { class: 'stiles' }, tiles)); } function featuresSection(ctx, tone) {