From 3b07d58140c161d087ef98115ee92449b19baadc Mon Sep 17 00:00:00 2001 From: Khalid Saifullah Khan Date: Wed, 21 Oct 2026 12:30:22 +0530 Subject: [PATCH 1/5] fix(server): live connection count never goes backwards Progress ticks from parallel probes could arrive out of order, and the progress write happened outside the lock, so the count shown during a scan could drop (CI: TestAC02_ProgressIsLiveAndScanCanBeCancelled saw 8 then 7). tick keeps the maximum under the lock, and message writes its event and progress under the lock. --- internal/server/runner.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/server/runner.go b/internal/server/runner.go index ed7f68a..d13b69f 100644 --- a/internal/server/runner.go +++ b/internal/server/runner.go @@ -229,9 +229,10 @@ func (p *progress) message(msg string) { } stage, pct, conns := p.stage, p.pctLocked(), p.conns p.lastSent = time.Now() - p.mu.Unlock() p.r.note(p.id, p.r.st.AddEvent(p.id, msg)) + // Written under the lock that computed it, so two progress writes never land out of order. p.r.note(p.id, p.r.st.UpdateProgress(p.id, stage, pct, conns)) + p.mu.Unlock() } func (p *progress) tick(n int) { @@ -239,15 +240,15 @@ func (p *progress) tick(n int) { return } p.mu.Lock() - p.conns = n + defer p.mu.Unlock() + // Probe goroutines report counts they read at different moments; a count read earlier can + // arrive later. The counter only moves forward. + p.conns = max(p.conns, n) if time.Since(p.lastSent) < 250*time.Millisecond { - p.mu.Unlock() return } p.lastSent = time.Now() - stage, pct := p.stage, p.pctLocked() - p.mu.Unlock() - p.r.note(p.id, p.r.st.UpdateProgress(p.id, stage, pct, n)) + p.r.note(p.id, p.r.st.UpdateProgress(p.id, p.stage, p.pctLocked(), p.conns)) } // pctLocked is the stage's floor plus a creep toward the next floor as From 61a5656f2ddb4ce7e996ed27655be11266209d59 Mon Sep 17 00:00:00 2001 From: Khalid Saifullah Khan Date: Wed, 21 Oct 2026 12:30:23 +0530 Subject: [PATCH 2/5] feat: classical strength for every detected algorithm (PRD-010) A suite is as strong as its weakest part (OD-1): cipher, key exchange, certificate key and MAC, each rated by NIST's own method (GNFS for RSA and DH, SP 800-186 for curves, SP 800-57 for ciphers and hashes). - eval/9: the evaluation carries suiteStrength and keyStrength; stored scans are re-evaluated on start - report and PDF read the evaluation; both heuristic copies are gone - tls scan --details shows each suite's word, bits and weakest part - Inventory, SSH report: a Classical strength column - CBOM: algorithmProperties.classicalSecurityLevel (1.6 and 1.7) - single DES is Insecure (#18) - Help, methodology, cbom-model, README, CHANGELOG; QA-REPORT-010 Closes #17. Closes #18. --- CHANGELOG.md | 39 +++ README.md | 1 + docs/cbom-model.md | 1 + docs/methodology.md | 32 +++ docs/product/ENH-001-cipher-strength.md | 5 +- docs/product/PRD-010-classical-strength.md | 67 +++++ docs/program/QA-REPORT-010.md | 73 ++++++ docs/program/RTM.md | 14 + internal/algo/strength.go | 33 ++- internal/algo/strength_of.go | 237 +++++++++++++++++ internal/algo/strength_test.go | 40 ++- internal/app/legacy_test.go | 25 +- internal/app/strength_details_test.go | 37 +++ internal/app/tls.go | 28 +- internal/archtest/rtm_test.go | 1 + internal/cbom/build.go | 23 ++ internal/eval/eval.go | 9 +- internal/eval/ssh.go | 6 +- internal/eval/ssh_strength.go | 125 +++++++++ internal/eval/strength.go | 284 +++++++++++++++++++++ internal/eval/strength_test.go | 168 ++++++++++++ internal/reportpdf/endpoint.go | 19 +- internal/reportpdf/matrix.go | 31 ++- internal/reportpdf/reportpdf_test.go | 35 ++- internal/reportpdf/ssh.go | 10 +- internal/server/inventory.go | 12 +- internal/server/inventory_filters_test.go | 43 ++++ internal/tlscbom/completeness_test.go | 34 +++ internal/tlsprobe/text.go | 27 +- testing/e2e/journey.mjs | 2 + testing/e2e/strength-phase.mjs | 51 ++++ testing/golden/debian-13.cbom-1.7.json | 27 +- testing/golden/debian-13.cbom.json | 27 +- testing/golden/ubuntu-24.04.cbom-1.7.json | 12 +- testing/golden/ubuntu-24.04.cbom.json | 12 +- web/static/js/views/help.js | 14 + web/static/js/views/inventory.js | 12 +- web/static/js/views/report-ssh.js | 15 +- web/static/js/views/report.js | 63 +++-- web/static/style.css | 4 + 40 files changed, 1619 insertions(+), 79 deletions(-) create mode 100644 docs/product/PRD-010-classical-strength.md create mode 100644 docs/program/QA-REPORT-010.md create mode 100644 internal/algo/strength_of.go create mode 100644 internal/app/strength_details_test.go create mode 100644 internal/eval/ssh_strength.go create mode 100644 internal/eval/strength.go create mode 100644 internal/eval/strength_test.go create mode 100644 testing/e2e/strength-phase.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index bba7a4e..56a4168 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,45 @@ the CBOM/report/eval schema versions, the database) can change in a minor releas ## [Unreleased] +### Added + +- **Classical strength for every detected algorithm** ([PRD-010](docs/product/PRD-010-classical-strength.md), + [ENH-001](docs/product/ENH-001-cipher-strength.md)). Strengths are computed with NIST's own methods, and the tests + reproduce NIST's published tables: + - RSA and finite-field DH by the General Number Field Sieve estimate (FIPS 140-3 IG D.B, SP 800-56B Rev. 2 + Appendix D); + - elliptic curves at half the order (SP 800-186); + - ciphers and hashes by SP 800-57 Part 1 Tables 2 and 3; + - post-quantum algorithms by their NIST category. + + Where it shows: + - **TLS report and PDF:** each cipher suite's word now comes from the server and is as strong as its weakest + part (cipher, key exchange, certificate key, MAC), with its bits and that part named. Groups and certificate + keys show their bits. + - **API:** the evaluation (`eval/9`) carries `suiteStrength` and `keyStrength`; stored scans are re-evaluated + on start. + - **CLI:** `tls scan --details` prints each suite's and group's strength. + - **Inventory:** a *Classical strength* column, and `classicalStrength` on every algorithm and key row of + `/api/v1/inventory`. + - **SSH reports** (page and PDF): a strength for every key exchange, host key (by the presented key's size), + cipher and MAC. + - **CBOM:** `classicalSecurityLevel` on every algorithm with a strength (1.6 and 1.7). + +### Changed + +- **Suite strength words follow the weakest-part rule**, so some suites read differently: + - a suite authenticated by an RSA-2048 certificate (112 bits) is now *Acceptable*, not *Strong*; + - a suite with a 1024-bit DH prime is *Weak*. + + The words are defined in docs/methodology.md and explained in Help. + +### Fixed + +- **Single-DES suites (56-bit key) are Insecure, not Weak** (#18). +- **A running scan's connection count could go backwards.** Concurrent probes reported counts out of order, and + progress writes could land out of order. The count now only moves forward, and writes are serialised. This was + found by a CI run of the workflow test. + ## [0.3.0] - 2026-10-17 ### Changed diff --git a/README.md b/README.md index d52e95a..23a783b 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ CycloneDX 1.7 CBOM, with PDF reports for each endpoint and for the whole invento |---|---| | **TLS endpoints** | Every protocol from SSLv2 to TLS 1.3, every cipher suite and key-exchange group (hybrids such as `X25519MLKEM768` included), the TLS 1.3 signature schemes (ML-DSA included), the certificate chain against the Mozilla and Chrome root stores or your own CA, OCSP stapling, Certificate Transparency and HSTS. STARTTLS for SMTP, IMAP, POP3, FTP, XMPP, LDAP and PostgreSQL. 55 cited checks and the TLS hygiene score. | | **SSH servers** | Identification and algorithm lists, negotiation as OpenSSH 10.6 would (the ML-KEM hybrid included), every host key fetched and verified (Ed25519, ECDSA, RSA, ML-DSA and the composite `ssh-mldsa44-ed25519`), 18 cited checks, and the CipherHorizon SSH baseline 2026 with the `sshd_config` lines to meet it. It never logs in. | +| **Classical strength** | Every algorithm, key and cipher suite in bits, computed with NIST's own methods (the GNFS estimate for RSA and DH, half the order for curves, SP 800-57 for ciphers and hashes). A suite is as strong as its weakest part and names it, on the report, the PDF, the CLI, the Inventory, SSH reports and the CBOM (`classicalSecurityLevel`). | | **Post-quantum readiness** | L0 to L4 for every TLS endpoint and SSH server, each protocol by its own stated rules, with harvest-now-decrypt-later exposure. | | **Machines** | Two readable inventory scripts (PowerShell, shell) list a machine's cryptographic software and certificates; nothing is installed and no key material is read. | | **Inventory** | TLS endpoints, SSH servers, machines and imported CBOMs merged into one CycloneDX 1.7 (or 1.6) CBOM: every algorithm with its OID where one exists (the exact certificate signature algorithm, ECDH on each named curve, legacy ciphers such as 3DES and RC4), certificates with their serial and SHA-256 fingerprint. | diff --git a/docs/cbom-model.md b/docs/cbom-model.md index a7f02fc..031a926 100644 --- a/docs/cbom-model.md +++ b/docs/cbom-model.md @@ -81,6 +81,7 @@ stretched: | `iana-obsolete` | algorithm | a group IANA marks obsolete, e.g. the pre-standard `X25519Kyber768Draft00` | | `tls-graph` | document | what the graph's edges mean (above) | | `evidence` | every component | how it was learned: `observed` (a ServerHello; a certificate and what it states), `accepted` (a probe offering only that group or signature scheme was answered), `derived` (implied by an accepted suite's IANA registry entry: its cipher, key exchange, authentication, MAC or hash), `negotiated` (used by the one handshake a current client completed). Several can apply. Never a probability (AUDIT-TLS-CBOM-001 A-5) | +| `classicalSecurityLevel` (CycloneDX field) | algorithm | the classical strength in bits from NIST's methods (GNFS for RSA and DH, half the order for curves, SP 800-57 tables for ciphers and hashes; docs/methodology.md "Classical strength"). Not set for a broken algorithm (its `classical-status` says why) or one whose size the component does not state (`RSA`, `AES`) | | `negotiated-in` | algorithm | the version of the handshake that used it (`TLSv1.3`); `negotiated` stays `true` | | `scan-complete`, `coverage:` | document | what the scan covered, per stage: `done`, `done; inconclusive: …`, `not tested: …` (switched off), `not applicable: …`, `not reached: …` (an earlier step failed), `not tested (budget)`, `inconclusive: …`. An absent component means "not accepted" only where its stage is `done` (A-6) | | `mapping` | document | the IANA registries (with retrieval dates) and the catalogue the derived facts were mapped with | diff --git a/docs/methodology.md b/docs/methodology.md index 48792ce..571ee1d 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -136,6 +136,38 @@ takes every finding except info, orders it by severity, then by whether it stops untrusted, name mismatch, missing intermediate, no serverAuth, Must-Staple missing, revoked), then by section order, and shows the first three distinct rules. +## Classical strength (eval/9, PRD-010) + +Every algorithm the product lists has a classical security strength in bits, computed with NIST's own methods. +Where no number applies, it says why instead: a cited break, or no rating. The derivation, with NIST's tables +reproduced to the decimal, is in [ENH-001 §5](product/ENH-001-cipher-strength.md#5-how-the-strength-is-calculated-nists-method-reproduced). +The code is `internal/algo/strength.go` and `strength_of.go`. + +| What | Method | Source | +|---|---|---| +| RSA modulus, finite-field DH prime | General Number Field Sieve: E(L) = (1.923·∛(L·ln 2)·∛((ln(L·ln 2))²) − 4.69)/ln 2, rounded to the nearest multiple of eight, at most 256. 1024 → 80, 2048 → 112, 3072 → 128, 4096 → 152, 8192 → 200 | FIPS 140-3 IG D.B; SP 800-56B Rev. 2 Appendix D, Table 4; SP 800-56A Rev. 3, Table 26 | +| Elliptic curves | Half the bit length of the base point's order, taken to the nearest strength SP 800-186 assigns (80, 112, 128, 192, 224, 256): P-256 and Curve25519 → 128, P-384 → 192, Curve448 → 224, P-521 → 256 | SP 800-186 §3 and Table 1 | +| Symmetric ciphers | AES-k → k; 3DES (3TDEA) → 112, disallowed for encryption after 2023; DES, RC4, RC2 → broken. Ciphers NIST does not rate (ChaCha20, Camellia, ARIA, SEED, …) get the bound an exhaustive key search sets, labelled as such | SP 800-57 Part 1 Rev. 5 Table 2; SP 800-131A Rev. 2; RFC 7465 | +| Hashes and MACs | Signatures, where collision resistance is needed: SHA-256 → 128, SHA-384 → 192, SHA-512 → 256; SHA-1 and MD5 → broken. HMAC: SHA-1 → 128, SHA-256 and longer → 256 | SP 800-57 Part 1 Table 3 | +| Post-quantum algorithms | The classical equivalent of their NIST category: ML-KEM-512/768/1024 → 128/192/256, ML-DSA-44/65/87 → 128/192/256, SLH-DSA by its level | FIPS 203, 204, 205 | +| Hybrids | Secure while either part is, so their stronger part: X25519MLKEM768 → 192 | RFC 10024, RFC 10042 | + +**A TLS cipher suite is as strong as its weakest part.** Its parts are the bulk cipher; the key exchange (the +weakest group the server accepts for that version, the DH prime it sent, or, for RSA key transport, the certificate +key); the authentication (the weakest end-entity key the server presented that the suite can use); and, for a +non-AEAD suite, the MAC. + +| Word | When | +|---|---| +| **Insecure** | a part is broken (NULL, export, RC4, single DES, anonymous), or fewer than 80 bits | +| **Weak** | 80 to 111 bits, or a 64-bit block cipher (Sweet32; RFC 9325 §4.2), or no forward secrecy (RFC 9325 §4.1) | +| **Acceptable** | at least 112 bits, but below 128 or not AEAD (RFC 9325 §4.2 prefers AEAD) | +| **Strong** | at least 128 bits in every part, AEAD, forward secrecy | + +An RSA-2048 certificate (112 bits) makes every suite it authenticates *Acceptable*: the honest reading of SP 800-57, +and the owner's decision OD-1. Each suite states its weakest part and the reason for its word. The words are this +product's own; the numbers are NIST's. + ## SSH SSH servers share the readiness scale and the findings model. They are **not scored**: an SSH server is judged against diff --git a/docs/product/ENH-001-cipher-strength.md b/docs/product/ENH-001-cipher-strength.md index d25a7fa..553b5d0 100644 --- a/docs/product/ENH-001-cipher-strength.md +++ b/docs/product/ENH-001-cipher-strength.md @@ -168,7 +168,10 @@ estimate; a 7680-bit key comfortably meets the 192 category. For the thresholds The best known classical attack on the elliptic-curve discrete logarithm problem is Pollard's rho, about √(πn/4) ≈ 2^(f/2) operations for a base point of order n with f bits. **SP 800-186 §3**: *"if an elliptic curve has a basepoint of order n, then the security strength will be approximately one half of the bit length of n"*. We take -f/2, rounded to a multiple of eight, held to 256. +f/2 to the **nearest strength SP 800-186 Table 1 assigns** (80, 112, 128, 192, 224, 256). This replaces an earlier +"round to a multiple of eight", which matched the prime curves but gave K-233 120 bits where NIST says 112. The +nearest-level rule reproduces every row of Table 1, binary curves included: K-233 116 → 112, K-283 140.5 → 128, +K-409 203.5 → 192, K-571 285 → 256. | Curve (as TLS or SSH names it) | Order f (bits) | f/2 | Strength | SP 800-186 Table 1 | |---|---|---|---|---| diff --git a/docs/product/PRD-010-classical-strength.md b/docs/product/PRD-010-classical-strength.md new file mode 100644 index 0000000..5919d03 --- /dev/null +++ b/docs/product/PRD-010-classical-strength.md @@ -0,0 +1,67 @@ +# PRD-010 · Classical strength for every detected algorithm + +Status: baselined 2026-10-18. The owner approved [ENH-001](ENH-001-cipher-strength.md) with **OD-1 as recommended**: +a suite is as strong as its weakest part, so an RSA-2048 certificate makes its suites *Acceptable*. The scope +includes the suite rating, the API, the CLI, the Inventory, SSH and the CBOM `classicalSecurityLevel`, and fixes +issue #18. + +Problem, evidence and the NIST method: ENH-001 §1, §2 and §5. Status per requirement: [RTM](../program/RTM.md). +Results: QA-REPORT-010. + +## 1. Goals + +| # | Goal | Measure | +|---|---|---| +| G-1 | Every place that lists an algorithm says how strong it is classically, from one cited model | No surface lists an algorithm without a strength, or a cited reason it has none | +| G-2 | The strength is NIST's, computed the way NIST computes it | Tests reproduce SP 800-56B Table 4, SP 800-56A Table 26, SP 800-186 Table 1 and SP 800-57 Tables 2 and 3 | +| G-3 | A suite's word explains itself | Each suite names its weakest part and its bits | + +**Non-goals:** +- a score; +- re-weighting findings or the TLS hygiene score; +- post-quantum strength, which stays in the readiness level and `nistQuantumSecurityLevel`. + +## 2. The rating (ENH-001 §3.1, OD-1) + +A suite's parts: +- **cipher:** the bulk cipher; +- **key exchange:** + - ECDHE: the weakest group the server accepts for that version; + - DHE: the prime it sent; + - RSA key transport: the certificate key; +- **authentication:** the weakest end-entity key the server presented whose type the suite can use (RSA or ECDSA + in TLS 1.2; any in TLS 1.3); +- **MAC:** for non-AEAD suites. + +Its **bits** are the minimum over the parts. Each part is broken or has a strength (`internal/algo/strength.go`). + +| Word | When | +|---|---| +| **Insecure** | any part broken (NULL, export, RC4, DES, anonymous, so no authentication), or fewer than 80 bits | +| **Weak** | 80 to 111 bits, or a 64-bit block cipher (Sweet32), or no forward secrecy (RSA key transport, static DH or ECDH) | +| **Acceptable** | at least 112 bits, but below 128 or not AEAD | +| **Strong** | at least 128 bits in every part, AEAD, forward secrecy | + +The reason names the weakest part, for example "RSA-2048 certificate key, 112 bits", or the rule that decided the +word ("64-bit block cipher"). + +## 3. Requirements + +| ID | Requirement | Acceptance | Test | Pri | +|---|---|---|---|---| +| **REQ-STR-1** | **The model.** `algo.StrengthOf` gives every catalogue algorithm, and every variant this program derives (RSA-n, ECDSA-curve, ECDH-curve, DSA-n, the signature variants), its classical strength in bits, or a cited broken status, or "not rated" with a reason. Never a guess. | Every catalogue entry is one of the three; the post-quantum ones carry their NIST category's classical equivalent (FIPS 203, 204, 205); a hybrid is as strong as its stronger part (RFC 10024) | `TestEveryAlgorithmHasACitedStrength` | P0 | +| **REQ-STR-2** | **Suite rating** as §2, in `internal/eval`, with the parts, the bits and the reason. Single DES is **Insecure** (#18). | A table test over the IANA registry; the demo targets' suites rate as expected | `TestSuiteStrengthIsTheWeakestPart`, `TestSingleDESIsInsecure` | P0 | +| **REQ-STR-3** | **API.** The evaluation carries `suiteStrength`: per suite, version, ID, word, bits, the weakest part and the reason. It also carries `keyStrength` for every certificate key and accepted group. `eval.Version` becomes `eval/9`, so stored scans are re-evaluated. | `GET /api/v1/scans/{id}/evaluation` shows them | `TestEvaluationCarriesStrength` | P0 | +| **REQ-STR-4** | **Report and PDF read the evaluation.** The two heuristic copies are deleted. The suite table shows the word with bits and the weakest part; the group and certificate-key tables show bits. | Page, PDF and API agree for every suite of every demo target | `TestPDFAndEvaluationAgreeOnStrength`, `script:testing/e2e/strength-phase.mjs#E2E-STR-1` | P0 | +| **REQ-STR-5** | **CLI.** `tls scan --details` shows each suite's word, bits and weakest part, and each group's bits. `--json` includes the evaluation fields. | | `TestDetailsShowSuiteStrength` | P1 | +| **REQ-STR-6** | **Inventory.** Algorithm and key rows carry `classicalStrength`: `bits`, `status` (rated, disallowed, broken, not-rated) and the cited `basis`. The lists show a *Classical strength* column, and no broken algorithm reads only "not classified". | 3DES shows "112 bits, disallowed" (SP 800-131A Rev. 2); RC4 "broken" (RFC 7465); RSA-2048 112; AES-256-GCM 256 | `TestInventoryRowsCarryClassicalStrength`, `script:testing/e2e/strength-phase.mjs#E2E-STR-2` | P0 | +| **REQ-STR-7** | **SSH.** Each advertised algorithm in the evaluation (key exchange, host key, cipher, MAC) carries its strength; host keys use the size of the key fetched. The report shows it. | `diffie-hellman-group14-sha256` 112; `curve25519-sha256` 128; `3des-cbc` 112 disallowed; `hmac-md5` not rated (MD5 is not an approved hash, so SP 800-57 gives it no strength; RFC 6151 does not call HMAC-MD5 broken); an RSA-1024 host key 80 | `TestSSHAlgorithmsCarryStrength` | P1 | +| **REQ-STR-8** | **CBOM.** `algorithmProperties.classicalSecurityLevel` is set from the model for every algorithm component that has a strength (1.6 and 1.7). A broken one keeps `classical-status` and gets no number. | Validates against both schemas | `TestCBOMCarriesClassicalSecurityLevel` | P0 | +| **REQ-STR-9** | **Documentation.** docs/methodology.md has the method, the table and its sources; Help explains the four words; docs/cbom-model.md the field; the CHANGELOG notes the change in words. | | `report:docs/methodology.md` | P1 | + +## 4. Release + +A minor release (0.4.0), when the owner asks. Visible changes: +- some suites change word (RSA-2048 suites become *Acceptable*; single DES becomes *Insecure*); +- new API fields; +- the CBOM's `classicalSecurityLevel`. diff --git a/docs/program/QA-REPORT-010.md b/docs/program/QA-REPORT-010.md new file mode 100644 index 0000000..c3777d9 --- /dev/null +++ b/docs/program/QA-REPORT-010.md @@ -0,0 +1,73 @@ +# QA-REPORT-010 · Classical strength (PRD-010) + +Date: 2026-10-18. Build: branch `feat/classical-strength`, on top of [ENH-001](../product/ENH-001-cipher-strength.md) +and the NIST strength model (`42b1644`). Requirements: [PRD-010](../product/PRD-010-classical-strength.md), +[RTM](RTM.md) §PRD-010. + +## Result + +| Gate | Command | Result | +|---|---|---| +| Unit and integration | `go test ./...` | 44 of 44 packages pass | +| Traceability | `TestRequirementsTraceability` (in the above) | 9 PRD-010 rows, all done, each with evidence | +| Lint | `golangci-lint run ./...`, and with `GOOS=linux` | 0 issues, 0 issues | +| UI scripts parse | `node --check` on every module, as CI does | all parse | +| Browser journey | `testing/e2e/journey.mjs` (CI setup: `--sign-in on`, token, server log) | **217 passed, 0 failed** (214 at 0.3.0; 3 new strength checks) | +| Screen walk | below | 2 findings fixed, 1 PRD correction | + +## What the new checks cover + +- **The model (REQ-STR-1).** Tests reproduce NIST's own tables from NIST's own formulae: + - the GNFS estimate for RSA and finite-field DH, against SP 800-56B Rev. 2 Table 4 and SP 800-56A Rev. 3 Table 26; + - the curve rule against SP 800-186 Table 1, the binary curves included; + - the symmetric ciphers and hashes against SP 800-57 Part 1 Rev. 5 Tables 2 and 3. + + Every catalogue algorithm has a strength, a cited broken status or "not rated" with a reason. +- **The suite rating (REQ-STR-2).** A table test covers the weakest-part rule and every suite in the IANA registry. + Single DES is Insecure (#18). +- **One source for every surface (REQ-STR-3 to 5).** + - The report, the PDF and the CLI read the evaluation; the two heuristic copies are deleted. + - E2E-STR-1 compares every suite row on every scanned TLS report with `GET /api/v1/scans/{id}/evaluation`. + - `TestPDFAndEvaluationAgreeOnStrength` does the same for the PDF. +- **Inventory, SSH, CBOM (REQ-STR-6 to 8).** + - E2E-STR-2 reads the Inventory's column: 3DES "112 bits, disallowed", RC4 broken, AES-256-GCM 256. + - `TestSSHAlgorithmsCarryStrength` rates RSA host keys by the size of the key fetched. + - The CBOM's `classicalSecurityLevel` validates against the 1.6 and 1.7 schemas. +- **Help (REQ-STR-9).** E2E-STR-3: the report's strength legend opens Help at the Classical strength topic. + +## Defects found by QA and fixed before release + +| # | Found by | Defect | Fix | +|---|---|---|---| +| Q1 | CI on PR #16 (`TestAC02_ProgressIsLiveAndScanCanBeCancelled`, 8 then 7 connections) | **Pre-existing race.** Progress ticks from parallel probes could land out of order, and the progress write happened outside the lock, so the live connection count could go backwards. | `tick` keeps the maximum under the lock; `message` writes its event and progress under the lock. | +| Q2 | Journey (most report checks, then a `getComputedStyle` abort) | The chain diagram used the evaluation without being passed it. The Summary and Configuration tabs of every TLS report rendered nothing below the tab bar. | `servedDiagram` takes the evaluation. Every view was then rendered against every scanned endpoint without an exception. | +| Q3 | Screen walk | The legend's "How strength is worked out" opened Help at its top, eleven topics above the answer. | Help opens at `?topic=`; E2E-STR-3 checks it. The first version scrolled in `requestAnimationFrame`, which never runs in a hidden tab; it now scrolls at once. | + +## Screen walk + +Walked in light, dark and at 375 px: +- the TLS report's Summary and Configuration, with the suite and group tables and the certificate chain; +- the SSH report's algorithm tables; +- Inventory › Algorithms and Keys; +- Help › Classical strength. + +No page scrolls sideways at 375 px. + +- **Kept:** a suite's hint names its weakest part ("128 bits · x25519"). When parts tie, the first in §2's order + (cipher, key exchange, authentication, MAC) is named. +- **Kept:** generic names that carry no size ("RSA", "ECDH", "AES", "SHA256withRSA" from imported CBOMs) read + *not rated*, because their strength depends on a size the source did not give. Each has its reason in `basis`. +- **PRD correction:** REQ-STR-7 expected `hmac-md5` to read *broken*. RFC 6151 does not call HMAC-MD5 broken, and + SP 800-57 gives it no strength because MD5 is not approved, so the model says *not rated* with that reason. The + PRD now says so. REQ-STR-6 named the field `securityBits`; it ships as `classicalStrength` {bits, status, basis}, + and 3DES reads "disallowed" (SP 800-131A Rev. 2), not "deprecated". + +## Changes a user will see + +- Suites change word: + - suites authenticated by an RSA-2048 certificate become *Acceptable* (OD-1, the weakest-part rule); + - single DES becomes *Insecure*. +- Stored scans are re-evaluated on start (eval/9). +- New API fields: `suiteStrength` and `keyStrength` on the evaluation, `strength` on SSH algorithms, and + `classicalStrength` on Inventory rows. +- The CBOM gains `classicalSecurityLevel`. diff --git a/docs/program/RTM.md b/docs/program/RTM.md index 2015a56..f7906d2 100644 --- a/docs/program/RTM.md +++ b/docs/program/RTM.md @@ -342,6 +342,20 @@ WP-B to WP-G (SSH) ship in the second pull request, amended 2026-10-13 per [STRA | REQ-CBM-6 | P2 | WP-4 | PRD-009 | — | TestSuiteAlgorithmsAreUnique | done | C-6 | | REQ-CBM-7 | P1 | WP-4 | PRD-009 | — | report:docs/product/CBOM-REVIEW-002-oids-and-completeness.md | done | docs/cbom-model.md, tls-scanning.md, exporting.md | +## PRD-010 · Classical strength for every detected algorithm ([PRD-010](../product/PRD-010-classical-strength.md); results in [QA-REPORT-010](QA-REPORT-010.md)) + +| Requirement | Pri | WP | Source | SR | Evidence | Status | Notes | +|---|---|---|---|---|---|---|---| +| REQ-STR-1 | P0 | — | ENH-001 | — | TestEveryAlgorithmHasACitedStrength, TestGNFSReproducesNISTTables, TestECCStrengthReproducesSP800186, TestSymmetricAndHashStrengthsFollowSP80057 | done | curve rule corrected to SP 800-186's levels (K-233 is 112, not 120) | +| REQ-STR-2 | P0 | — | ENH-001 | — | TestSuiteStrengthIsTheWeakestPart, TestSingleDESIsInsecure, TestEverySuiteInTheRegistryIsRated | done | fixes #18 | +| REQ-STR-3 | P0 | — | ENH-001 | — | TestEvaluationCarriesStrength | done | eval/9 | +| REQ-STR-4 | P0 | — | ENH-001 | — | TestPDFAndEvaluationAgreeOnStrength, script:testing/e2e/strength-phase.mjs#E2E-STR-1 | done | the JS and Go heuristics are deleted | +| REQ-STR-5 | P1 | — | ENH-001 | — | TestDetailsShowSuiteStrength | done | | +| REQ-STR-6 | P0 | — | ENH-001 | — | TestInventoryRowsCarryClassicalStrength, script:testing/e2e/strength-phase.mjs#E2E-STR-2 | done | | +| REQ-STR-7 | P1 | — | ENH-001 | — | TestSSHAlgorithmsCarryStrength | done | RSA and DSA host keys by the presented key's size; SHA-1 signatures broken | +| REQ-STR-8 | P0 | — | ENH-001 | — | TestCBOMCarriesClassicalSecurityLevel, TestLegacyOutputIsPreserved | done | host goldens regenerated; the legacy test allows exactly this addition | +| REQ-STR-9 | P1 | — | ENH-001 | — | report:docs/methodology.md, script:testing/e2e/strength-phase.mjs#E2E-STR-3 | done | Help (the report's legend opens it at the topic), cbom-model, CHANGELOG, README | + ## Security requirements (SECARCH-001) coverage Each SR must be exercised by at least one requirement above; `TestRequirementsTraceability` checks that every `SR-nn` in SECARCH-001 appears in the SR column at least once, except those listed here as cross-cutting. diff --git a/internal/algo/strength.go b/internal/algo/strength.go index 7eef043..9a9d590 100644 --- a/internal/algo/strength.go +++ b/internal/algo/strength.go @@ -60,21 +60,40 @@ func FFCStrength(bits int) int { return min(RoundToEight(GNFSEstimate(bits)), Ma // MaxStrength is the largest security strength NIST assigns (SP 800-57 Part 1 Table 2). const MaxStrength = 256 +// eccLevels are the security strengths SP 800-186 Table 1 assigns to elliptic curves. +var eccLevels = []int{80, 112, 128, 192, 224, 256} + // ECCStrength is the classical security strength of elliptic-curve cryptography whose base point -// has an order of orderBits bits: half the bit length (SP 800-186 section 3), rounded to a multiple -// of eight like the other estimates, and at most 256 (SP 800-57 Part 1 Table 2, "f = 512+"). -// Curve25519's order is 253 bits (126.5 → 128) and Curve448's 446 bits (223 → 224), as SP 800-186 -// Table 1 lists them; P-521's 521 bits (260.5) is held to 256. +// has an order of orderBits bits: about half the bit length (SP 800-186 section 3; the work of +// Pollard's rho), taken to the nearest of the strengths SP 800-186 Table 1 assigns. That reproduces +// every row of the table: P-224 (224 → 112), K-233 (232 → 116 → 112), P-256 (128), Curve25519 +// (253 → 126.5 → 128), K-283 (281 → 140.5 → 128), P-384 (192), K-409 (407 → 203.5 → 192), +// Curve448 (446 → 223 → 224), P-521 (521 → 260.5 → 256), K-571 (570 → 285 → 256). Rounding to a +// multiple of eight, as for RSA, would not: K-233 would read 120. func ECCStrength(orderBits int) int { - return min(RoundToEight(float64(orderBits)/2), MaxStrength) + if orderBits <= 0 { + return 0 + } + half := float64(orderBits) / 2 + best := eccLevels[0] + for _, l := range eccLevels { + if math.Abs(half-float64(l)) < math.Abs(half-float64(best)) { + best = l + } + } + return best } // curveOrderBits are the bit lengths of the base-point orders of the curves this program names, -// from their defining documents (SP 800-186 for the NIST and Edwards curves, RFC 7748 for -// Curve25519 and Curve448: order 2^252 + … and 2^446 − …). +// from their defining documents: SP 800-186 for the NIST, binary and Edwards curves; RFC 7748 for +// Curve25519 and Curve448 (orders 2^252 + … and 2^446 − …); RFC 5639 for the Brainpool curves. var curveOrderBits = map[string]int{ "secp224r1": 224, "secp256r1": 256, "secp384r1": 384, "secp521r1": 521, "Curve25519": 253, "Ed25519": 253, "Curve448": 446, "Ed448": 446, + "sect233k1": 232, "sect233r1": 233, "sect283k1": 281, "sect283r1": 282, "sect409k1": 407, "sect409r1": 409, + "sect571k1": 570, "sect571r1": 570, + "brainpoolP256r1": 256, "brainpoolP384r1": 384, "brainpoolP512r1": 512, + "brainpoolP256r1tls13": 256, "brainpoolP384r1tls13": 384, "brainpoolP512r1tls13": 512, } // CurveStrength is ECCStrength for a named curve; false when the curve is not one listed above. diff --git a/internal/algo/strength_of.go b/internal/algo/strength_of.go new file mode 100644 index 0000000..206871d --- /dev/null +++ b/internal/algo/strength_of.go @@ -0,0 +1,237 @@ +package algo + +import ( + "fmt" + "strconv" + "strings" +) + +// Strength is the classical security strength of one algorithm as this program states it +// (PRD-010 REQ-STR-1): a number of bits with the source it comes from, or a reason there is none. +type Strength struct { + // Bits is the security strength, 0 when Status is not rated or broken. + Bits int `json:"bits,omitempty"` + // Status is rated, disallowed (rated, but NIST no longer allows it for protection), broken (a + // cited practical break, or below anything NIST rates) or not-rated (no source gives a strength + // for it, or it depends on a size or parameter not known here). + Status string `json:"status"` + // Basis is the source, or the reason for the status. + Basis string `json:"basis"` +} + +// The statuses of a Strength. +const ( + StrengthRated = "rated" + StrengthDisallowed = "disallowed" + StrengthBroken = "broken" + StrengthNotRated = "not-rated" +) + +// Words is the strength for people: "128 bits", "112 bits, disallowed", "broken", "not rated". +func (s Strength) Words() string { + switch s.Status { + case StrengthRated: + return fmt.Sprintf("%d bits", s.Bits) + case StrengthDisallowed: + return fmt.Sprintf("%d bits, disallowed", s.Bits) + case StrengthBroken: + return "broken" + } + return "not rated" +} + +// Known reports whether the strength is a number (rated or disallowed). +func (s Strength) Known() bool { return s.Status == StrengthRated || s.Status == StrengthDisallowed } + +const ( + basisTable2 = "NIST SP 800-57 Part 1 Rev. 5, Table 2" + basisTable3 = "NIST SP 800-57 Part 1 Rev. 5, Table 3" + basisGNFS = "GNFS estimate, FIPS 140-3 IG D.B (SP 800-56B Rev. 2 Appendix D), rounded to a multiple of eight" + basisSP800186 = "NIST SP 800-186, Table 1 (half the base-point order)" + basisKeySearch = "exhaustive search of the key; NIST assigns this cipher no strength" + basisFIPS203 = "FIPS 203: the NIST category's classical equivalent (category 1, 3, 5: AES-128, -192, -256 key search)" + basisFIPS204 = "FIPS 204: the NIST category's classical equivalent (category 2: SHA-256 collisions; 3, 5: AES-192, -256)" + basisFIPS205 = "FIPS 205: the NIST category's classical equivalent" + basisHybrid = "a hybrid is secure while either part is (RFC 10024, RFC 10042): its stronger part" +) + +func rated(bits int, basis string) Strength { + return Strength{Bits: bits, Status: StrengthRated, Basis: basis} +} +func broken(basis string) Strength { return Strength{Status: StrengthBroken, Basis: basis} } +func notRated(basis string) Strength { return Strength{Status: StrengthNotRated, Basis: basis} } + +// StrengthOf is the classical strength of an algorithm by the name this program gives it: a +// catalogue ID, a size or curve variant it derives (RSA-2048, ECDSA-P256, DSA-2048), or a +// certificate signature variant (ecdsa-with-SHA256). It never guesses: a name it does not know is +// not rated. +func StrengthOf(name string) Strength { + if s, ok := fixedStrength[name]; ok { + return s + } + switch { + case strings.HasPrefix(name, "RSA-"): + if n, err := strconv.Atoi(strings.TrimPrefix(name, "RSA-")); err == nil { + return KeyStrength("RSA", n, "") + } + case strings.HasPrefix(name, "DSA-"): + if n, err := strconv.Atoi(strings.TrimPrefix(name, "DSA-")); err == nil { + return KeyStrength("DSA", n, "") + } + case strings.HasPrefix(name, "ECDSA-P"): + return KeyStrength("ECDSA", 0, "P-"+strings.TrimPrefix(name, "ECDSA-P")) + case strings.HasPrefix(name, "oid-") || strings.Count(name, ".") > 2: + return notRated("an algorithm this program does not recognise") + } + return notRated("no strength is recorded for this algorithm") +} + +// goCurves maps Go's curve names to the SECG names curveOrderBits uses. +var goCurves = map[string]string{"P-224": "secp224r1", "P-256": "secp256r1", "P-384": "secp384r1", "P-521": "secp521r1"} + +// KeyStrength is the classical strength of a public key: RSA by its modulus (GNFS), DSA and DH by +// their prime (GNFS), EC by its curve (SP 800-186), Ed25519 and Ed448 by theirs, ML-DSA by its +// parameter set. A key of unknown size is not rated. +func KeyStrength(algorithm string, bits int, curve string) Strength { + switch strings.ToUpper(algorithm) { + case "RSA": + if bits > 0 { + return ifc(bits) + } + case "DSA", "DH": + if bits > 0 { + return ifc(bits) + } + case "ECDSA", "EC", "ECDH": + c := curve + if g, ok := goCurves[curve]; ok { + c = g + } + if s, ok := CurveStrength(c); ok { + return rated(s, basisSP800186) + } + case "ED25519": + return rated(128, basisSP800186) + case "ED448": + return rated(224, basisSP800186) + default: + if s, ok := fixedStrength[algorithm]; ok { // ML-DSA-65 and the other catalogue names + return s + } + } + return notRated("the key size or curve is not known") +} + +// ifc rates an RSA modulus or a finite-field prime with the GNFS estimate. Below 80 bits (a key +// shorter than 1024 bits) nothing NIST rates is left: it is broken. +func ifc(bits int) Strength { + s := IFCStrength(bits) + if s < 80 { + return Strength{Status: StrengthBroken, Basis: fmt.Sprintf("%d-bit modulus: %s gives %d bits, below the 80 NIST rates at all", bits, "the GNFS estimate", s)} + } + return rated(s, basisGNFS) +} + +// GroupStrength is the classical strength of a TLS supported group (IANA name) or an SSH key +// exchange method's group: named curves by SP 800-186, RFC 7919 and RFC 3526 finite-field groups by +// the GNFS estimate of their prime, ML-KEM and the hybrids as in StrengthOf. +func GroupStrength(name string) Strength { + switch { + case strings.EqualFold(name, "x25519"): + return rated(128, basisSP800186) + case strings.EqualFold(name, "x448"): + return rated(224, basisSP800186) + case strings.HasPrefix(name, "ffdhe"): + if n, err := strconv.Atoi(strings.TrimPrefix(name, "ffdhe")); err == nil { + return ifc(n) + } + case strings.HasPrefix(name, "MLKEM"): + return StrengthOf("ML-KEM-" + strings.TrimPrefix(name, "MLKEM")) + } + if s, ok := CurveStrength(name); ok { + return rated(s, basisSP800186) + } + return StrengthOf(name) +} + +// fixedStrength is every catalogue entry, and every derived name, whose strength does not depend +// on a size this program reads separately. TestEveryAlgorithmHasACitedStrength holds the catalogue +// to it: every entry is here or is a family whose size is read elsewhere. +var fixedStrength = map[string]Strength{ + // Families: their strength is their size's, read from the key or the suite. + "RSA": notRated("depends on the modulus size (see the key)"), + "DSA": notRated("depends on the prime size (see the key)"), + "DH": notRated("depends on the prime size (see the group or the DH parameters)"), + "ECDSA": notRated("depends on the curve (see the key)"), + "ECDH": notRated("depends on the curve (see the group)"), + "AES": notRated("depends on the key size"), + "ML-KEM": notRated("depends on the parameter set"), + "ML-DSA": notRated("depends on the parameter set"), + + "Ed25519": rated(128, basisSP800186), + "X25519": rated(128, basisSP800186), + "Ed448": rated(224, basisSP800186), + "X448": rated(224, basisSP800186), + "ECDH-P256": rated(128, basisSP800186), + "ECDH-P384": rated(192, basisSP800186), + "ECDH-P521": rated(256, basisSP800186), + + "AES-128-CBC": rated(128, basisTable2), "AES-128-GCM": rated(128, basisTable2), "AES-128-CCM": rated(128, basisTable2), + "AES-128-CTR": rated(128, basisTable2), + "AES-192-CBC": rated(192, basisTable2), "AES-192-CTR": rated(192, basisTable2), + "AES-256-CBC": rated(256, basisTable2), "AES-256-GCM": rated(256, basisTable2), "AES-256-CCM": rated(256, basisTable2), + "AES-256-CTR": rated(256, basisTable2), + "ChaCha20-Poly1305": rated(256, "256-bit key (RFC 8439); "+basisKeySearch), + "3DES": {Bits: 112, Status: StrengthDisallowed, Basis: basisTable2 + " (3TDEA, 112 bits); disallowed for encryption after 2023, NIST SP 800-131A Rev. 2"}, + "DES": broken("56-bit key: exhaustive search is practical; FIPS 46-3 withdrawn"), + "RC4": broken("RFC 7465 prohibits RC4 in TLS; practical biases recover plaintext"), + "RC2": broken("40- to 128-bit key with published related-key and key-search breaks; never rated by NIST"), + "Blowfish": notRated("64-bit block; NIST assigns Blowfish no strength"), + "CAST-128": notRated("64-bit block; NIST assigns CAST-128 no strength"), + + // Hash functions where collision resistance is needed (signatures). + "SHA-256": rated(128, basisTable3), "SHA-384": rated(192, basisTable3), "SHA-512": rated(256, basisTable3), + "SHA3-256": rated(128, basisTable3), "SHA3-384": rated(192, basisTable3), "SHA3-512": rated(256, basisTable3), + "SHA-1": broken(basisTable3 + ": below 80 bits for collision resistance (footnote 74); RFC 6194"), + "MD5": broken("collisions are practical (RFC 6151)"), + "MD4": broken("moved to Historic for practical attacks (RFC 6150)"), + "MD2": broken("moved to Historic as broken (RFC 6149)"), + + // MACs: SP 800-57 Table 3 gives HMAC the strength of its hash's HMAC row. + "HMAC-SHA-1": rated(128, basisTable3), "HMAC-SHA-256": rated(256, basisTable3), "HMAC-SHA-384": rated(256, basisTable3), + "HMAC-SHA-512": rated(256, basisTable3), + "HMAC-MD5": notRated("MD5 is not an approved hash, so SP 800-57 gives HMAC-MD5 no strength (RFC 6151: not for new designs)"), + "UMAC": notRated("depends on the tag length (UMAC-64, UMAC-128); NIST assigns UMAC no strength"), + + // Post-quantum algorithms: the classical strength of their NIST category. + "ML-KEM-512": rated(128, basisFIPS203), "ML-KEM-768": rated(192, basisFIPS203), "ML-KEM-1024": rated(256, basisFIPS203), + "ML-DSA-44": rated(128, basisFIPS204), "ML-DSA-65": rated(192, basisFIPS204), "ML-DSA-87": rated(256, basisFIPS204), + "SLH-DSA-SHA2-128s": rated(128, basisFIPS205), "SLH-DSA-SHA2-128f": rated(128, basisFIPS205), + "SLH-DSA-SHA2-192s": rated(192, basisFIPS205), "SLH-DSA-SHA2-192f": rated(192, basisFIPS205), + "SLH-DSA-SHA2-256s": rated(256, basisFIPS205), "SLH-DSA-SHA2-256f": rated(256, basisFIPS205), + "SLH-DSA-SHAKE-128s": rated(128, basisFIPS205), "SLH-DSA-SHAKE-128f": rated(128, basisFIPS205), + "SLH-DSA-SHAKE-192s": rated(192, basisFIPS205), "SLH-DSA-SHAKE-192f": rated(192, basisFIPS205), + "SLH-DSA-SHAKE-256s": rated(256, basisFIPS205), "SLH-DSA-SHAKE-256f": rated(256, basisFIPS205), + + // Hybrids: the stronger part. + "X25519MLKEM768": rated(192, basisHybrid+" (ML-KEM-768, 192)"), + "SecP256r1MLKEM768": rated(192, basisHybrid+" (ML-KEM-768, 192)"), + "SecP384r1MLKEM1024": rated(256, basisHybrid+" (ML-KEM-1024, 256)"), + "mlkem768x25519-sha256": rated(192, basisHybrid+" (ML-KEM-768, 192)"), + "mlkem768nistp256-sha256": rated(192, basisHybrid+" (ML-KEM-768, 192)"), + "mlkem1024nistp384-sha384": rated(256, basisHybrid+" (ML-KEM-1024, 256)"), + "sntrup761x25519-sha512": rated(128, "at least its X25519 part (128, SP 800-186); NIST rates Streamlined NTRU Prime at nothing"), + + // Certificate signature algorithms: the hash's collision strength (the key is rated on its own). + "sha256WithRSAEncryption": rated(128, basisTable3+" (SHA-256; the key is rated separately)"), + "sha384WithRSAEncryption": rated(192, basisTable3+" (SHA-384; the key is rated separately)"), + "sha512WithRSAEncryption": rated(256, basisTable3+" (SHA-512; the key is rated separately)"), + "ecdsa-with-SHA256": rated(128, basisTable3+" (SHA-256; the key is rated separately)"), + "ecdsa-with-SHA384": rated(192, basisTable3+" (SHA-384; the key is rated separately)"), + "ecdsa-with-SHA512": rated(256, basisTable3+" (SHA-512; the key is rated separately)"), + "RSASSA-PSS": notRated("its hash is a parameter: see the certificate's signature parameters"), + "sha1WithRSAEncryption": broken("SHA-1 signatures: " + basisTable3 + ", footnote 74; RFC 6194"), + "ecdsa-with-SHA1": broken("SHA-1 signatures: " + basisTable3 + ", footnote 74; RFC 6194"), + "dsa-with-SHA1": broken("SHA-1 signatures: " + basisTable3 + ", footnote 74; RFC 6194"), + "md5WithRSAEncryption": broken("MD5 signatures: collisions are practical (RFC 6151)"), +} diff --git a/internal/algo/strength_test.go b/internal/algo/strength_test.go index d1be576..3616cce 100644 --- a/internal/algo/strength_test.go +++ b/internal/algo/strength_test.go @@ -55,13 +55,15 @@ func TestECCStrengthReproducesSP800186(t *testing.T) { for curve, want := range map[string]int{ "secp224r1": 112, "secp256r1": 128, "Curve25519": 128, "Ed25519": 128, "secp384r1": 192, "Curve448": 224, "Ed448": 224, "secp521r1": 256, + "sect233k1": 112, "sect233r1": 112, "sect283k1": 128, "sect283r1": 128, "sect409k1": 192, "sect409r1": 192, + "sect571k1": 256, "sect571r1": 256, } { got, ok := algo.CurveStrength(curve) if !ok || got != want { t.Errorf("CurveStrength(%s) = %d, %v; SP 800-186 Table 1 says %d", curve, got, ok, want) } } - if _, ok := algo.CurveStrength("brainpoolP256r1"); ok { + if _, ok := algo.CurveStrength("curveSM2"); ok { t.Error("a curve without a listed order was given a strength") } } @@ -96,3 +98,39 @@ func TestSymmetricAndHashStrengthsFollowSP80057(t *testing.T) { } } } + +// PRD-010 REQ-STR-1: every catalogue algorithm has a classical strength with its source, or a +// stated reason it has none. Nothing falls through to "no strength is recorded". +func TestEveryAlgorithmHasACitedStrength(t *testing.T) { + for _, a := range algo.All() { + s := algo.StrengthOf(a.ID) + if s.Basis == "" || s.Basis == "no strength is recorded for this algorithm" { + t.Errorf("%s has no cited strength", a.ID) + } + if s.Known() && s.Bits < 80 { + t.Errorf("%s is rated %d bits: below 80 it must be broken", a.ID, s.Bits) + } + } + for name, want := range map[string]string{ + "RSA-1024": "80 bits", "RSA-2048": "112 bits", "RSA-3072": "128 bits", "RSA-512": "broken", + "ECDSA-P256": "128 bits", "ECDSA-P384": "192 bits", "3DES": "112 bits, disallowed", "DES": "broken", "RC4": "broken", + "AES-128-GCM": "128 bits", "ChaCha20-Poly1305": "256 bits", "X25519MLKEM768": "192 bits", + "ecdsa-with-SHA256": "128 bits", "sha1WithRSAEncryption": "broken", "ML-DSA-65": "192 bits", + "oid-1.3.6.1.4.1.99999.1": "not rated", + } { + if got := algo.StrengthOf(name).Words(); got != want { + t.Errorf("StrengthOf(%s) = %q, want %q", name, got, want) + } + } + for g, want := range map[string]int{"x25519": 128, "secp256r1": 128, "secp384r1": 192, "ffdhe2048": 112, "ffdhe3072": 128, "MLKEM768": 192, "X25519MLKEM768": 192} { + if s := algo.GroupStrength(g); s.Bits != want { + t.Errorf("GroupStrength(%s) = %+v, want %d", g, s, want) + } + } + if s := algo.KeyStrength("RSA", 4096, ""); s.Bits != 152 { + t.Errorf("RSA-4096 = %+v, want 152", s) + } + if s := algo.KeyStrength("ECDSA", 256, "P-256"); s.Bits != 128 { + t.Errorf("ECDSA P-256 = %+v, want 128", s) + } +} diff --git a/internal/app/legacy_test.go b/internal/app/legacy_test.go index ddc8f52..8706d81 100644 --- a/internal/app/legacy_test.go +++ b/internal/app/legacy_test.go @@ -134,7 +134,9 @@ func TestLegacyOutputIsPreserved(t *testing.T) { if o.Type != c.Type || o.Name != c.Name || o.Version != c.Version || o.Purl != c.Purl { t.Errorf("%s: identity changed: %+v -> %+v", ref, o, c) } - if string(o.Crypto) != string(c.Crypto) { + // PRD-010 REQ-STR-8 adds classicalSecurityLevel to algorithms that have a NIST + // strength: the one addition allowed here; every other difference still fails. + if canonicalCrypto(t, o.Crypto) != canonicalCrypto(t, c.Crypto) { t.Errorf("%s: cryptoProperties changed:\n old %s\n new %s", ref, o.Crypto, c.Crypto) } if !reflect.DeepEqual(o.locations(), c.locations()) { @@ -239,3 +241,24 @@ func TestCurrentOutputCarriesClaimsAndCoverage(t *testing.T) { t.Errorf("a user-tier run must carry the privilege note: %v", meta["cipherhorizon:coverage-note"]) } } + +// canonicalCrypto is cryptoProperties re-encoded with sorted keys and without the +// classicalSecurityLevel PRD-010 added, so that addition is the only difference allowed. +func canonicalCrypto(t *testing.T, raw json.RawMessage) string { + t.Helper() + if len(raw) == 0 { + return "" // a component with no cryptoProperties (a package, a file) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if ap, ok := m["algorithmProperties"].(map[string]any); ok { + delete(ap, "classicalSecurityLevel") + } + b, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return string(b) +} diff --git a/internal/app/strength_details_test.go b/internal/app/strength_details_test.go new file mode 100644 index 0000000..b1ace91 --- /dev/null +++ b/internal/app/strength_details_test.go @@ -0,0 +1,37 @@ +package app + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/offsetsecurity/cipherhorizon/internal/eval" + "github.com/offsetsecurity/cipherhorizon/internal/tlsprobe" +) + +// PRD-010 REQ-STR-5: tls scan --details prints each suite's and each group's classical strength, +// as the evaluation gives it. +func TestDetailsShowSuiteStrength(t *testing.T) { + rep := &tlsprobe.Report{ + SchemaVersion: tlsprobe.SchemaVersion, Target: tlsprobe.Target{Host: "details.example", Port: 443}, + Protocols: []tlsprobe.ProtocolResult{{Version: "TLSv1.2", Supported: true}}, + Suites: []tlsprobe.SuiteResult{ + {Version: "TLSv1.2", ID: 0xC02F, Name: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", Kx: "ECDHE", Auth: "RSA", Cipher: "AES", KeyBits: 128, Mode: "GCM", MAC: "AEAD", AEAD: true, Ephemeral: true}, + {Version: "TLSv1.2", ID: 0x0009, Name: "TLS_RSA_WITH_DES_CBC_SHA", Kx: "RSA", Auth: "RSA", Cipher: "DES", KeyBits: 56, Mode: "CBC", BlockBits: 64, MAC: "SHA1"}, + }, + Groups: []tlsprobe.GroupResult{{Version: "TLSv1.2", Name: "secp256r1"}}, + Certificates: []tlsprobe.CertResult{{Source: "tls-default", Chain: []tlsprobe.CertInfo{{SHA256: strings.Repeat("cd", 32), PublicKey: tlsprobe.KeyInfo{Algorithm: "RSA", Bits: 2048}}}}}, + } + ev := eval.Evaluate(rep, time.Date(2026, 10, 18, 0, 0, 0, 0, time.UTC)) + var b bytes.Buffer + if err := rep.WriteTextWith(&b, strengthAnnotations(ev)); err != nil { + t.Fatal(err) + } + out := b.String() + for _, want := range []string{"acceptable 112 (RSA-2048 certificate key)", "insecure (DES)", "secp256r1 iana: 128 bits"} { + if !strings.Contains(out, want) { + t.Errorf("--details lacks %q:\n%s", want, out) + } + } +} diff --git a/internal/app/tls.go b/internal/app/tls.go index b92d40f..3d50ddf 100644 --- a/internal/app/tls.go +++ b/internal/app/tls.go @@ -163,7 +163,7 @@ func tlsCmd(ctx context.Context, args []string, env Env) int { } if *details { _, _ = fmt.Fprintln(env.Stdout) - if err := rep.WriteText(env.Stdout); err != nil { + if err := rep.WriteTextWith(env.Stdout, strengthAnnotations(ev)); err != nil { return fatalf(env, "writing report: %v", err) } } @@ -228,3 +228,29 @@ func anySupported(rep *tlsprobe.Report) bool { } return false } + +// strengthAnnotations print, beside each suite and group of --details, the classical strength the +// evaluation gives it (PRD-010 REQ-STR-5): "strong 128 (AES-128-GCM)", "weak 112 (3DES)". +func strengthAnnotations(ev eval.Evaluation) tlsprobe.Annotations { + return tlsprobe.Annotations{ + Suite: func(s tlsprobe.SuiteResult) string { + for _, r := range ev.SuiteStrength { + if r.Version == s.Version && r.ID == s.ID { + if r.Bits > 0 { + return fmt.Sprintf("%s %d (%s)", r.Word, r.Bits, r.Weakest) + } + return r.Word + " (" + r.Weakest + ")" + } + } + return "" + }, + Group: func(g tlsprobe.GroupResult) string { + for _, k := range ev.KeyStrength { + if k.Kind == "group" && k.Version == g.Version && k.Name == g.Name { + return k.Strength.Words() + } + } + return "" + }, + } +} diff --git a/internal/archtest/rtm_test.go b/internal/archtest/rtm_test.go index 2ab12da..8f12e15 100644 --- a/internal/archtest/rtm_test.go +++ b/internal/archtest/rtm_test.go @@ -23,6 +23,7 @@ var prdPaths = []string{ "../../docs/product/PRD-007-launch-1-0.md", "../../docs/product/PRD-008-pq-signatures-and-ssh.md", "../../docs/product/PRD-009-interactive-charts-motion-port-cbom.md", + "../../docs/product/PRD-010-classical-strength.md", } const ( diff --git a/internal/cbom/build.go b/internal/cbom/build.go index 06f8cb7..11b4536 100644 --- a/internal/cbom/build.go +++ b/internal/cbom/build.go @@ -15,6 +15,7 @@ import ( cdx "github.com/CycloneDX/cyclonedx-go" + "github.com/offsetsecurity/cipherhorizon/internal/algo" "github.com/offsetsecurity/cipherhorizon/internal/collector" ) @@ -83,6 +84,7 @@ func Build(assets []collector.CryptoAsset, opts Options) (*cdx.BOM, []string) { } c := a.Component warnings = append(warnings, sanitize(&c)...) + withClassicalLevel(&c) ref := c.BOMRef if prev, seen := components[ref]; seen { @@ -334,3 +336,24 @@ func randomSerial() string { b[8] = b[8]&0x3f | 0x80 // RFC 4122 variant return fmt.Sprintf("urn:uuid:%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) } + +// withClassicalLevel sets algorithmProperties.classicalSecurityLevel (CycloneDX 1.6 and 1.7) from +// the strength model when the algorithm has a NIST-derived strength (PRD-010 REQ-STR-8). A broken +// or unrated algorithm gets no number: its classical-status says why. A level a producer set is +// kept. The component's own properties are copied, never shared. +func withClassicalLevel(c *cdx.Component) { + cp := c.CryptoProperties + if cp == nil || cp.AssetType != cdx.CryptoAssetTypeAlgorithm || cp.AlgorithmProperties == nil || cp.AlgorithmProperties.ClassicalSecurityLevel != nil { + return + } + st := algo.StrengthOf(c.Name) + if !st.Known() { + return + } + cpCopy := *cp + ap := *cp.AlgorithmProperties + bits := st.Bits + ap.ClassicalSecurityLevel = &bits + cpCopy.AlgorithmProperties = &ap + c.CryptoProperties = &cpCopy +} diff --git a/internal/eval/eval.go b/internal/eval/eval.go index 32fc6c1..32e8758 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/8" +const Version = "eval/9" // 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"). @@ -129,6 +129,11 @@ type Evaluation struct { // Baseline is, for an SSH endpoint, how it measures against an SSH baseline (PRD-008 WP-G). // SSH servers are judged this way and get no hygiene score (REQ-SHE-5). Baseline *Conformance `json:"baseline,omitempty"` + + // eval/9 (PRD-010): the classical strength of every accepted suite (rated by its weakest part) + // and of every accepted group and presented certificate key. + SuiteStrength []SuiteStrength `json:"suiteStrength,omitempty"` + KeyStrength []KeyStrength `json:"keyStrength,omitempty"` } // Check is one check the evaluation ran. Title names what was looked for (a problem, as a @@ -259,6 +264,8 @@ func EvaluateWith(rep *tlsprobe.Report, in Input) Evaluation { ev.FixFirst = fixFirst(findings) ev.Vulnerabilities = attacks(findings) ev.ReferenceData = referenceData(in.Now) + ev.SuiteStrength = suiteStrengths(rep) + ev.KeyStrength = keyStrengths(rep) if rep.DH != nil { if p, ok := refdata.LookupDHPrime(rep.DH.PrimeSHA256); ok { ev.DHGroup = &DHGroupView{Name: p.Name, Source: p.Source, Bits: p.Bits, Negotiated: p.Negotiated} diff --git a/internal/eval/ssh.go b/internal/eval/ssh.go index 9852582..9979752 100644 --- a/internal/eval/ssh.go +++ b/internal/eval/ssh.go @@ -5,6 +5,7 @@ import ( "sort" "strings" + "github.com/offsetsecurity/cipherhorizon/internal/algo" "github.com/offsetsecurity/cipherhorizon/internal/sshprobe" "github.com/offsetsecurity/cipherhorizon/internal/sshprobe/registry" "github.com/offsetsecurity/cipherhorizon/internal/tlsprobe" @@ -735,6 +736,8 @@ type SSHAlgorithm struct { Used bool `json:"used,omitempty"` // Baseline is allowed or off against the evaluation's baseline (PRD-008 REQ-SHB-3). Baseline string `json:"baseline"` + // Strength is its classical strength (PRD-010 REQ-STR-7); nil for compression. + Strength *algo.Strength `json:"strength,omitempty"` } // sshAlgorithmViews lists every advertised algorithm, kind by kind, in the server's order. @@ -751,7 +754,8 @@ func sshAlgorithmViews(c *sshCtx, b *SSHBaseline) []SSHAlgorithm { }{{registry.KindKex, c.alg.Kex}, {registry.KindHostKey, c.alg.HostKey}, {registry.KindCipher, c.ciphers}, {registry.KindMAC, c.macs}, {registry.KindCompression, c.comp}} { for _, name := range l.names { a, ok := registry.Lookup(l.kind, name) - v := SSHAlgorithm{Kind: l.kind, Name: name, Display: a.Display, Used: used[l.kind] == name, Baseline: BaselineOff} + v := SSHAlgorithm{Kind: l.kind, Name: name, Display: a.Display, Used: used[l.kind] == name, Baseline: BaselineOff, + Strength: sshStrength(l.kind, name, a, ok, c.rep.HostKeys)} if registry.IsPseudo(name) { v.Baseline = "" } else if b.allows(l.kind, name) { diff --git a/internal/eval/ssh_strength.go b/internal/eval/ssh_strength.go new file mode 100644 index 0000000..670bc82 --- /dev/null +++ b/internal/eval/ssh_strength.go @@ -0,0 +1,125 @@ +package eval + +import ( + "fmt" + "strings" + + "github.com/offsetsecurity/cipherhorizon/internal/algo" + "github.com/offsetsecurity/cipherhorizon/internal/sshprobe" + "github.com/offsetsecurity/cipherhorizon/internal/sshprobe/registry" +) + +// sshStrength is the classical strength of one advertised SSH algorithm (PRD-010 REQ-STR-7), from +// what the SSH registry records about it and, for RSA and DSA host keys, the size of the key the +// server presented. The numbers come from internal/algo (NIST's methods). +func sshStrength(kind, name string, a registry.Algorithm, ok bool, keys []sshprobe.HostKey) *algo.Strength { + if !ok || kind == registry.KindCompression || registry.IsPseudo(name) { + return nil + } + var st algo.Strength + switch kind { + case registry.KindKex: + st = sshKexStrength(name, a) + case registry.KindHostKey: + st = sshHostKeyStrength(name, a, keys) + case registry.KindCipher: + st = sshNamed(a.Catalogue, a.KeyBits, a.Cipher) + case registry.KindMAC: + st = sshNamed(a.Catalogue, 0, "") + default: + return nil + } + return &st +} + +// sshNamed rates a cipher or MAC by its catalogue name, falling back, for a cipher NIST does not +// rate, to the bound an exhaustive search of its key sets. +func sshNamed(catalogue string, keyBits int, cipher string) algo.Strength { + if catalogue != "" { + if st := algo.StrengthOf(catalogue); st.Status != algo.StrengthNotRated || keyBits == 0 { + return st + } + } + if keyBits > 0 { + return algo.Strength{Bits: keyBits, Status: algo.StrengthRated, + Basis: fmt.Sprintf("%d-bit key; NIST assigns %s no strength, so this is the bound an exhaustive key search sets", keyBits, cipher)} + } + return algo.Strength{Status: algo.StrengthNotRated, Basis: "no strength is recorded for this algorithm"} +} + +// sshCurve is the SECG curve an SSH method names (ecdh-sha2-nistp256, ecdsa-sha2-nistp384). +func sshCurve(name string) string { + for _, c := range []string{"nistp256", "nistp384", "nistp521"} { + if strings.Contains(name, c) { + return "secp" + strings.TrimPrefix(c, "nistp") + "r1" + } + } + return "" +} + +func sshKexStrength(name string, a registry.Algorithm) algo.Strength { + switch a.Exchange { + case "MODP": + if a.GroupBits > 0 { + return algo.KeyStrength("DH", a.GroupBits, "") + } + case "ECDH": + if c := sshCurve(name); c != "" { + return algo.GroupStrength(c) + } + case "X25519": + return algo.GroupStrength("x25519") + case "X448": + return algo.GroupStrength("x448") + case "Hybrid": + return algo.StrengthOf(a.Catalogue) + case "GEX": + return algo.Strength{Status: algo.StrengthNotRated, Basis: "group exchange: the group's size is chosen per connection and was not measured"} + } + return algo.Strength{Status: algo.StrengthNotRated, Basis: "no strength is recorded for this key exchange"} +} + +func sshHostKeyStrength(name string, a registry.Algorithm, keys []sshprobe.HostKey) algo.Strength { + // A SHA-1 signature (ssh-rsa, ssh-dss) is below 80 bits where collisions matter (SP 800-57 + // Part 1 Table 3, footnote 74), whatever the key. + if a.Hash == "SHA-1" { + return algo.Strength{Status: algo.StrengthBroken, Basis: "SHA-1 signatures: NIST SP 800-57 Part 1 Rev. 5, Table 3, footnote 74"} + } + switch { + case a.Key == "Ed25519": + return algo.KeyStrength("Ed25519", 0, "") + case a.Key == "Ed448": + return algo.KeyStrength("Ed448", 0, "") + case a.Key == "ECDSA": + if c := sshCurve(name); c != "" { + return algo.GroupStrength(c) + } + case a.Key == "RSA" || a.Key == "DSA": + // The size is the presented key's: the method name does not fix it. + weakest := algo.Strength{Status: algo.StrengthNotRated, Basis: "the host key was not fetched, so its size is not known"} + for _, k := range keys { + if !strings.EqualFold(k.Algorithm, a.Key) || k.Bits == 0 { + continue + } + if st := algo.KeyStrength(a.Key, k.Bits, ""); weakest.Status == algo.StrengthNotRated || st.Bits < weakest.Bits { + weakest = st + } + } + return weakest + case strings.Contains(a.Key, "+"): + // A composite signature (ML-DSA-44+Ed25519) stands while either part does: its stronger part. + var best algo.Strength + for _, part := range strings.Split(a.Key, "+") { + if st := algo.KeyStrength(part, 0, ""); st.Known() && st.Bits > best.Bits { + best = st + } + } + if best.Known() { + best.Basis = "a composite signature stands while either part does: its stronger part (" + best.Basis + ")" + return best + } + case a.Key != "": + return algo.KeyStrength(a.Key, 0, "") // ML-DSA-65 and the other parameter sets + } + return algo.Strength{Status: algo.StrengthNotRated, Basis: "no strength is recorded for this host key type"} +} diff --git a/internal/eval/strength.go b/internal/eval/strength.go new file mode 100644 index 0000000..ceee3cf --- /dev/null +++ b/internal/eval/strength.go @@ -0,0 +1,284 @@ +package eval + +import ( + "fmt" + "strings" + + "github.com/offsetsecurity/cipherhorizon/internal/algo" + "github.com/offsetsecurity/cipherhorizon/internal/tlsprobe" +) + +// The classical strength of what a TLS endpoint accepts (PRD-010, ENH-001): each cipher suite rated +// by its weakest part, and each accepted group and certificate key with its own strength. The +// numbers come from internal/algo (NIST's methods); the four words are this product's, defined here +// and in docs/methodology.md. + +// The suite strength words (PRD-010 §2). +const ( + StrengthStrong = "strong" + StrengthAcceptable = "acceptable" + StrengthWeak = "weak" + StrengthInsecure = "insecure" +) + +// StrengthPart is one part of a cipher suite and its strength. +type StrengthPart struct { + Role string `json:"role"` // cipher, key exchange, authentication, MAC + Name string `json:"name"` // AES-128-GCM, ffdhe2048, RSA-2048 certificate key, HMAC-SHA-1 + Strength algo.Strength `json:"strength"` +} + +// SuiteStrength is a cipher suite's rating: the word, the bits of its weakest rated part, which +// part that is, and the reason for the word. +type SuiteStrength struct { + Version string `json:"version"` + ID uint16 `json:"id"` + Name string `json:"name"` + Word string `json:"word"` + Bits int `json:"bits,omitempty"` + Weakest string `json:"weakest,omitempty"` + Reason string `json:"reason"` + Parts []StrengthPart `json:"parts"` +} + +// KeyStrength is the strength of an accepted key-exchange group or a presented certificate key. +type KeyStrength struct { + Kind string `json:"kind"` // group | certificate-key + Version string `json:"version,omitempty"` + Name string `json:"name"` + Strength algo.Strength `json:"strength"` +} + +// suiteCipher names a suite's bulk cipher in the catalogue's words and rates it. Ciphers NIST does +// not rate (Camellia, ARIA, SEED, SM4, IDEA, GOST, AEGIS, Ascon) are given the bound an exhaustive +// key search sets, stated as such. +func suiteCipher(s tlsprobe.SuiteResult) (string, algo.Strength) { + switch s.Cipher { + case "NULL": + return "no encryption", algo.Strength{Status: algo.StrengthBroken, Basis: "the suite encrypts nothing (RFC 9325 section 4.1: MUST NOT)"} + case "AES": + name := fmt.Sprintf("AES-%d-%s", s.KeyBits, s.Mode) + if st := algo.StrengthOf(name); st.Known() { + return name, st + } + return name, algo.Strength{Bits: s.KeyBits, Status: algo.StrengthRated, Basis: "NIST SP 800-57 Part 1 Rev. 5, Table 2"} + case "CHACHA20": + return "ChaCha20-Poly1305", algo.StrengthOf("ChaCha20-Poly1305") + case "3DES", "DES", "RC4", "RC2": + if s.Export { + return s.Cipher + " (export)", algo.Strength{Status: algo.StrengthBroken, Basis: fmt.Sprintf("export-grade, %d-bit key (RFC 9325 section 4.1: MUST NOT under 112 bits)", s.KeyBits)} + } + return s.Cipher, algo.StrengthOf(s.Cipher) + } + name := fmt.Sprintf("%s-%d %s", s.Cipher, s.KeyBits, s.Mode) + if s.Export { + return name, algo.Strength{Status: algo.StrengthBroken, Basis: "export-grade (RFC 9325 section 4.1)"} + } + if s.KeyBits < 80 { + return name, algo.Strength{Status: algo.StrengthBroken, Basis: fmt.Sprintf("%d-bit key: exhaustive search is practical", s.KeyBits)} + } + return name, algo.Strength{Bits: s.KeyBits, Status: algo.StrengthRated, Basis: fmt.Sprintf("%d-bit key; NIST assigns %s no strength, so this is the bound an exhaustive key search sets", s.KeyBits, s.Cipher)} +} + +// macPart is a non-AEAD suite's MAC. +func macPart(s tlsprobe.SuiteResult) (StrengthPart, bool) { + name := map[string]string{"SHA1": "HMAC-SHA-1", "SHA256": "HMAC-SHA-256", "SHA384": "HMAC-SHA-384", "MD5": "HMAC-MD5"}[s.MAC] + if s.AEAD || name == "" { + return StrengthPart{}, false + } + return StrengthPart{Role: "MAC", Name: name, Strength: algo.StrengthOf(name)}, true +} + +// leafKeys are the end-entity keys the server presented to clients that send a server name +// (a no-SNI-only chain is not what those clients see), weakest first is not assumed. +func leafKeys(rep *tlsprobe.Report) []tlsprobe.KeyInfo { + var out []tlsprobe.KeyInfo + seen := map[string]bool{} + for _, cr := range rep.Certificates { + if cr.NoSNI || len(cr.Chain) == 0 { + continue + } + leaf := cr.Chain[0] + if seen[leaf.SHA256] { + continue + } + seen[leaf.SHA256] = true + out = append(out, leaf.PublicKey) + } + return out +} + +func keyName(k tlsprobe.KeyInfo) string { + switch { + case k.Curve != "": + return fmt.Sprintf("%s %s certificate key", k.Algorithm, k.Curve) + case k.Bits > 0 && (k.Algorithm == "RSA" || k.Algorithm == "DSA"): + return fmt.Sprintf("%s-%d certificate key", k.Algorithm, k.Bits) + } + return k.Algorithm + " certificate key" +} + +// weakestOf picks the weakest part: a broken one first, then the fewest bits. Not-rated parts do +// not decide (they are stated, but say nothing about the number). +func weakestOf(parts []StrengthPart) (StrengthPart, bool) { + var best StrengthPart + found := false + for _, p := range parts { + switch { + case p.Strength.Status == algo.StrengthBroken: + if !found || best.Strength.Status != algo.StrengthBroken { + best, found = p, true + } + case p.Strength.Known(): + if !found || (best.Strength.Status != algo.StrengthBroken && p.Strength.Bits < best.Strength.Bits) { + best, found = p, true + } + } + } + return best, found +} + +// suiteStrengths rates every accepted suite (PRD-010 §2). +func suiteStrengths(rep *tlsprobe.Report) []SuiteStrength { + if rep == nil { + return nil + } + groupsOf := func(version string) []tlsprobe.GroupResult { + var out []tlsprobe.GroupResult + for _, g := range rep.Groups { + if g.Version == version { + out = append(out, g) + } + } + if len(out) == 0 && version != "TLSv1.3" { + // TLS 1.0 and 1.1 use the same ECDHE curves as TLS 1.2, which is where they are probed. + for _, g := range rep.Groups { + if g.Version == "TLSv1.2" { + out = append(out, g) + } + } + } + return out + } + weakestGroup := func(version string) (StrengthPart, bool) { + var parts []StrengthPart + for _, g := range groupsOf(version) { + parts = append(parts, StrengthPart{Role: "key exchange", Name: g.Name, Strength: algo.GroupStrength(g.Name)}) + } + return weakestOf(parts) + } + keys := leafKeys(rep) + weakestKey := func(alg string) (StrengthPart, bool) { + var parts []StrengthPart + for _, k := range keys { + if alg != "" && !strings.EqualFold(k.Algorithm, alg) { + continue + } + parts = append(parts, StrengthPart{Role: "authentication", Name: keyName(k), Strength: algo.KeyStrength(k.Algorithm, k.Bits, k.Curve)}) + } + return weakestOf(parts) + } + + out := make([]SuiteStrength, 0, len(rep.Suites)) + for _, s := range rep.Suites { + cname, cst := suiteCipher(s) + parts := []StrengthPart{{Role: "cipher", Name: cname, Strength: cst}} + noFS := false + switch { + case s.Version == "TLSv1.3": + if p, ok := weakestGroup("TLSv1.3"); ok { + parts = append(parts, p) + } + case s.Kx == "ECDHE" || s.Kx == "ECDH": + if p, ok := weakestGroup(s.Version); ok { + parts = append(parts, p) + } + noFS = s.Kx == "ECDH" + case s.Kx == "DHE" || s.Kx == "DH": + if rep.DH != nil && rep.DH.PrimeBits > 0 { + parts = append(parts, StrengthPart{Role: "key exchange", Name: fmt.Sprintf("%d-bit DH prime", rep.DH.PrimeBits), Strength: algo.KeyStrength("DH", rep.DH.PrimeBits, "")}) + } + noFS = s.Kx == "DH" + case s.Kx == "RSA": + if p, ok := weakestKey("RSA"); ok { + p.Role = "key exchange" + parts = append(parts, p) + } + noFS = true + } + switch { + case s.Anon: + parts = append(parts, StrengthPart{Role: "authentication", Name: "none (anonymous)", Strength: algo.Strength{Status: algo.StrengthBroken, Basis: "no authentication: anyone can impersonate the server"}}) + case s.Version == "TLSv1.3": + if p, ok := weakestKey(""); ok { + parts = append(parts, p) + } + case s.Auth == "RSA" || s.Auth == "ECDSA" || s.Auth == "DSS": + alg := map[string]string{"RSA": "RSA", "ECDSA": "ECDSA", "DSS": "DSA"}[s.Auth] + if p, ok := weakestKey(alg); ok { + parts = append(parts, p) + } + } + if p, ok := macPart(s); ok { + parts = append(parts, p) + } + out = append(out, rateSuite(s, parts, noFS)) + } + return out +} + +// rateSuite turns a suite's parts into its word, bits and reason. +func rateSuite(s tlsprobe.SuiteResult, parts []StrengthPart, noFS bool) SuiteStrength { + r := SuiteStrength{Version: s.Version, ID: s.ID, Name: s.Name, Parts: parts} + w, ok := weakestOf(parts) + if ok { + r.Weakest = w.Name + if w.Strength.Known() { + r.Bits = w.Strength.Bits + } + } + aead := s.AEAD || s.Version == "TLSv1.3" + switch { + case ok && w.Strength.Status == algo.StrengthBroken: + r.Word, r.Reason = StrengthInsecure, fmt.Sprintf("%s is broken: %s", w.Name, w.Strength.Basis) + case ok && r.Bits < 80: + r.Word, r.Reason = StrengthInsecure, fmt.Sprintf("%s gives %d bits, below 80", w.Name, r.Bits) + case ok && r.Bits < 112: + r.Word, r.Reason = StrengthWeak, fmt.Sprintf("%s gives %d bits, below 112 (RFC 9325 section 4.1)", w.Name, r.Bits) + case s.BlockBits == 64: + r.Word, r.Reason = StrengthWeak, "64-bit block cipher: long connections are exposed to birthday attacks (Sweet32; RFC 9325 section 4.2)" + case noFS: + r.Word, r.Reason = StrengthWeak, "no forward secrecy: a stolen server key decrypts recorded traffic (RFC 9325 section 4.1)" + case !ok: + r.Word, r.Reason = StrengthAcceptable, "no part of this suite has a NIST strength to compare" + case r.Bits < 128: + r.Word, r.Reason = StrengthAcceptable, fmt.Sprintf("weakest part: %s, %d bits", w.Name, r.Bits) + case !aead: + r.Word, r.Reason = StrengthAcceptable, fmt.Sprintf("%d bits, but not an AEAD cipher (RFC 9325 section 4.2 prefers AEAD)", r.Bits) + default: + r.Word, r.Reason = StrengthStrong, fmt.Sprintf("every part at least %d bits, AEAD, forward secrecy; weakest part: %s", r.Bits, w.Name) + } + return r +} + +// keyStrengths lists the strength of every accepted group and every presented certificate key. +func keyStrengths(rep *tlsprobe.Report) []KeyStrength { + if rep == nil { + return nil + } + var out []KeyStrength + for _, g := range rep.Groups { + out = append(out, KeyStrength{Kind: "group", Version: g.Version, Name: g.Name, Strength: algo.GroupStrength(g.Name)}) + } + seen := map[string]bool{} + for _, cr := range rep.Certificates { + for _, ci := range cr.Chain { + if ci.SHA256 == "" || seen[ci.SHA256] { + continue + } + seen[ci.SHA256] = true + out = append(out, KeyStrength{Kind: "certificate-key", Name: ci.SHA256, Strength: algo.KeyStrength(ci.PublicKey.Algorithm, ci.PublicKey.Bits, ci.PublicKey.Curve)}) + } + } + return out +} diff --git a/internal/eval/strength_test.go b/internal/eval/strength_test.go new file mode 100644 index 0000000..045a48f --- /dev/null +++ b/internal/eval/strength_test.go @@ -0,0 +1,168 @@ +package eval_test + +import ( + "strings" + "testing" + "time" + + "github.com/offsetsecurity/cipherhorizon/internal/eval" + "github.com/offsetsecurity/cipherhorizon/internal/sshprobe" + "github.com/offsetsecurity/cipherhorizon/internal/tlsprobe" + "github.com/offsetsecurity/cipherhorizon/internal/tlsprobe/registry" +) + +// PRD-010 REQ-STR-2 and REQ-STR-3: a suite is as strong as its weakest part, and the evaluation +// says which part and why. + +func ianaSuite(version string, id uint16) tlsprobe.SuiteResult { + s, ok := registry.SuiteByID(id) + if !ok { + panic("unknown suite") + } + return tlsprobe.SuiteResult{Version: version, ID: s.ID, Name: s.Name, IANARecommended: string(s.Recommended), Kx: s.Kx, Auth: s.Auth, + Cipher: s.Cipher, KeyBits: s.KeyBits, Mode: s.Mode, BlockBits: s.BlockBits, MAC: s.MAC, AEAD: s.AEAD, Export: s.Export, Anon: s.Anon, Ephemeral: s.Ephemeral} +} + +func strengthReport(key tlsprobe.KeyInfo, groups []string, suites ...tlsprobe.SuiteResult) *tlsprobe.Report { + rep := &tlsprobe.Report{SchemaVersion: tlsprobe.SchemaVersion, Target: tlsprobe.Target{Host: "strength.example", Port: 443}, Suites: suites, + Certificates: []tlsprobe.CertResult{{Source: "tls-default", Chain: []tlsprobe.CertInfo{{SHA256: strings.Repeat("ab", 32), PublicKey: key}}}}} + for _, g := range groups { + v := "TLSv1.3" + if strings.HasPrefix(g, "1.2:") { + v, g = "TLSv1.2", strings.TrimPrefix(g, "1.2:") + } + rep.Groups = append(rep.Groups, tlsprobe.GroupResult{Version: v, Name: g}) + } + return rep +} + +func rated(t *testing.T, rep *tlsprobe.Report) map[string]eval.SuiteStrength { + t.Helper() + out := map[string]eval.SuiteStrength{} + for _, s := range eval.Evaluate(rep, time.Date(2026, 10, 18, 0, 0, 0, 0, time.UTC)).SuiteStrength { + out[s.Name] = s + } + return out +} + +func TestSuiteStrengthIsTheWeakestPart(t *testing.T) { + p256 := tlsprobe.KeyInfo{Algorithm: "ECDSA", Bits: 256, Curve: "P-256"} + rsa2048 := tlsprobe.KeyInfo{Algorithm: "RSA", Bits: 2048} + rsa3072 := tlsprobe.KeyInfo{Algorithm: "RSA", Bits: 3072} + + // TLS 1.3, X25519, ECDSA P-256: every part 128 or more, AEAD, forward secret. + r := rated(t, strengthReport(p256, []string{"x25519", "X25519MLKEM768"}, ianaSuite("TLSv1.3", 0x1301), ianaSuite("TLSv1.3", 0x1302))) + if s := r["TLS_AES_128_GCM_SHA256"]; s.Word != eval.StrengthStrong || s.Bits != 128 { + t.Errorf("AES-128 / X25519 / P-256: %+v", s) + } + // The same with an RSA-2048 certificate: 112 bits, Acceptable (OD-1). + r = rated(t, strengthReport(rsa2048, []string{"x25519"}, ianaSuite("TLSv1.3", 0x1302))) + if s := r["TLS_AES_256_GCM_SHA384"]; s.Word != eval.StrengthAcceptable || s.Bits != 112 || s.Weakest != "RSA-2048 certificate key" { + t.Errorf("RSA-2048 certificate: %+v", s) + } + // TLS 1.2 CBC with secp384r1 and RSA-3072: 128 bits, but not AEAD. + r = rated(t, strengthReport(rsa3072, []string{"1.2:secp384r1"}, ianaSuite("TLSv1.2", 0xC028))) // ECDHE_RSA_AES_256_CBC_SHA384 + if s := r["TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384"]; s.Word != eval.StrengthAcceptable || s.Bits != 128 { + t.Errorf("CBC suite: %+v", s) + } + // A 1024-bit DH prime: 80 bits, Weak. + rep := strengthReport(rsa3072, nil, ianaSuite("TLSv1.2", 0x009E)) // DHE_RSA_AES_128_GCM_SHA256 + rep.DH = &tlsprobe.DHResult{PrimeBits: 1024} + if s := rated(t, rep)["TLS_DHE_RSA_WITH_AES_128_GCM_SHA256"]; s.Word != eval.StrengthWeak || s.Bits != 80 { + t.Errorf("DHE-1024: %+v", s) + } + // 3DES: 112 bits but a 64-bit block; RSA key transport. Weak. + if s := rated(t, strengthReport(rsa2048, nil, ianaSuite("TLSv1.2", 0x000A)))["TLS_RSA_WITH_3DES_EDE_CBC_SHA"]; s.Word != eval.StrengthWeak { + t.Errorf("3DES: %+v", s) + } + // Static RSA with AES-GCM: no forward secrecy, Weak. + if s := rated(t, strengthReport(rsa3072, nil, ianaSuite("TLSv1.2", 0x009C)))["TLS_RSA_WITH_AES_128_GCM_SHA256"]; s.Word != eval.StrengthWeak || !strings.Contains(s.Reason, "forward secrecy") { + t.Errorf("static RSA: %+v", s) + } + // RC4, NULL, export, anonymous: Insecure. + for id, name := range map[uint16]string{0x0005: "TLS_RSA_WITH_RC4_128_SHA", 0x0001: "TLS_RSA_WITH_NULL_MD5", + 0x0003: "TLS_RSA_EXPORT_WITH_RC4_40_MD5", 0x0034: "TLS_DH_anon_WITH_AES_128_CBC_SHA"} { + if s := rated(t, strengthReport(rsa2048, nil, ianaSuite("TLSv1.2", id)))[name]; s.Word != eval.StrengthInsecure { + t.Errorf("%s: %+v", name, s) + } + } +} + +// Issue #18: single DES (56-bit key) is Insecure, not Weak. +func TestSingleDESIsInsecure(t *testing.T) { + s := rated(t, strengthReport(tlsprobe.KeyInfo{Algorithm: "RSA", Bits: 2048}, nil, ianaSuite("TLSv1.2", 0x0009)))["TLS_RSA_WITH_DES_CBC_SHA"] + if s.Word != eval.StrengthInsecure || s.Weakest != "DES" { + t.Errorf("single DES: %+v", s) + } +} + +// Every suite in the IANA registry gets a word and a reason; the weakest part is always named when +// a part was rated. +func TestEverySuiteInTheRegistryIsRated(t *testing.T) { + var all []tlsprobe.SuiteResult + for _, s := range registry.Suites() { + v := "TLSv1.2" + if s.Class == registry.ClassTLS13 { + v = "TLSv1.3" + } + all = append(all, ianaSuite(v, s.ID)) + } + rep := strengthReport(tlsprobe.KeyInfo{Algorithm: "RSA", Bits: 2048}, []string{"x25519", "1.2:secp256r1"}, all...) + rep.DH = &tlsprobe.DHResult{PrimeBits: 2048} + words := map[string]int{} + for _, s := range rated(t, rep) { + if s.Word == "" || s.Reason == "" { + t.Errorf("%s: %+v", s.Name, s) + } + words[s.Word]++ + } + if words[eval.StrengthInsecure] == 0 || words[eval.StrengthWeak] == 0 || words[eval.StrengthAcceptable] == 0 { + t.Errorf("the registry should span the words: %v", words) + } +} + +// The evaluation lists each group's and each certificate key's strength. +func TestEvaluationCarriesStrength(t *testing.T) { + ev := eval.Evaluate(strengthReport(tlsprobe.KeyInfo{Algorithm: "RSA", Bits: 2048}, []string{"x25519", "ffdhe2048", "secp384r1"}, ianaSuite("TLSv1.3", 0x1301)), time.Now()) + got := map[string]int{} + for _, k := range ev.KeyStrength { + got[k.Kind+":"+k.Name] = k.Strength.Bits + } + for k, want := range map[string]int{"group:x25519": 128, "group:ffdhe2048": 112, "group:secp384r1": 192, "certificate-key:" + strings.Repeat("ab", 32): 112} { + if got[k] != want { + t.Errorf("%s = %d, want %d (all: %v)", k, got[k], want, got) + } + } + if len(ev.SuiteStrength) != 1 || ev.Version != "eval/9" { + t.Errorf("suiteStrength %d, version %s", len(ev.SuiteStrength), ev.Version) + } +} + +// PRD-010 REQ-STR-7: every advertised SSH algorithm (bar compression) carries its classical +// strength; an RSA host key's comes from the key the server presented. +func TestSSHAlgorithmsCarryStrength(t *testing.T) { + rep := modernSSH() + rep.Algorithms.Kex = append(rep.Algorithms.Kex, "diffie-hellman-group14-sha256") + rep.Algorithms.HostKey = append(rep.Algorithms.HostKey, "rsa-sha2-256", "ssh-rsa") + rep.Algorithms.CipherC2S = append(rep.Algorithms.CipherC2S, "3des-cbc") + rep.Algorithms.MACC2S = append(rep.Algorithms.MACC2S, "hmac-md5") + rep.HostKeys = append(rep.HostKeys, sshprobe.HostKey{Type: "ssh-rsa", Algorithm: "RSA", Bits: 1024, Fingerprint: "SHA256:r"}) + got := map[string]string{} + for _, a := range sshEval(rep).SSHAlgorithms { + if a.Strength != nil { + got[a.Name] = a.Strength.Words() + } else if a.Kind != "compression" { + t.Errorf("%s (%s) has no strength", a.Name, a.Kind) + } + } + for name, want := range map[string]string{ + "curve25519-sha256": "128 bits", "ecdh-sha2-nistp256": "128 bits", "mlkem768x25519-sha256": "192 bits", + "diffie-hellman-group14-sha256": "112 bits", "ssh-ed25519": "128 bits", "ecdsa-sha2-nistp256": "128 bits", + "rsa-sha2-256": "80 bits", "ssh-rsa": "broken", "chacha20-poly1305@openssh.com": "256 bits", "aes128-ctr": "128 bits", + "3des-cbc": "112 bits, disallowed", "hmac-sha2-256-etm@openssh.com": "256 bits", "hmac-md5": "not rated", + } { + if got[name] != want { + t.Errorf("%s = %q, want %q", name, got[name], want) + } + } +} diff --git a/internal/reportpdf/endpoint.go b/internal/reportpdf/endpoint.go index 856179d..963c786 100644 --- a/internal/reportpdf/endpoint.go +++ b/internal/reportpdf/endpoint.go @@ -1095,11 +1095,20 @@ func (x *endpoint) suites() sectionContent { if s.Rank > 0 { rank = fmt.Sprint(s.Rank) } + sr := x.suiteRating(s) + strength := sr.Word + if sr.Bits > 0 { + strength = fmt.Sprintf("%s, %d bits (%s)", sr.Word, sr.Bits, sr.Weakest) + } + st := ink + if sr.Word == eval.StrengthWeak || sr.Word == eval.StrengthInsecure { + st = toneBad.fg + } rows = append(rows, []pdf.Cell{txt(s.Version, pdf.Regular, text2), txt(rank, pdf.Regular, text2), monoCell(s.Name), txt(cipher, pdf.Regular, ink), - txt(kx, pdf.Regular, ink), txt(strings.Join(flags, ", "), pdf.Bold, fc), txt(iana, pdf.Bold, it.fg)}) + txt(kx, pdf.Regular, ink), txt(strength, pdf.Regular, st), txt(strings.Join(flags, ", "), pdf.Bold, fc), txt(iana, pdf.Bold, it.fg)}) } - x.table([]pdf.Col{{Title: "Version", W: 0.85}, {Title: "#", W: 0.3}, {Title: "Suite", W: 3.1}, {Title: "Cipher", W: 1.3}, {Title: "Key exchange", W: 0.9}, - {Title: "Flags", W: 1.3}, {Title: "IANA", W: 1}}, rows) + x.table([]pdf.Col{{Title: "Version", W: 0.7}, {Title: "#", W: 0.25}, {Title: "Suite", W: 2.6}, {Title: "Cipher", W: 1.1}, {Title: "Key exchange", W: 0.8}, + {Title: "Strength", W: 1.5}, {Title: "Flags", W: 1.1}, {Title: "IANA", W: 0.85}}, rows) }} } @@ -1175,9 +1184,9 @@ func (x *endpoint) keyExchange() sectionContent { rank = fmt.Sprint(g.Rank) } iana := map[string]string{"Y": "Recommended", "N": "Not recommended", "D": "Discouraged"}[g.IANARecommended] - gr = append(gr, []pdf.Cell{txt(g.Version, pdf.Regular, text2), txt(rank, pdf.Regular, text2), monoCell(g.Name), pqc, txt(iana, pdf.Regular, text2)}) + gr = append(gr, []pdf.Cell{txt(g.Version, pdf.Regular, text2), txt(rank, pdf.Regular, text2), monoCell(g.Name), txt(x.keyBits("group", g.Name), pdf.Regular, ink), pqc, txt(iana, pdf.Regular, text2)}) } - x.table([]pdf.Col{{Title: "Version", W: 0.9}, {Title: "#", W: 0.35}, {Title: "Group", W: 2.4}, {Title: "Post-quantum", W: 1.9}, {Title: "IANA", W: 1.2}}, gr) + x.table([]pdf.Col{{Title: "Version", W: 0.8}, {Title: "#", W: 0.3}, {Title: "Group", W: 2.1}, {Title: "Classical strength", W: 1.3}, {Title: "Post-quantum", W: 1.7}, {Title: "IANA", W: 1}}, gr) } x.signatureSchemes() if len(r.Citations) > 0 { diff --git a/internal/reportpdf/matrix.go b/internal/reportpdf/matrix.go index 0f18d3a..23d78ce 100644 --- a/internal/reportpdf/matrix.go +++ b/internal/reportpdf/matrix.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/offsetsecurity/cipherhorizon/internal/eval" "github.com/offsetsecurity/cipherhorizon/internal/pdf" "github.com/offsetsecurity/cipherhorizon/internal/tlsprobe" ) @@ -17,17 +18,25 @@ func versionLabel(v string) string { return strings.NewReplacer("TLSv", "TLS ", "SSLv", "SSL ").Replace(v) } -// suiteStrength matches the web report's four words. -func suiteStrength(s tlsprobe.SuiteResult) string { - switch { - case s.Cipher == "NULL" || s.Export || s.Anon || s.Cipher == "RC4": - return "insecure" - case s.BlockBits == 64 || (s.Kx != "" && !s.Ephemeral && (s.Kx == "RSA" || s.Kx == "DH" || s.Kx == "ECDH")): - return "weak" - case !s.AEAD && s.Version != "TLSv1.3": - return "acceptable" +// suiteRating is a suite's classical strength from the evaluation (PRD-010), the same the web +// report and the API show; "not rated" for a result evaluated before eval/9. +func (x *endpoint) suiteRating(s tlsprobe.SuiteResult) eval.SuiteStrength { + for _, r := range x.ev.SuiteStrength { + if r.Version == s.Version && r.ID == s.ID { + return r + } + } + return eval.SuiteStrength{Word: "not rated"} +} + +// keyBits is the strength of a group or a certificate key in words, from the evaluation. +func (x *endpoint) keyBits(kind, name string) string { + for _, k := range x.ev.KeyStrength { + if k.Kind == kind && k.Name == name { + return k.Strength.Words() + } } - return "strong" + return "-" } // matrix draws one row per protocol version: accepted or not, and what the version ran. @@ -57,7 +66,7 @@ func (x *endpoint) matrix() { continue } n++ - counts[suiteStrength(s)]++ + counts[x.suiteRating(s).Word]++ if s.Version == "TLSv1.3" || s.Ephemeral { fs++ } diff --git a/internal/reportpdf/reportpdf_test.go b/internal/reportpdf/reportpdf_test.go index a0e3bcd..7767658 100644 --- a/internal/reportpdf/reportpdf_test.go +++ b/internal/reportpdf/reportpdf_test.go @@ -1,6 +1,7 @@ package reportpdf import ( + "fmt" "strings" "testing" "time" @@ -28,8 +29,8 @@ func legacyServer() *tlsprobe.Report { {Version: "SSLv3", Supported: true, Evidence: "server-hello"}, }, Suites: []tlsprobe.SuiteResult{ - {Version: "TLSv1.2", Name: "TLS_RSA_WITH_RC4_128_SHA", Cipher: "RC4", KeyBits: 128, Mode: "STREAM", Kx: "RSA", IANARecommended: "D", Rank: 1}, - {Version: "TLSv1.2", Name: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", Cipher: "AES", KeyBits: 128, Mode: "GCM", Kx: "ECDHE", Ephemeral: true, AEAD: true, IANARecommended: "Y", Rank: 2}, + {Version: "TLSv1.2", ID: 0x0005, Auth: "RSA", MAC: "SHA1", Name: "TLS_RSA_WITH_RC4_128_SHA", Cipher: "RC4", KeyBits: 128, Mode: "STREAM", Kx: "RSA", IANARecommended: "D", Rank: 1}, + {Version: "TLSv1.2", ID: 0xC02F, Auth: "RSA", MAC: "AEAD", Name: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", Cipher: "AES", KeyBits: 128, Mode: "GCM", Kx: "ECDHE", Ephemeral: true, AEAD: true, IANARecommended: "Y", Rank: 2}, }, Groups: []tlsprobe.GroupResult{{Version: "TLSv1.2", Name: "secp256r1", IANARecommended: "Y", Rank: 1}}, Features: tlsprobe.Features{SecureRenegotiation: &yes, Compression: &no, FallbackSCSV: &no}, @@ -168,3 +169,33 @@ func TestPDFCarriesTheLockup(t *testing.T) { } } } + +// PRD-010 REQ-STR-4: the PDF shows each suite's strength exactly as the evaluation (and so the API +// and the web report) states it, and each group's strength. +func TestPDFAndEvaluationAgreeOnStrength(t *testing.T) { + info := render(t, KindFull) + ev := eval.Evaluate(legacyServer(), now) + if len(ev.SuiteStrength) != 2 { + t.Fatalf("suiteStrength: %+v", ev.SuiteStrength) + } + for _, s := range ev.SuiteStrength { + want := s.Word + if s.Bits > 0 { + want = fmt.Sprintf("%s, %d bits (%s)", s.Word, s.Bits, s.Weakest) + } + if !strings.Contains(strings.Join(strings.Fields(info.Text), ""), strings.Join(strings.Fields(want), "")) { + t.Errorf("the PDF lacks %s's strength %q", s.Name, want) + } + } + // RC4 is insecure; the GCM suite is held down by the 1024-bit RSA certificate key (80 bits). + got := map[string]string{} + for _, s := range ev.SuiteStrength { + got[s.Name] = s.Word + } + if got["TLS_RSA_WITH_RC4_128_SHA"] != eval.StrengthInsecure || got["TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"] != eval.StrengthWeak { + t.Errorf("words: %v", got) + } + if !strings.Contains(strings.ToLower(info.Text), "classical strength") || !strings.Contains(info.Text, "secp256r1 128 bits") { + t.Error("the PDF's group table lacks the classical strength") + } +} diff --git a/internal/reportpdf/ssh.go b/internal/reportpdf/ssh.go index b3c2a12..f72527f 100644 --- a/internal/reportpdf/ssh.go +++ b/internal/reportpdf/ssh.go @@ -237,10 +237,16 @@ func (x *endpoint) algoTable(kind string, names []string, used string) { if n == used { what += " (used by " + sshprobe.CurrentClient + ")" } - rows = append(rows, []pdf.Cell{monoCell(n), chipCell(word, t), txt(what, pdf.Regular, text2)}) + strength := "-" + for _, ea := range x.ev.SSHAlgorithms { + if ea.Kind == kind && ea.Name == n && ea.Strength != nil { + strength = ea.Strength.Words() + } + } + rows = append(rows, []pdf.Cell{monoCell(n), chipCell(word, t), txt(strength, pdf.Regular, ink), txt(what, pdf.Regular, text2)}) } if len(rows) > 0 { - x.table([]pdf.Col{{Title: "Name", W: 2.5}, {Title: "Status", W: 1.9}, {Title: "What it is", W: 2.4}}, rows) + x.table([]pdf.Col{{Title: "Name", W: 2.3}, {Title: "Status", W: 1.7}, {Title: "Classical strength", W: 1.2}, {Title: "What it is", W: 2.1}}, rows) } } diff --git a/internal/server/inventory.go b/internal/server/inventory.go index 6cc6c60..872388d 100644 --- a/internal/server/inventory.go +++ b/internal/server/inventory.go @@ -13,6 +13,7 @@ import ( cdx "github.com/CycloneDX/cyclonedx-go" + "github.com/offsetsecurity/cipherhorizon/internal/algo" "github.com/offsetsecurity/cipherhorizon/internal/cbom" "github.com/offsetsecurity/cipherhorizon/internal/pqc" "github.com/offsetsecurity/cipherhorizon/internal/store" @@ -49,6 +50,9 @@ type inventoryRow struct { Places []sourceLabel `json:"places"` Refs []string `json:"refs"` // bom-refs merged into this row Seen *time.Time `json:"lastSeen,omitempty"` // when the most recent of its places last reported + // Strength is the classical security strength (PRD-010 REQ-STR-6): bits from NIST's methods, or a + // cited broken status, or why it is not rated. Algorithms and keys only. + Strength *algo.Strength `json:"classicalStrength,omitempty"` } func property(c cdx.Component, name string) string { @@ -121,12 +125,15 @@ func inventoryRows(inv *mergedInventory, tab string) []inventoryRow { q := "not-applicable" key := string(assetType(c)) + "\x00" + c.Name detail := detailOf(c) + var strength *algo.Strength switch assetType(c) { case cdx.CryptoAssetTypeAlgorithm: q = "unknown" if st := status[c.BOMRef]; st != "" { q = st } + st := algo.StrengthOf(c.Name) + strength = &st case cdx.CryptoAssetTypeRelatedCryptoMaterial: // A key takes the status of its algorithm. Keys are named by their certificate's // subject, and two certificates may share a subject while holding different keys (an @@ -143,13 +150,16 @@ func inventoryRows(inv *mergedInventory, tab string) []inventoryRow { if len(algs) > 0 { key += "\x00" + strings.Join(algs, "+") detail = strings.TrimPrefix(detail+" · "+strings.Join(algs, " + "), " · ") + // A key is as strong as its algorithm at its size (RSA-2048, ECDSA-P256). + st := algo.StrengthOf(algs[0]) + strength = &st } } i, ok := byName[key] if !ok { i = len(out) byName[key] = i - out = append(out, inventoryRow{Name: c.Name, Kind: tab, Detail: detail, Quantum: q, Places: []sourceLabel{}}) + out = append(out, inventoryRow{Name: c.Name, Kind: tab, Detail: detail, Quantum: q, Places: []sourceLabel{}, Strength: strength}) } row := &out[i] row.Refs = append(row.Refs, c.BOMRef) diff --git a/internal/server/inventory_filters_test.go b/internal/server/inventory_filters_test.go index 72e423c..ed10935 100644 --- a/internal/server/inventory_filters_test.go +++ b/internal/server/inventory_filters_test.go @@ -55,3 +55,46 @@ func TestInventoryFiltersByQuantumAndPurpose(t *testing.T) { } } } + +// PRD-010 REQ-STR-6: inventory rows carry their classical strength: bits with a source, or a +// cited broken status; a key takes the strength of its algorithm at its size. +func TestInventoryRowsCarryClassicalStrength(t *testing.T) { + e := newEnv(t) + if sc := e.scanAndWait(e.addAsset(hybridServer(t).Addr).ID); sc.Status != "done" { + t.Fatalf("scan %+v", sc) + } + type row struct { + Name string `json:"name"` + Strength *struct { + Bits int `json:"bits"` + Status string `json:"status"` + Basis string `json:"basis"` + } `json:"classicalStrength"` + } + var out struct { + Rows []row `json:"rows"` + } + for _, kind := range []string{"algorithm", "key"} { + if s := e.call("GET", "/api/v1/inventory?kind="+kind, nil, &out); s != 200 { + t.Fatalf("%s: %d", kind, s) + } + if len(out.Rows) == 0 { + t.Fatalf("no %s rows", kind) + } + for _, r := range out.Rows { + if r.Strength == nil || r.Strength.Status == "" || r.Strength.Basis == "" { + t.Errorf("%s %s has no classical strength", kind, r.Name) + } + } + } + e.call("GET", "/api/v1/inventory?kind=algorithm", nil, &out) + got := map[string]int{} + for _, r := range out.Rows { + got[r.Name] = r.Strength.Bits + } + for name, want := range map[string]int{"X25519": 128, "X25519MLKEM768": 192, "AES-128-GCM": 128} { + if got[name] != want { + t.Errorf("%s: %d bits, want %d (rows: %v)", name, got[name], want, got) + } + } +} diff --git a/internal/tlscbom/completeness_test.go b/internal/tlscbom/completeness_test.go index 3231b98..d452b24 100644 --- a/internal/tlscbom/completeness_test.go +++ b/internal/tlscbom/completeness_test.go @@ -239,3 +239,37 @@ func encode17(t *testing.T, assets []collector.CryptoAsset) string { schematest.Validate(t, buf.Bytes()) return buf.String() } + +// PRD-010 REQ-STR-8: every algorithm with a NIST-derived strength carries it as +// classicalSecurityLevel, in 1.6 and 1.7 alike; a broken one carries none. +func TestCBOMCarriesClassicalSecurityLevel(t *testing.T) { + assets := tlscbom.FromTLSReport(sampleReport()) + assets = append(assets, tlscbom.FromTLSReport(legacyReport())...) + bom := build(t, assets) + want := map[string]int{"AES-128-GCM": 128, "X25519": 128, "RSA-2048": 112, "ChaCha20-Poly1305": 256, "ECDH-P256": 128, + "X25519MLKEM768": 192, "3DES": 112, "sha256WithRSAEncryption": 128} + none := map[string]bool{"RC4": true, "DES": true, "RSA": true} + for _, c := range *bom.Components { + if c.CryptoProperties == nil || c.CryptoProperties.AlgorithmProperties == nil { + continue + } + lvl := c.CryptoProperties.AlgorithmProperties.ClassicalSecurityLevel + if w, ok := want[c.Name]; ok && (lvl == nil || *lvl != w) { + t.Errorf("%s: classicalSecurityLevel %v, want %d", c.Name, lvl, w) + } + if none[c.Name] && lvl != nil { + t.Errorf("%s: classicalSecurityLevel %d, want none (broken or size unknown)", c.Name, *lvl) + } + delete(want, c.Name) + } + if len(want) > 0 { + t.Errorf("components missing from the test document: %v", want) + } + for _, spec := range []string{cbom.Spec16, cbom.Spec17} { + var buf bytes.Buffer + if err := cbom.EncodeSpec(&buf, bom, spec, true); err != nil { + t.Fatal(err) + } + schematest.Validate(t, buf.Bytes()) + } +} diff --git a/internal/tlsprobe/text.go b/internal/tlsprobe/text.go index 7cbe8cd..0c913ed 100644 --- a/internal/tlsprobe/text.go +++ b/internal/tlsprobe/text.go @@ -12,8 +12,19 @@ import ( // WriteText renders the report for a person. It states what was observed and the // registry facts attached to it (IANA's Recommended flag, export/anonymous/NULL // properties); it passes no verdicts. Evaluation is a separate layer. -func (r *Report) WriteText(w io.Writer) error { - o := &out{w: w} +func (r *Report) WriteText(w io.Writer) error { return r.WriteTextWith(w, Annotations{}) } + +// Annotations add a caller's words to the rows WriteText prints, such as the evaluation's strength +// of each suite and group (PRD-010 REQ-STR-5). This package passes no verdicts itself; a nil +// function adds nothing. +type Annotations struct { + Suite func(s SuiteResult) string + Group func(g GroupResult) string +} + +// WriteTextWith is WriteText with annotations. +func (r *Report) WriteTextWith(w io.Writer, a Annotations) error { + o := &out{w: w, ann: a} r.writeText(o) return o.err } @@ -23,6 +34,7 @@ func (r *Report) WriteText(w io.Writer) error { type out struct { w io.Writer err error + ann Annotations } func (o *out) f(format string, args ...any) { o.fTo(o.w, format, args...) } @@ -135,7 +147,11 @@ func (r *Report) writeSuites(o *out) { if s.Rank > 0 { rank = fmt.Sprint(s.Rank) } - o.fTo(tw, " %s\t%s\t%s\tiana:%s\t%s\n", rank, s.Name, suiteFacts(s), s.IANARecommended, "") + note := "" + if o.ann.Suite != nil { + note = o.ann.Suite(s) + } + o.fTo(tw, " %s\t%s\t%s\tiana:%s\t%s\n", rank, s.Name, suiteFacts(s), s.IANARecommended, note) } o.flush(tw) } @@ -186,6 +202,11 @@ func (r *Report) writeGroups(o *out) { if g.Obsolete { tags = append(tags, "obsolete draft") } + if o.ann.Group != nil { + if n := o.ann.Group(g); n != "" { + tags = append([]string{n}, tags...) + } + } o.fTo(tw, " %s\t%s\tiana:%s\t%s\n", rank, g.Name, g.IANARecommended, strings.Join(tags, ", ")) } o.flush(tw) diff --git a/testing/e2e/journey.mjs b/testing/e2e/journey.mjs index 0f292a3..21d9d73 100644 --- a/testing/e2e/journey.mjs +++ b/testing/e2e/journey.mjs @@ -20,6 +20,7 @@ import { launchPhase } from './launch-phase.mjs'; import { pqsPhase } from './pqs-phase.mjs'; import { sshPhase } from './ssh-phase.mjs'; import { chartsPhase } from './charts-phase.mjs'; +import { strengthPhase } from './strength-phase.mjs'; const arg = (name, def) => { const i = process.argv.indexOf(`--${name}`); return i > 0 ? process.argv[i + 1] : def; }; const BASE = arg('base', 'http://127.0.0.1:8090'); @@ -748,6 +749,7 @@ async function main() { await pqsPhase({ check, js, go, sleep, shot }); await sshPhase({ check, js, go, sleep, shot, viewport, clickText }); await chartsPhase({ check, js, go, sleep, shot, viewport, send }); + await strengthPhase({ check, js, go, sleep, shot, viewport }); // Keyboard: Tab reaches the nav, Escape closes dialogs (NFR-a11y). await go('#/assets'); diff --git a/testing/e2e/strength-phase.mjs b/testing/e2e/strength-phase.mjs new file mode 100644 index 0000000..2ae5feb --- /dev/null +++ b/testing/e2e/strength-phase.mjs @@ -0,0 +1,51 @@ +// PRD-010 (classical strength), driven through the browser on the other phases' data: the TLS +// report shows each suite's strength exactly as the evaluation API states it, groups show their +// bits, and the Inventory shows a classical strength for every algorithm, broken ones included. + +export async function strengthPhase({ check, js, go, sleep, shot, viewport }) { + await viewport(1440, 900, false); + + // E2E-STR-1: on every scanned TLS endpoint, every suite row's Strength cell is the evaluation's word. + const endpoints = await js(`return (await (await fetch('/api/v1/assets?kind=endpoint')).json()).filter((a) => a.protocol !== 'ssh' && a.lastDone)`); + const bad = []; + let suites = 0, groups = 0; + for (const a of endpoints.slice(0, 6)) { + const ev = await js(`return (await fetch('/api/v1/scans/${a.lastDone.id}/evaluation')).json()`); + await go(`#/endpoints/${a.id}?tab=configuration`); + await sleep(1400); + const page = await js(`await new Promise((r) => setTimeout(r, 200)); + for (const d of document.querySelectorAll('details.ver-row')) d.open = true; + return { rows: [...document.querySelectorAll('.ver-body tbody tr')].map((tr) => ({ name: tr.querySelector('code')?.textContent, cell: tr.querySelector('.strength-cell')?.textContent || tr.textContent })), + groups: [...document.querySelectorAll('table')].filter((t) => /Classical strength/.test(t.querySelector('thead')?.textContent || '')).flatMap((t) => [...t.querySelectorAll('tbody tr')].map((tr) => tr.textContent)) }`); + for (const s of ev.suiteStrength || []) { + const row = page.rows.find((r) => r.name === s.name); + suites++; + const word = s.word[0].toUpperCase() + s.word.slice(1); + if (!row || !row.cell.includes(word) || (s.bits && !row.cell.includes(`${s.bits} bits`))) bad.push({ endpoint: a.name, suite: s.name, want: `${word} ${s.bits}`, got: row && row.cell }); + } + groups += page.groups.filter((g) => /\d+ bits|broken|not rated/.test(g)).length; + } + check('E2E-STR-1', 'every suite on every TLS report shows the strength the evaluation API states (word and bits), and groups show their bits', + suites > 10 && bad.length === 0 && groups > 0, JSON.stringify({ suites, groups, bad: bad.slice(0, 3) })); + await shot('strength-report'); + + // E2E-STR-3: the legend's link opens Help at the Classical strength topic, not at its top. + const link = await js(`const a = [...document.querySelectorAll('#main a')].find((x) => /How strength is worked out/.test(x.textContent)); if (!a) return null; a.click(); return a.getAttribute('href')`); + await sleep(1500); + const helpAt = await js(`const s = document.getElementById('help-strength'); return s ? Math.round(s.getBoundingClientRect().top) : null`); + check('E2E-STR-3', 'the strength legend opens Help at the Classical strength topic', + link === '#/help?topic=strength' && helpAt !== null && helpAt >= -5 && helpAt < 200, JSON.stringify({ link, helpAt })); + + // E2E-STR-2: the Inventory's Classical strength column; broken algorithms say so. + await go('#/inventory/algorithms'); + await sleep(1500); + const inv = await js(`const head = [...document.querySelectorAll('#main thead th')].map((x) => x.textContent); + const i = head.indexOf('Classical strength'); + return { i, rows: [...document.querySelectorAll('#main tbody tr')].map((tr) => ({ name: tr.cells[0].textContent, strength: i >= 0 ? tr.cells[i].textContent : '' })) }`); + const by = Object.fromEntries(inv.rows.map((r) => [r.name, r.strength])); + const empty = inv.rows.filter((r) => !r.strength || r.strength === '·'); + check('E2E-STR-2', 'the Inventory lists a classical strength for every algorithm: 3DES 112 disallowed, RC4 broken, AES-256-GCM 256', + inv.i >= 0 && empty.length === 0 && /112 bits/.test(by['3DES'] || '') && /disallowed/.test(by['3DES'] || '') && /broken/.test(by.RC4 || '') && /256 bits/.test(by['AES-256-GCM'] || ''), + JSON.stringify({ column: inv.i, empty: empty.map((r) => r.name), '3DES': by['3DES'], RC4: by.RC4, AES256: by['AES-256-GCM'] })); + await shot('strength-inventory'); +} diff --git a/testing/golden/debian-13.cbom-1.7.json b/testing/golden/debian-13.cbom-1.7.json index ab7061c..63ebbb2 100644 --- a/testing/golden/debian-13.cbom-1.7.json +++ b/testing/golden/debian-13.cbom-1.7.json @@ -293,6 +293,7 @@ "algorithmProperties": { "primitive": "signature", "algorithmFamily": "ML-DSA", + "classicalSecurityLevel": 128, "nistQuantumSecurityLevel": 2 }, "oid": "2.16.840.1.101.3.4.3.17" @@ -329,6 +330,7 @@ "algorithmProperties": { "primitive": "signature", "algorithmFamily": "ML-DSA", + "classicalSecurityLevel": 192, "nistQuantumSecurityLevel": 3 }, "oid": "2.16.840.1.101.3.4.3.18" @@ -365,6 +367,7 @@ "algorithmProperties": { "primitive": "signature", "algorithmFamily": "ML-DSA", + "classicalSecurityLevel": 256, "nistQuantumSecurityLevel": 5 }, "oid": "2.16.840.1.101.3.4.3.19" @@ -401,6 +404,7 @@ "algorithmProperties": { "primitive": "kem", "algorithmFamily": "ML-KEM", + "classicalSecurityLevel": 256, "nistQuantumSecurityLevel": 5 }, "oid": "2.16.840.1.101.3.4.4.3" @@ -437,6 +441,7 @@ "algorithmProperties": { "primitive": "kem", "algorithmFamily": "ML-KEM", + "classicalSecurityLevel": 128, "nistQuantumSecurityLevel": 1 }, "oid": "2.16.840.1.101.3.4.4.1" @@ -473,6 +478,7 @@ "algorithmProperties": { "primitive": "kem", "algorithmFamily": "ML-KEM", + "classicalSecurityLevel": 192, "nistQuantumSecurityLevel": 3 }, "oid": "2.16.840.1.101.3.4.4.2" @@ -550,7 +556,8 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "hash", - "algorithmFamily": "SHA-2" + "algorithmFamily": "SHA-2", + "classicalSecurityLevel": 128 }, "oid": "2.16.840.1.101.3.4.2.1" } @@ -589,7 +596,8 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "hash", - "algorithmFamily": "SHA-2" + "algorithmFamily": "SHA-2", + "classicalSecurityLevel": 192 }, "oid": "2.16.840.1.101.3.4.2.2" } @@ -628,7 +636,8 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "hash", - "algorithmFamily": "SHA-2" + "algorithmFamily": "SHA-2", + "classicalSecurityLevel": 256 }, "oid": "2.16.840.1.101.3.4.2.3" } @@ -664,6 +673,7 @@ "algorithmProperties": { "primitive": "signature", "algorithmFamily": "SLH-DSA", + "classicalSecurityLevel": 128, "nistQuantumSecurityLevel": 1 }, "oid": "2.16.840.1.101.3.4.3.21" @@ -700,6 +710,7 @@ "algorithmProperties": { "primitive": "signature", "algorithmFamily": "SLH-DSA", + "classicalSecurityLevel": 128, "nistQuantumSecurityLevel": 1 }, "oid": "2.16.840.1.101.3.4.3.20" @@ -736,6 +747,7 @@ "algorithmProperties": { "primitive": "signature", "algorithmFamily": "SLH-DSA", + "classicalSecurityLevel": 192, "nistQuantumSecurityLevel": 3 }, "oid": "2.16.840.1.101.3.4.3.23" @@ -772,6 +784,7 @@ "algorithmProperties": { "primitive": "signature", "algorithmFamily": "SLH-DSA", + "classicalSecurityLevel": 192, "nistQuantumSecurityLevel": 3 }, "oid": "2.16.840.1.101.3.4.3.22" @@ -808,6 +821,7 @@ "algorithmProperties": { "primitive": "signature", "algorithmFamily": "SLH-DSA", + "classicalSecurityLevel": 256, "nistQuantumSecurityLevel": 5 }, "oid": "2.16.840.1.101.3.4.3.25" @@ -844,6 +858,7 @@ "algorithmProperties": { "primitive": "signature", "algorithmFamily": "SLH-DSA", + "classicalSecurityLevel": 256, "nistQuantumSecurityLevel": 5 }, "oid": "2.16.840.1.101.3.4.3.24" @@ -880,6 +895,7 @@ "algorithmProperties": { "primitive": "signature", "algorithmFamily": "SLH-DSA", + "classicalSecurityLevel": 128, "nistQuantumSecurityLevel": 1 }, "oid": "2.16.840.1.101.3.4.3.27" @@ -916,6 +932,7 @@ "algorithmProperties": { "primitive": "signature", "algorithmFamily": "SLH-DSA", + "classicalSecurityLevel": 128, "nistQuantumSecurityLevel": 1 }, "oid": "2.16.840.1.101.3.4.3.26" @@ -952,6 +969,7 @@ "algorithmProperties": { "primitive": "signature", "algorithmFamily": "SLH-DSA", + "classicalSecurityLevel": 192, "nistQuantumSecurityLevel": 3 }, "oid": "2.16.840.1.101.3.4.3.29" @@ -988,6 +1006,7 @@ "algorithmProperties": { "primitive": "signature", "algorithmFamily": "SLH-DSA", + "classicalSecurityLevel": 192, "nistQuantumSecurityLevel": 3 }, "oid": "2.16.840.1.101.3.4.3.28" @@ -1024,6 +1043,7 @@ "algorithmProperties": { "primitive": "signature", "algorithmFamily": "SLH-DSA", + "classicalSecurityLevel": 256, "nistQuantumSecurityLevel": 5 }, "oid": "2.16.840.1.101.3.4.3.31" @@ -1060,6 +1080,7 @@ "algorithmProperties": { "primitive": "signature", "algorithmFamily": "SLH-DSA", + "classicalSecurityLevel": 256, "nistQuantumSecurityLevel": 5 }, "oid": "2.16.840.1.101.3.4.3.30" diff --git a/testing/golden/debian-13.cbom.json b/testing/golden/debian-13.cbom.json index d3f4ecf..93d69cf 100644 --- a/testing/golden/debian-13.cbom.json +++ b/testing/golden/debian-13.cbom.json @@ -287,6 +287,7 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "signature", + "classicalSecurityLevel": 128, "nistQuantumSecurityLevel": 2 }, "oid": "2.16.840.1.101.3.4.3.17" @@ -322,6 +323,7 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "signature", + "classicalSecurityLevel": 192, "nistQuantumSecurityLevel": 3 }, "oid": "2.16.840.1.101.3.4.3.18" @@ -357,6 +359,7 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "signature", + "classicalSecurityLevel": 256, "nistQuantumSecurityLevel": 5 }, "oid": "2.16.840.1.101.3.4.3.19" @@ -392,6 +395,7 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "kem", + "classicalSecurityLevel": 256, "nistQuantumSecurityLevel": 5 }, "oid": "2.16.840.1.101.3.4.4.3" @@ -427,6 +431,7 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "kem", + "classicalSecurityLevel": 128, "nistQuantumSecurityLevel": 1 }, "oid": "2.16.840.1.101.3.4.4.1" @@ -462,6 +467,7 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "kem", + "classicalSecurityLevel": 192, "nistQuantumSecurityLevel": 3 }, "oid": "2.16.840.1.101.3.4.4.2" @@ -538,7 +544,8 @@ "cryptoProperties": { "assetType": "algorithm", "algorithmProperties": { - "primitive": "hash" + "primitive": "hash", + "classicalSecurityLevel": 128 }, "oid": "2.16.840.1.101.3.4.2.1" } @@ -576,7 +583,8 @@ "cryptoProperties": { "assetType": "algorithm", "algorithmProperties": { - "primitive": "hash" + "primitive": "hash", + "classicalSecurityLevel": 192 }, "oid": "2.16.840.1.101.3.4.2.2" } @@ -614,7 +622,8 @@ "cryptoProperties": { "assetType": "algorithm", "algorithmProperties": { - "primitive": "hash" + "primitive": "hash", + "classicalSecurityLevel": 256 }, "oid": "2.16.840.1.101.3.4.2.3" } @@ -649,6 +658,7 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "signature", + "classicalSecurityLevel": 128, "nistQuantumSecurityLevel": 1 }, "oid": "2.16.840.1.101.3.4.3.21" @@ -684,6 +694,7 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "signature", + "classicalSecurityLevel": 128, "nistQuantumSecurityLevel": 1 }, "oid": "2.16.840.1.101.3.4.3.20" @@ -719,6 +730,7 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "signature", + "classicalSecurityLevel": 192, "nistQuantumSecurityLevel": 3 }, "oid": "2.16.840.1.101.3.4.3.23" @@ -754,6 +766,7 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "signature", + "classicalSecurityLevel": 192, "nistQuantumSecurityLevel": 3 }, "oid": "2.16.840.1.101.3.4.3.22" @@ -789,6 +802,7 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "signature", + "classicalSecurityLevel": 256, "nistQuantumSecurityLevel": 5 }, "oid": "2.16.840.1.101.3.4.3.25" @@ -824,6 +838,7 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "signature", + "classicalSecurityLevel": 256, "nistQuantumSecurityLevel": 5 }, "oid": "2.16.840.1.101.3.4.3.24" @@ -859,6 +874,7 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "signature", + "classicalSecurityLevel": 128, "nistQuantumSecurityLevel": 1 }, "oid": "2.16.840.1.101.3.4.3.27" @@ -894,6 +910,7 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "signature", + "classicalSecurityLevel": 128, "nistQuantumSecurityLevel": 1 }, "oid": "2.16.840.1.101.3.4.3.26" @@ -929,6 +946,7 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "signature", + "classicalSecurityLevel": 192, "nistQuantumSecurityLevel": 3 }, "oid": "2.16.840.1.101.3.4.3.29" @@ -964,6 +982,7 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "signature", + "classicalSecurityLevel": 192, "nistQuantumSecurityLevel": 3 }, "oid": "2.16.840.1.101.3.4.3.28" @@ -999,6 +1018,7 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "signature", + "classicalSecurityLevel": 256, "nistQuantumSecurityLevel": 5 }, "oid": "2.16.840.1.101.3.4.3.31" @@ -1034,6 +1054,7 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "signature", + "classicalSecurityLevel": 256, "nistQuantumSecurityLevel": 5 }, "oid": "2.16.840.1.101.3.4.3.30" diff --git a/testing/golden/ubuntu-24.04.cbom-1.7.json b/testing/golden/ubuntu-24.04.cbom-1.7.json index 7fc9aa5..b7398c6 100644 --- a/testing/golden/ubuntu-24.04.cbom-1.7.json +++ b/testing/golden/ubuntu-24.04.cbom-1.7.json @@ -301,7 +301,8 @@ "algorithmProperties": { "primitive": "signature", "algorithmFamily": "EdDSA", - "ellipticCurve": "other/Ed25519" + "ellipticCurve": "other/Ed25519", + "classicalSecurityLevel": 128 }, "oid": "1.3.101.112" } @@ -378,7 +379,8 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "hash", - "algorithmFamily": "SHA-2" + "algorithmFamily": "SHA-2", + "classicalSecurityLevel": 128 }, "oid": "2.16.840.1.101.3.4.2.1" } @@ -417,7 +419,8 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "hash", - "algorithmFamily": "SHA-2" + "algorithmFamily": "SHA-2", + "classicalSecurityLevel": 192 }, "oid": "2.16.840.1.101.3.4.2.2" } @@ -456,7 +459,8 @@ "assetType": "algorithm", "algorithmProperties": { "primitive": "hash", - "algorithmFamily": "SHA-2" + "algorithmFamily": "SHA-2", + "classicalSecurityLevel": 256 }, "oid": "2.16.840.1.101.3.4.2.3" } diff --git a/testing/golden/ubuntu-24.04.cbom.json b/testing/golden/ubuntu-24.04.cbom.json index 352a884..58d287c 100644 --- a/testing/golden/ubuntu-24.04.cbom.json +++ b/testing/golden/ubuntu-24.04.cbom.json @@ -294,7 +294,8 @@ "cryptoProperties": { "assetType": "algorithm", "algorithmProperties": { - "primitive": "signature" + "primitive": "signature", + "classicalSecurityLevel": 128 }, "oid": "1.3.101.112" } @@ -370,7 +371,8 @@ "cryptoProperties": { "assetType": "algorithm", "algorithmProperties": { - "primitive": "hash" + "primitive": "hash", + "classicalSecurityLevel": 128 }, "oid": "2.16.840.1.101.3.4.2.1" } @@ -408,7 +410,8 @@ "cryptoProperties": { "assetType": "algorithm", "algorithmProperties": { - "primitive": "hash" + "primitive": "hash", + "classicalSecurityLevel": 192 }, "oid": "2.16.840.1.101.3.4.2.2" } @@ -446,7 +449,8 @@ "cryptoProperties": { "assetType": "algorithm", "algorithmProperties": { - "primitive": "hash" + "primitive": "hash", + "classicalSecurityLevel": 256 }, "oid": "2.16.840.1.101.3.4.2.3" } diff --git a/web/static/js/views/help.js b/web/static/js/views/help.js index b17d321..0bb5cb8 100644 --- a/web/static/js/views/help.js +++ b/web/static/js/views/help.js @@ -38,6 +38,7 @@ const TOPICS = [ ['import', 'Import a CBOM'], ['levels', 'Post-quantum readiness levels'], ['score', 'The TLS hygiene score'], + ['strength', 'Classical strength'], ['baseline', 'The SSH baseline'], ['limits', 'What is and is not checked'], ['charts', 'Charts and filtered lists'], @@ -101,6 +102,16 @@ export async function render(root) { 'Each distinct problem counts once. A critical finding limits the score to 29 and a high one to 74, so a serious problem cannot be averaged away.'), h('p', null, 'Bands: Strong (90 and above), Sound (75 to 89), Fair (55 to 74), Weak (30 to 54), Broken (below 30). Every point taken off is listed on the endpoint’s page with the finding it came from.'), h('p', { class: 'text-2' }, 'Post-quantum findings are not counted: the readiness level answers that separately, so an endpoint can score Strong and still be exposed to harvest-now-decrypt-later. The weights are this product’s own choice, not a standard. SSH servers have no score: see the SSH baseline.')), + sec('strength', 'Classical strength', + h('p', null, 'Every algorithm, key and cipher suite shows how strong it is against today’s computers, in bits: about 2 to that power operations would break it. The numbers are NIST’s, computed the way NIST computes them: RSA and Diffie-Hellman by the General Number Field Sieve estimate (2048 bits gives 112, 3072 gives 128), elliptic curves at half their order (P-256 and X25519 give 128), AES by its key, hashes by their collision resistance.'), + h('p', null, h('b', null, 'A cipher suite is as strong as its weakest part'), ': the cipher, the key exchange, the certificate key and the MAC. It is '), + h('ul', null, [ + [h('b', null, 'Insecure'), ' when a part is broken (no encryption, export, RC4, single DES, no authentication) or under 80 bits;'], + [h('b', null, 'Weak'), ' from 80 to 111 bits, or with a 64-bit block cipher such as 3DES, or without forward secrecy;'], + [h('b', null, 'Acceptable'), ' at 112 bits or more, but under 128 or not an AEAD cipher;'], + [h('b', null, 'Strong'), ' at 128 bits or more in every part, with an AEAD cipher and forward secrecy.'], + ].map((x) => h('li', null, x))), + h('p', { class: 'text-2' }, 'An RSA-2048 certificate is 112 bits, so the suites it authenticates read Acceptable. Each suite names its weakest part. The words are this product’s own; the numbers and their sources are in the methodology. Post-quantum readiness is separate: a 128-bit classical algorithm can still be broken by a quantum computer.')), sec('baseline', 'The SSH baseline', h('p', null, 'An SSH server is judged against a baseline: a named, versioned list of what an SSH server may offer and must offer, every entry cited. It ', h('b', null, 'meets'), ' the baseline when nothing it offers should be off and every requirement holds; otherwise the report lists the changes and the ', code('sshd_config'), ' lines that make them. A server whose lists could not be read is not judged.'), @@ -144,5 +155,8 @@ export async function render(root) { sec('sources', 'Sources', citations(SOURCES.map(([title, url]) => ({ title, url }))))); root.appendChild(h('div', { class: 'help-layout' }, toc, body)); + // #/help?topic=strength opens at that topic (links from the report's legends). + const topic = new URLSearchParams(location.hash.split('?')[1] || '').get('topic'); + if (topic && TOPICS.some(([id]) => id === topic)) setTimeout(() => document.getElementById(`help-${topic}`)?.scrollIntoView({ block: 'start' }), 0); return null; } diff --git a/web/static/js/views/inventory.js b/web/static/js/views/inventory.js index c7a5ec2..cb3f56f 100644 --- a/web/static/js/views/inventory.js +++ b/web/static/js/views/inventory.js @@ -32,6 +32,15 @@ const QUANTUM = { unknown: ['? not classified', 'neutral'], 'not-applicable': ['·', 'none'], }; +// The classical strength of an algorithm or key (PRD-010): bits from NIST's methods, or broken with +// its cited reason, or why it is not rated. The source is in the tooltip. +function strengthCell(st) { + if (!st) return h('span', { class: 'muted' }, '·'); + if (st.status === 'broken') return badge('✖ broken', 'bad', st.basis); + if (st.status === 'disallowed') return h('span', { title: st.basis }, `${st.bits} bits `, badge('disallowed', 'warn', st.basis)); + if (st.status === 'rated') return h('span', { class: st.bits < 112 ? 'text-bad' : '', title: st.basis }, `${st.bits} bits`); + return h('span', { class: 'muted', title: st.basis }, 'not rated'); +} const quantumBadge = (q) => { const [t, k] = QUANTUM[q] || [q, 'neutral']; return k === 'none' ? h('span', { class: 'muted' }, t) : badge(t, k); }; // Certificates and their keys are named by their subject; people know them by the common name. const cnOf = (dn) => { const m = /CN=([^,]+)/.exec(dn || ''); return m ? m[1] : dn; }; @@ -376,10 +385,11 @@ async function itemsList(root, kind, query) { } const showQuantum = kind === 'algorithm' || kind === 'key'; list.appendChild(h('div', { class: 'tablewrap' }, h('table', { class: 'compact invtable' }, - h('thead', null, h('tr', null, ['Name', kind === 'software' ? 'Version' : 'Kind', showQuantum ? 'Post-quantum' : null, 'Seen on', 'Last seen'].filter(Boolean).map((c) => h('th', { scope: 'col' }, c)))), + h('thead', null, h('tr', null, ['Name', kind === 'software' ? 'Version' : 'Kind', showQuantum ? 'Classical strength' : null, showQuantum ? 'Post-quantum' : null, 'Seen on', 'Last seen'].filter(Boolean).map((c) => h('th', { scope: 'col' }, c)))), h('tbody', null, rows.map((r) => h('tr', null, h('td', null, h('b', { title: r.name }, shownName(r.name))), h('td', { class: 'muted' }, humanDetail(r.detail) || '·'), + showQuantum ? h('td', null, strengthCell(r.classicalStrength)) : null, showQuantum ? h('td', null, quantumBadge(r.quantum)) : null, h('td', null, placesCell(r.places)), h('td', { class: 'muted nowrapcell' }, r.lastSeen ? relTime(r.lastSeen) : '·'))))))); diff --git a/web/static/js/views/report-ssh.js b/web/static/js/views/report-ssh.js index 0d1c5da..88e159c 100644 --- a/web/static/js/views/report-ssh.js +++ b/web/static/js/views/report-ssh.js @@ -38,16 +38,27 @@ const statusOf = (a) => { }; const offCount = (all) => all.filter((a) => a.baseline === 'off').length; -/** One kind of algorithm as a table: name, what it is, its verdict, and the one a current client uses. */ +// An algorithm's classical strength (PRD-010): bits from NIST's methods, broken with its reason, or +// why it is not rated; the source is in the tooltip. +function strengthWords(st) { + if (!st) return h('span', { class: 'muted' }, '·'); + if (st.status === 'broken') return badge('✖ broken', 'bad', st.basis); + if (st.status === 'disallowed') return h('span', { title: st.basis }, `${st.bits} bits `, badge('disallowed', 'warn', st.basis)); + if (st.status === 'rated') return h('span', { class: st.bits < 112 ? 'text-bad' : '', title: st.basis }, `${st.bits} bits`); + return h('span', { class: 'muted', title: st.basis }, 'not rated'); +} + +/** One kind of algorithm as a table: name, what it is, its strength, its verdict, and the one a current client uses. */ function algoTable(ev, kind, caption) { const rows = (ev.sshAlgorithms || []).filter((a) => a.kind === kind); if (!rows.length) return null; return h('div', null, caption ? h('h3', null, caption) : null, h('div', { class: 'tablewrap' }, h('table', { class: 'compact' }, - h('thead', null, h('tr', null, ['Name', 'What it is', 'Baseline', ''].map((c) => h('th', { scope: 'col' }, c)))), + h('thead', null, h('tr', null, ['Name', 'What it is', 'Classical strength', 'Baseline', ''].map((c) => h('th', { scope: 'col' }, c)))), h('tbody', null, rows.map((a) => h('tr', null, h('td', null, h('code', null, a.name)), h('td', { class: 'muted' }, a.display || ''), + h('td', { class: 'nowrap' }, kind === 'compression' ? '' : strengthWords(a.strength)), h('td', null, versionState(statusOf(a))), h('td', null, a.used ? badge('used by OpenSSH 10.6', 'info', 'What a current client negotiates') : null))))))); } diff --git a/web/static/js/views/report.js b/web/static/js/views/report.js index 7964b17..5116017 100644 --- a/web/static/js/views/report.js +++ b/web/static/js/views/report.js @@ -197,14 +197,26 @@ export function summaryView(ctx) { const VERSION_ORDER = ['TLSv1.3', 'TLSv1.2', 'TLSv1.1', 'TLSv1.0', 'SSLv3', 'SSLv2']; const versionLabel = (v) => v.replace('TLSv', 'TLS ').replace('SSLv', 'SSL '); -/** How strong a cipher suite is, in four words a reader can compare across versions. */ -function suiteStrength(s) { - if (s.cipher === 'NULL' || s.export || s.anon || s.cipher === 'RC4') return 'insecure'; - if (s.blockBits === 64 || (s.kx && !s.ephemeral && ['RSA', 'DH', 'ECDH'].includes(s.kx))) return 'weak'; - if (!s.aead && s.version !== 'TLSv1.3') return 'acceptable'; - return 'strong'; -} +/** A suite's classical strength, from the evaluation (PRD-010): the word, the bits of its weakest + * part, which part, and why. The rules are the server's, cited in docs/methodology.md; the page only + * shows them. A result evaluated before eval/9 has none and shows "not rated". */ +const suiteRating = (ev, s) => ((ev && ev.suiteStrength) || []).find((x) => x.version === s.version && x.id === s.id) || null; +const suiteStrength = (ev, s) => (suiteRating(ev, s) || {}).word || 'unrated'; const STRENGTHS = [['strong', 'Strong', 'good'], ['acceptable', 'Acceptable', 'neutral'], ['weak', 'Weak', 'warn'], ['insecure', 'Insecure', 'bad']]; +/** The strength of a certificate key or group from the evaluation, in words ("112 bits"). */ +const keyBits = (ev, kind, name) => { + const k = ((ev && ev.keyStrength) || []).find((x) => x.kind === kind && x.name === name); + if (!k) return ''; + const st = k.strength; + return st.status === 'rated' ? `${st.bits} bits` : st.status === 'disallowed' ? `${st.bits} bits, disallowed` : st.status === 'broken' ? 'broken' : 'not rated'; +}; +const strengthCell = (ev, s) => { + const r = suiteRating(ev, s); + if (!r) return h('span', { class: 'muted' }, 'not rated'); + const [, word, tone] = STRENGTHS.find(([k]) => k === r.word) || [null, r.word, 'neutral']; + return h('span', { class: 'strength-cell', title: r.reason }, badge(word, tone), + h('span', { class: 'hint' }, r.bits ? ` ${r.bits} bits · ${r.weakest}` : r.weakest ? ` ${r.weakest}` : '')); +}; function strengthBar(counts, total, max) { const W = 160, H = 10; @@ -244,7 +256,7 @@ function versionState([tone, sym, state, judgement]) { /** The protocol matrix: every version, accepted or not, and for each accepted one what it ran. */ function protocolMatrix(ctx) { - const { rep } = ctx; + const { rep, ev } = ctx; const byVersion = Object.fromEntries((rep.protocols || []).map((p) => [p.version, p])); const suites = rep.suites || []; const groups = rep.groups || []; @@ -264,7 +276,7 @@ function protocolMatrix(ctx) { const p = byVersion[v]; const vs = suites.filter((s) => s.version === v); const counts = {}; - for (const s of vs) counts[suiteStrength(s)] = (counts[suiteStrength(s)] || 0) + 1; + for (const s of vs) counts[suiteStrength(ev, s)] = (counts[suiteStrength(ev, s)] || 0) + 1; const fs = vs.filter((s) => s.version === 'TLSv1.3' || s.ephemeral).length; const vg = groups.filter((g) => g.version === v); const pq = vg.filter((g) => g.pq && !g.obsolete); @@ -281,11 +293,9 @@ function protocolMatrix(ctx) { accepted ? h('span', { class: 'ver-open' }, h('span', { class: 't-show' }, 'Show suites'), h('span', { class: 't-hide' }, 'Hide suites')) : h('span', null)); if (!accepted) return h('div', { class: `ver-row off vr-${verdict[0]}` }, h('div', { class: 'ver-line' }, ...line.childNodes)); const suiteRows = vs.map((s) => { - const st = suiteStrength(s); - const [, word, tone] = STRENGTHS.find(([k]) => k === st); return h('tr', null, h('td', { class: 'num' }, s.rank || '·'), h('td', null, h('code', null, s.name)), h('td', null, s.cipher === 'NULL' ? 'none' : `${s.cipher}-${s.keyBits} ${s.mode}`), h('td', null, s.kx || 'TLS 1.3'), - h('td', null, badge(word, tone)), h('td', { class: 'muted' }, ianaLabel(s.ianaRecommended))); + h('td', null, strengthCell(ev, s)), h('td', { class: 'muted' }, ianaLabel(s.ianaRecommended))); }); return h('details', { class: `ver-row on vr-${verdict[0]}`, id: `ver-${v}` }, line, h('div', { class: 'ver-body' }, @@ -296,7 +306,8 @@ function protocolMatrix(ctx) { vg.length ? h('p', null, h('b', null, 'Key-exchange groups: '), vg.map((g, i) => [i ? ', ' : '', h('span', { class: g.pq && !g.obsolete ? 'pq-group' : '' }, g.name), g.pq && !g.obsolete ? ' (post-quantum)' : ''])) : null)); }); - const legend = h('p', { class: 'hint strength-legend' }, h('span', null, 'Suite strength:'), STRENGTHS.map(([k, w]) => h('span', null, h('span', { class: `swatch sw-st-${k}`, 'aria-hidden': 'true' }), ` ${w} `))); + const legend = h('p', { class: 'hint strength-legend' }, h('span', null, 'Suite strength:'), STRENGTHS.map(([k, w]) => h('span', null, h('span', { class: `swatch sw-st-${k}`, 'aria-hidden': 'true' }), ` ${w} `)), + h('span', null, 'A suite is as strong as its weakest part (cipher, key exchange, certificate key, MAC), in bits as NIST estimates them. ', h('a', { href: '#/help?topic=strength' }, 'How strength is worked out'))); const n = rep.negotiated || {}; return h('section', { class: 'card matrix', id: 'sec-protocols', 'aria-labelledby': 'matrix-h' }, h('span', { id: 'sec-suites' }), @@ -577,7 +588,7 @@ function certificatesBody(ctx) { const draw = () => { tabs.replaceChildren(...views.map((v, i) => h('button', { type: 'button', role: 'tab', class: 'chaintab', 'aria-selected': String(i === selected), onclick: () => { selected = i; draw(); } }, chainLabel(v)))); - panel.replaceChildren(chainPanel(views[selected], certs, asset)); + panel.replaceChildren(chainPanel(views[selected], certs, asset, ctx.ev)); }; draw(); const first = views.find((v) => !v.noSni) || views[0]; @@ -587,7 +598,7 @@ function certificatesBody(ctx) { const trustedBy = (first.trust || []).filter((t) => t.trusted).map((t) => t.store); if (first.private && first.private.trusted) trustedBy.push('your CA bundle'); const facts = [ - `Issued to ${cn(leaf.subject)} by ${cn(leaf.issuer)}; ${keyDesc(leaf.publicKey)} key, signed with ${leaf.signatureAlgorithm.name}.`, + `Issued to ${cn(leaf.subject)} by ${cn(leaf.issuer)}; ${keyDesc(leaf.publicKey)} key${keyBits(ctx.ev, 'certificate-key', leaf.sha256) ? ` (${keyBits(ctx.ev, 'certificate-key', leaf.sha256)})` : ''}, signed with ${leaf.signatureAlgorithm.name}.`, left === null ? null : left < 0 ? `Expired ${-left} days ago (${fmtDay(leaf.notAfter)}).` : `Valid until ${fmtDay(leaf.notAfter)}: ${left} days left of a ${first.lifetime.days}-day lifetime.`, trustedBy.length ? `Trusted by ${trustedBy.join(', ')}.` : 'Not trusted by any root store checked.', @@ -599,14 +610,14 @@ function certificatesBody(ctx) { return { verdict, facts, lead: h('div', null, views.length > 1 ? tabs : null, panel) }; } -function chainPanel(v, certs, asset) { +function chainPanel(v, certs, asset, ev) { const cr = certs.find((c) => c.chain[0] && c.chain[0].sha256 === v.leaf && !!c.noSni === !!v.noSni) || certs.find((c) => c.chain[0] && c.chain[0].sha256 === v.leaf); if (v.error || !cr) return h('p', { class: 'muted' }, v.error || 'This chain could not be analysed.'); return h('div', { class: 'chainpanel' }, v.noSni ? h('div', { class: 'callout info', role: 'note' }, h('b', null, 'Only clients that send no server name see this certificate. '), 'Every current client sends one, and many large sites deliberately serve a placeholder here. It is shown for information and is not scored.') : null, h('h3', null, 'Chain as served'), - servedDiagram(v, cr), + servedDiagram(v, cr, ev), h('h3', null, 'Trust'), trustRows(v), h('details', { class: 'alldata' }, h('summary', null, 'Validity, names and every field'), @@ -619,7 +630,7 @@ function chainPanel(v, certs, asset) { certChain(cr, { host: asset.host, noTrust: true }))); } -function servedDiagram(v, cr) { +function servedDiagram(v, cr, ev) { const issuesAt = {}; for (const i of v.served.issues || []) (issuesAt[i.index] = issuesAt[i.index] || []).push(i); const onPath = new Set(v.served.path || []); @@ -627,7 +638,7 @@ function servedDiagram(v, cr) { h('span', { class: 'servedpos' }, String(i)), h('div', null, h('div', { class: 'servedcn' }, cn(c.subject)), - h('div', { class: 'hint' }, i === 0 ? 'leaf' : c.selfSigned ? 'root' : 'intermediate', ` · ${keyDesc(c.publicKey)} · until ${fmtDay(c.notAfter)}`), + h('div', { class: 'hint' }, i === 0 ? 'leaf' : c.selfSigned ? 'root' : 'intermediate', ` · ${keyDesc(c.publicKey)}${keyBits(ev, 'certificate-key', c.sha256) ? ` (${keyBits(ev, 'certificate-key', c.sha256)})` : ''} · until ${fmtDay(c.notAfter)}`), (issuesAt[i] || []).map((is) => h('div', { class: 'servedissue' }, `⚠ ${is.kind}: ${is.detail}`)), (v.served.expiresFirst || []).includes(i) ? h('div', { class: 'servedissue' }, '⚠ expires before the leaf') : null))); if (v.served.missing) { @@ -766,17 +777,18 @@ function suiteProblems(s) { return out; } -function suiteTable(rows) { +function suiteTable(rows, ev) { return h('div', { class: 'tablewrap' }, h('table', { class: 'compact' }, - h('thead', null, h('tr', null, ['Version', '#', 'Suite', 'Cipher', 'Key exchange', 'Flags', 'IANA'].map((c) => h('th', { scope: 'col' }, c)))), + h('thead', null, h('tr', null, ['Version', '#', 'Suite', 'Cipher', 'Key exchange', 'Strength', 'Flags', 'IANA'].map((c) => h('th', { scope: 'col' }, c)))), h('tbody', null, rows.map((s) => h('tr', null, h('td', null, s.version), h('td', { class: 'num' }, s.rank || '·'), h('td', null, h('code', null, s.name)), h('td', null, s.cipher === 'NULL' ? 'none' : `${s.cipher}-${s.keyBits} ${s.mode}`), h('td', null, s.kx || 'TLS 1.3'), + h('td', null, strengthCell(ev, s)), h('td', null, suiteProblems(s).filter(([t]) => t !== 'IANA discouraged').map(([t, k]) => h('span', null, badge(t, k), ' ')), s.aead ? badge('AEAD', 'good') : null), h('td', null, badge(ianaLabel(s.ianaRecommended), s.ianaRecommended === 'Y' ? 'good' : s.ianaRecommended === 'D' ? 'bad' : 'neutral'))))))); } function suitesBody(ctx) { - const { rep } = ctx; + const { rep, ev } = ctx; const suites = rep.suites || []; const problem = suites.filter((s) => suiteProblems(s).some(([, k]) => k !== 'neutral')); const fine = suites.filter((s) => !problem.includes(s)); @@ -787,8 +799,8 @@ function suitesBody(ctx) { `${plural(suites.length, 'suite')} accepted: ${aead} AEAD${cbc ? `, ${cbc} CBC` : ''}${problem.length ? `, ${problem.length} with a weakness` : ''}.`, order.length ? `Order: ${order.join('; ')}.` : null, ]; - const lead = problem.length ? h('div', null, h('h3', null, 'Suites with a weakness'), suiteTable(problem)) : null; - return { facts, lead, all: fine.length ? h('div', null, h('p', { class: 'hint' }, 'Suites with no weakness flagged:'), suiteTable(fine)) : null }; + const lead = problem.length ? h('div', null, h('h3', null, 'Suites with a weakness'), suiteTable(problem, ev)) : null; + return { facts, lead, all: fine.length ? h('div', null, h('p', { class: 'hint' }, 'Suites with no weakness flagged:'), suiteTable(fine, ev)) : null }; } function keyExchangeBody(ctx) { @@ -818,12 +830,13 @@ function keyExchangeBody(ctx) { const groupRow = (g) => h('tr', null, h('td', { class: 'num' }, g.rank || '·'), h('td', null, h('code', null, g.name)), + h('td', { class: 'nowrap' }, keyBits(ev, 'group', g.name) || h('span', { class: 'muted' }, '·')), h('td', null, g.pq ? badge(g.hybrid ? 'hybrid post-quantum' : 'post-quantum', g.obsolete ? 'warn' : 'good') : h('span', { class: 'muted' }, 'classical'), g.obsolete ? badge('obsolete draft', 'warn') : null), h('td', null, badge(ianaLabel(g.ianaRecommended), g.ianaRecommended === 'Y' ? 'good' : 'neutral'))); const groupTable = (v) => h('div', null, h('h3', null, v), h('div', { class: 'tablewrap' }, h('table', { class: 'compact' }, - h('thead', null, h('tr', null, ['#', 'Group', 'Post-quantum', 'IANA'].map((c) => h('th', { scope: 'col' }, c)))), + h('thead', null, h('tr', null, ['#', 'Group', 'Classical strength', 'Post-quantum', 'IANA'].map((c) => h('th', { scope: 'col' }, c)))), h('tbody', null, groups.filter((g) => g.version === v).map(groupRow))))); const all = h('div', null, gv.map(groupTable), rep.dh ? h('p', null, `DHE prime fingerprint ${rep.dh.primeSha256}, sampled from `, h('code', null, rep.dh.suite)) : null, diff --git a/web/static/style.css b/web/static/style.css index dbddf36..c46c9ff 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -1586,6 +1586,10 @@ a.sumtile:focus-visible::after, a.recent-row:focus-visible::after { transform: s .fade-host { transition: opacity 140ms ease; } .fade-host.fading { opacity: 0; transition-duration: 80ms; } +/* PRD-010: classical strength. Below 112 bits is weak (RFC 9325 section 4.1), said in words beside it. */ +.text-bad { color: var(--bad); font-weight: 550; } +.strength-cell { display: inline-flex; flex-wrap: wrap; align-items: baseline; gap: .1rem .35rem; } + @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } } From da10257be0a8ca05df4eff3d7aee3f5540b70bdc Mon Sep 17 00:00:00 2001 From: Khalid Saifullah Khan Date: Wed, 21 Oct 2026 12:45:10 +0530 Subject: [PATCH 3/5] fix(deps): build with Go 1.27.3 (GO-2026-6809) crypto/x509 in 1.27.2 does not distrust compromised intermediate certificates, and pki.Validate calls Certificate.Verify. Bumps the go.mod toolchain (which every workflow reads) and the Dockerfile's build image, pinned by digest. --- CHANGELOG.md | 5 +++++ Dockerfile | 2 +- go.mod | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56a4168..0e4f35e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,11 @@ the CBOM/report/eval schema versions, the database) can change in a minor releas progress writes could land out of order. The count now only moves forward, and writes are serialised. This was found by a CI run of the workflow test. +### Security + +- Built with Go 1.27.3 (GO-2026-6809). `crypto/x509` now distrusts compromised intermediate certificates, and the + trust check uses it. + ## [0.3.0] - 2026-10-17 ### Changed diff --git a/Dockerfile b/Dockerfile index fcef015..04c0201 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ # The build stage always runs on the builder's native platform and cross-compiles, so a # multi-arch image needs no emulation. -FROM --platform=$BUILDPLATFORM golang:1.27.2@sha256:5bc7f572bbaa98885a3a1fd9c0aa76b59e3e14e8628bfc316bbfd0c701e4818c AS build +FROM --platform=$BUILDPLATFORM golang:1.27.3@sha256:6928d94d1142af58816fed5a62338d605a266ccd53a0deaab6b1096f19059261 AS build ARG TARGETOS ARG TARGETARCH ARG MODULE=github.com/offsetsecurity/cipherhorizon diff --git a/go.mod b/go.mod index 8ab01e4..2536ca3 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/offsetsecurity/cipherhorizon go 1.27.0 -toolchain go1.27.2 +toolchain go1.27.3 require ( github.com/CycloneDX/cyclonedx-go v0.12.0 From 3ea5c43708211003cfb2b2c97e2889ff0cdaf7e7 Mon Sep 17 00:00:00 2001 From: Khalid Saifullah Khan Date: Wed, 21 Oct 2026 13:28:50 +0530 Subject: [PATCH 4/5] fix(web): Help opens at its topic after the web fonts load On CI the fonts swapped in after the scroll and pushed the topic 214 px down (E2E-STR-3). Scroll again once document.fonts is ready. --- web/static/js/views/help.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/static/js/views/help.js b/web/static/js/views/help.js index 0bb5cb8..5ba8606 100644 --- a/web/static/js/views/help.js +++ b/web/static/js/views/help.js @@ -157,6 +157,11 @@ export async function render(root) { root.appendChild(h('div', { class: 'help-layout' }, toc, body)); // #/help?topic=strength opens at that topic (links from the report's legends). const topic = new URLSearchParams(location.hash.split('?')[1] || '').get('topic'); - if (topic && TOPICS.some(([id]) => id === topic)) setTimeout(() => document.getElementById(`help-${topic}`)?.scrollIntoView({ block: 'start' }), 0); + // Scroll once now and again when the web fonts are in: their metrics move the sections down. + if (topic && TOPICS.some(([id]) => id === topic)) { + const go = () => document.getElementById(`help-${topic}`)?.scrollIntoView({ block: 'start' }); + setTimeout(go, 0); + if (document.fonts && document.fonts.status !== 'loaded') document.fonts.ready.then(go); + } return null; } From 48c18a4384c9cbac3da8f2fa4ac0aa56c5ad7604 Mon Sep 17 00:00:00 2001 From: Khalid Saifullah Khan Date: Wed, 21 Oct 2026 13:59:32 +0530 Subject: [PATCH 5/5] test(e2e): E2E-STR-3 asks for the topic in view, not an exact offset CI places the Help topic 214 px from the top on every run (20 px locally): a layout difference, not timing. The check now states the requirement: the page scrolled and the heading is in the top third. --- docs/program/QA-REPORT-010.md | 6 ++++++ testing/e2e/strength-phase.mjs | 6 ++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/program/QA-REPORT-010.md b/docs/program/QA-REPORT-010.md index c3777d9..0af25ae 100644 --- a/docs/program/QA-REPORT-010.md +++ b/docs/program/QA-REPORT-010.md @@ -43,6 +43,12 @@ and the NIST strength model (`42b1644`). Requirements: [PRD-010](../product/PRD- | Q2 | Journey (most report checks, then a `getComputedStyle` abort) | The chain diagram used the evaluation without being passed it. The Summary and Configuration tabs of every TLS report rendered nothing below the tab bar. | `servedDiagram` takes the evaluation. Every view was then rendered against every scanned endpoint without an exception. | | Q3 | Screen walk | The legend's "How strength is worked out" opened Help at its top, eleven topics above the answer. | Help opens at `?topic=`; E2E-STR-3 checks it. The first version scrolled in `requestAnimationFrame`, which never runs in a hidden tab; it now scrolls at once. | +## Test changes, and why each is not a weakening + +- **E2E-STR-3** first asked for the topic within 200 px of the top. On CI the heading lands at 214 px on every run + (20 px locally), from a layout difference between the two machines, not a timing one. The check now states the + requirement: the page has scrolled, and the topic's heading is in the top third of the window. + ## Screen walk Walked in light, dark and at 375 px: diff --git a/testing/e2e/strength-phase.mjs b/testing/e2e/strength-phase.mjs index 2ae5feb..9fedb45 100644 --- a/testing/e2e/strength-phase.mjs +++ b/testing/e2e/strength-phase.mjs @@ -32,9 +32,11 @@ export async function strengthPhase({ check, js, go, sleep, shot, viewport }) { // E2E-STR-3: the legend's link opens Help at the Classical strength topic, not at its top. const link = await js(`const a = [...document.querySelectorAll('#main a')].find((x) => /How strength is worked out/.test(x.textContent)); if (!a) return null; a.click(); return a.getAttribute('href')`); await sleep(1500); - const helpAt = await js(`const s = document.getElementById('help-strength'); return s ? Math.round(s.getBoundingClientRect().top) : null`); + // The topic's heading is in the top third of the window and the page has scrolled: Help opened at the + // topic, not at its top (fonts and layout differ between machines, so no exact offset is asked). + const helpAt = await js(`const s = document.getElementById('help-strength'); return s ? { top: Math.round(s.getBoundingClientRect().top), third: Math.round(innerHeight / 3), scrolled: Math.round(scrollY) } : null`); check('E2E-STR-3', 'the strength legend opens Help at the Classical strength topic', - link === '#/help?topic=strength' && helpAt !== null && helpAt >= -5 && helpAt < 200, JSON.stringify({ link, helpAt })); + link === '#/help?topic=strength' && helpAt !== null && helpAt.scrolled > 0 && helpAt.top >= -5 && helpAt.top < helpAt.third, JSON.stringify({ link, helpAt })); // E2E-STR-2: the Inventory's Classical strength column; broken algorithms say so. await go('#/inventory/algorithms');