From 5e27a82f49efc8bd4d440760d06b698e67abeed4 Mon Sep 17 00:00:00 2001 From: Khalid Saifullah Khan Date: Wed, 21 Oct 2026 20:33:38 +0530 Subject: [PATCH 1/3] feat(web): the SSH report, as instruments (PRD-014) The SSH server report gets the one-stop Configuration PRD-013 gave TLS (UX-DESIGN-012): version and software, key exchange, host keys, ciphers, MACs and compression, and the baseline, under a sticky jump row. Every algorithm is a row with its bits bar, post-quantum glyph and baseline mark; host keys are cards; requirements are tiles linking to their source; baseline changes are chips in their parts; each part ends with its issues. The Summary gets the four-figure strip, and the baseline is line 1 of Fix first in the shared row shape. Journey: E2E-VIS-6 rewritten against sshAlgorithms; E2E-VIS-8, 9 and 10 added; E2E-H.2 reads the marks that replaced its words. 237 passed. CRYPTO-REVIEW-002 signed off; QA-REPORT-013; RTM rows REQ-SSV-1 to 9. --- CHANGELOG.md | 8 +- .../CRYPTO-REVIEW-002-ssh-instruments.md | 93 ++++ docs/product/PRD-014-ssh-instruments.md | 32 ++ ...X-DESIGN-011-instruments-not-paragraphs.md | 3 +- docs/product/UX-DESIGN-012-ssh-instruments.md | 78 +++ docs/program/QA-REPORT-013.md | 68 +++ docs/program/RTM.md | 16 +- internal/archtest/rtm_test.go | 1 + testing/e2e/ssh-phase.mjs | 22 +- testing/e2e/visual-phase.mjs | 128 ++++- web/static/js/views/report-ssh.js | 491 ++++++++++++------ web/static/js/views/report.js | 19 +- web/static/style.css | 33 ++ 13 files changed, 788 insertions(+), 204 deletions(-) create mode 100644 docs/product/CRYPTO-REVIEW-002-ssh-instruments.md create mode 100644 docs/product/PRD-014-ssh-instruments.md create mode 100644 docs/product/UX-DESIGN-012-ssh-instruments.md create mode 100644 docs/program/QA-REPORT-013.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 09b4573..59c3de5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,7 +64,13 @@ the CBOM/report/eval schema versions, the database) can change in a minor releas each part ends with the issues found in it. HSTS shows the header as sent. - **Inventory:** algorithms *By class* and *By classical strength* (`?strength=`: broken, below-112, 112, 128, 192, 256, not-rated), each bar opening its list; the list is weakest first, with a bits bar. - - **SSH reports:** the algorithm tables use the bits bar and a post-quantum column. + - **SSH reports** ([PRD-014](docs/product/PRD-014-ssh-instruments.md), + [UX-DESIGN-012](docs/product/UX-DESIGN-012-ssh-instruments.md)) get the same one-stop Configuration: version and + software (what a current client gets, as a banner), key exchange (readiness, then each method as a row with its + bits bar, post-quantum glyph and baseline mark), host keys (a card per key), ciphers, MACs and compression, and + the baseline (a tile per requirement linking to its source, the changes as chips in their parts, the + `sshd_config` lines). The Summary has the same four-figure strip, and the baseline is line 1 of *Fix first*. + Checked by [CRYPTO-REVIEW-002](docs/product/CRYPTO-REVIEW-002-ssh-instruments.md). - **Scanner:** a TLS scan records whether the server negotiates the heartbeat extension (RFC 6520). - Every number and claim on these screens was checked against its source ([CRYPTO-REVIEW-001](docs/product/CRYPTO-REVIEW-001-visual-rebuild.md)). diff --git a/docs/product/CRYPTO-REVIEW-002-ssh-instruments.md b/docs/product/CRYPTO-REVIEW-002-ssh-instruments.md new file mode 100644 index 0000000..6a6c84b --- /dev/null +++ b/docs/product/CRYPTO-REVIEW-002-ssh-instruments.md @@ -0,0 +1,93 @@ +# CRYPTO-REVIEW-002 · Every number and claim on the rebuilt SSH report (PRD-014) + +Date: 2026-10-21. Reviewer role: cryptographer (PRD-014 REQ-SSV-9). Build: branch `feat/ssh-instruments`, stacked +on `feat/classes-and-x509` (PRD-013). Scope: what the SSH Summary and Configuration *show*: each number, word, +glyph, tooltip and sentence, checked against the report and evaluation it is drawn from and against the source +cited. The SSH rules and strengths themselves were reviewed for PRD-008 and PRD-010 (CRYPTO-REVIEW-001 covers the +shared scale); they are re-read here only where a screen states them. + +Data: the CI journey's SSH demo servers: 9460 (hybrid key exchange, OpenSSH-style MACs), 9461 (classical), 9462 +(legacy: SHA-1 groups, a 1024-bit RSA host key, CBC, 3DES, truncated MACs), 9463 (ML-DSA-65 host key), 9465 +(SSH-1 only), 9467 (hardened to meet the baseline). Agreement between screen and API is checked by the journey +(E2E-H.2, E2E-VIS-6, E2E-VIS-8, E2E-VIS-9, E2E-VIS-10); this review checks that what is said is right. + +## Verdict + +**Signed off**, with the two defects below fixed in this branch and three notes left as they are, each with its +reason. No number on the SSH screens disagrees with its cited source. + +## Numbers checked + +| Shown | Value | Source | Holds | +|---|---|---|---| +| Hybrid key exchange | mlkem768x25519-sha256 → 192 | the stronger part (RFC 10042; ML-KEM-768 is category 3, FIPS 203) | yes | +| Elliptic-curve key exchange and host keys | curve25519-sha256, ssh-ed25519, ecdsa-sha2-nistp256 → 128 | SP 800-186 Table 1 (half the base-point order) | yes | +| Finite-field groups | group14 (2048-bit MODP) → 112; group1 (1024-bit, Oakley 2) → 80 | SP 800-57 Part 1 Rev. 5, Table 2 | yes | +| RSA host-key algorithms | rsa-sha2-256 on a 1024-bit key → 80; ssh-rsa (SHA-1) → broken | SP 800-57 Table 2; SP 800-131A Rev. 2 (SHA-1 disallowed for signature generation) | yes | +| ML-DSA host key | ssh-mldsa-65 → 192 | FIPS 204, category 3 | yes | +| Ciphers | AES-128/192/256 (CTR, GCM, CBC) by key; 3DES → 112, disallowed; chacha20-poly1305 → 256 by its key | SP 800-57 Table 2; SP 800-131A; RFC 8439 | yes | +| MACs | HMAC-SHA-1 → 128; HMAC-SHA-256 and -512 → 256 | SP 800-57 Table 3, HMAC column | yes (see note 1) | +| Host-key card | the strongest signature the key answers to: RSA 1024 → 80 (rsa-sha2-256, not ssh-rsa); Ed25519 → 128; ML-DSA-65 → 192 | as above | yes | +| Weakest classical strength (Summary) | the weakest rated algorithm offered: on 9462 ssh-rsa, broken; on 9460 128 bits | the evaluation's strengths | yes (E2E-VIS-10) | +| Readiness L0 to L4 | stepper filled to the evaluation's level: 9460 L3, 9461 L1, 9463 L4, 9465 L0 | PRD-008 SSH readiness | yes (E2E-H.1b, E2E-VIS-9) | +| Fingerprints | `SHA256:` base64 of the host-key blob, as `ssh-keygen -l` prints it | RFC 4253 §6.6 blob; OpenSSH fingerprint format | yes | + +## Claims checked + +- **"Every list here is in the server's order; in each, the client's order decides (RFC 4253 §7.1)."** RFC 4253 + §7.1: the chosen algorithm is the first on the client's name-list that is also on the server's, for key exchange, + host keys, ciphers, MACs and compression alike. So the rows carry no rank numbers, unlike TLS's suites (where the + server may pick). Correct after fix 2. +- **"A current client gets" + four chips, "signature verified".** The scan's own negotiation with OpenSSH 10.6's + default lists, carried out to the host-key signature over the exchange hash, which verified. The banner names + the client in its tooltip. Correct. +- **"no MAC" with an AEAD cipher.** With chacha20-poly1305@openssh.com or aes-gcm@openssh.com the negotiated MAC is + ignored (OpenSSH PROTOCOL §1.6 for aes-gcm@openssh.com; PROTOCOL.chacha20poly1305). Correct. +- **Post-quantum glyphs.** Shield with a check on mlkem768x25519-sha256 and ssh-mldsa-65; shield with a cross on + curve25519, ECDH, finite-field DH, Ed25519, ECDSA and RSA; a dot on ciphers and MACs ("no post-quantum verdict"). + See note 3. +- **Strict key exchange: "On: the countermeasure to Terrapin".** kex-strict-s-v00@openssh.com, OpenSSH 9.6 + (CVE-2023-48795). *Not offered* is the problem tone only when the scan infers Terrapin is possible + (chacha20-poly1305, or CBC with encrypt-then-MAC, without strict key exchange); otherwise it needs attention. + Correct. +- **Compression: "zlib@openssh.com, after login" and "zlib, before login".** zlib@openssh.com starts after user + authentication; plain zlib starts with the first NEWKEYS, before authentication. Correct. +- **Protocol tile.** "SSH-2 only" for 2.0; "SSH-2, and SSH-1 accepted" for 1.99 (RFC 4253 §5.1); "SSH-1 only" for + 1.x. The identification string is shown as sent. Correct. +- **"Lines before the identification".** RFC 4253 §4.2 allows them; shown as a count, in no tone. Correct. +- **Software.** "As the server announces it. Its version is not matched against vendor advisories." True: the + scanner reads the banner only; *Server software flaws* stays *Not tested* under Weaknesses. +- **Baseline requirements.** Each tile links to its cited source (RFC 10042; OpenSSH 9.6 release notes; + SP 800-57 Table 2 for 3072-bit RSA, i.e. 128-bit strength). The *Met / Not met* states equal the evaluation's. +- **The changes.** − remove, + add, ↑ upgrade, ⟲ replace a key; each chip's tooltip is the evaluation's own reason + and source. The `sshd_config` hint ("sshd keeps the first value of each keyword") is sshd_config(5)'s rule. + Correct. + +## Defects found and fixed in this branch + +1. **The Summary's Host key tile drew a 1024-bit RSA key in the good tone.** It was toned by post-quantum status + alone (classical → green). It now follows the key's strength: weak or broken in the problem tone, acceptable in + the attention tone, strong green, post-quantum the L4 colour. +2. **"Listed in the server's order; the client's order decides" stood on Key exchange alone**, which could read as + if the other lists were preferences. RFC 4253 §7.1 applies the rule to every list; the aside now says so. + +## Notes left as they are + +1. **Truncated MACs are rated by their HMAC.** hmac-sha1-96 shows 128 bits: SP 800-57 Table 3 rates the HMAC, not + its tag length, and a 96-bit tag bounds a forgery guess at 2⁻⁹⁶ per attempt, online. The row is marked + *should be off* and SSH.MAC-WEAK names the truncation, so no verdict is wrong; a tag-length cap + (SP 800-107 Rev. 1, truncated MAC tags) would be a change to the evaluation, which PRD-014 leaves alone. Proposed as a + follow-up for the strength catalogue. +2. **SHA-1 key exchanges are rated by their group.** diffie-hellman-group14-sha1 shows 112 bits (the 2048-bit + group); its SHA-1 exchange hash is what puts it on *should be off* (SSH.KEX-SHA1, RFC 9142). Same reasoning as + note 1. +3. **Ciphers and MACs carry a dot, not a shield.** The TLS class map and the Inventory mark AES as quantum-safe + (NIST IR 8547; Grover gives no practical break), while the SSH rows give ciphers and MACs no post-quantum + verdict, as PRD-013 drew them. Both are true statements; the dot's tooltip says *no post-quantum verdict*, not + *safe* or *vulnerable*. Kept, so the SSH page does not imply that its ciphers were classified by a source they + were not. + +## Sign-off + +The numbers and claims on the SSH Summary and Configuration are correct against their sources, with fixes 1 and 2 +in this branch. Signed off for PRD-014 REQ-SSV-9, cryptography. diff --git a/docs/product/PRD-014-ssh-instruments.md b/docs/product/PRD-014-ssh-instruments.md new file mode 100644 index 0000000..1d65286 --- /dev/null +++ b/docs/product/PRD-014-ssh-instruments.md @@ -0,0 +1,32 @@ +# PRD-014 · The SSH report, as instruments + +Status: draft 2026-10-21, from the owner's PRD-013 verdict ("instruments, not paragraphs"), applied to the SSH +server report. Design: [UX-DESIGN-012](UX-DESIGN-012-ssh-instruments.md), an addendum to UX-DESIGN-011. The data, +the API and the evaluation stay as they are; this is a front-end change. Sign-offs: the cryptographer +(CRYPTO-REVIEW-002) and the PM's walk (QA-REPORT-013). + +## Why + +PRD-013 rebuilt the TLS report on shared instruments. The SSH report kept its PRD-008 layout: a baseline card of +prose, fact cards that say each thing as a sentence and again as a bullet, and bullet lists of citations. Only its +algorithm tables used `bitsBar` and `pqGlyph` (REQ-VIS-10). The owner rejected that kind of screen for TLS; an SSH +reviewer should get the same one-stop page. + +## Requirements + +| ID | Requirement | Acceptance | Test | Pri | +|---|---|---|---|---| +| **REQ-SSV-1** | **One Configuration page in a reviewer's order** (UX-DESIGN-012 §3): version and software, key exchange, host keys, ciphers, MACs and compression, baseline changes; a sticky jump row with each part's state; About this report last. | The parts are in that order and the jump row leads to each | `script:testing/e2e/visual-phase.mjs#E2E-VIS-8` | P0 | +| **REQ-SSV-2** | **The algorithm rows** (§3.1): every algorithm the evaluation lists, in the server's order, with the bits bar, the post-quantum glyph for key exchange and host keys, and the baseline mark; the row a current client uses is marked. | The rows equal `sshAlgorithms` kind by kind, and each bar, glyph and mark agrees with it | `script:testing/e2e/visual-phase.mjs#E2E-VIS-6` | P0 | +| **REQ-SSV-3** | **Version, key exchange and host keys as instruments**: the negotiated banner, status tiles (protocol, software, strict key exchange, compression), the readiness row with the stepper, a card per host key with its verification, and a dashed card for a key not fetched. | The banner equals the report's negotiated algorithms; the tiles agree with the report | `script:testing/e2e/visual-phase.mjs#E2E-VIS-9` | P0 | +| **REQ-SSV-4** | **Baseline as tiles and chips**: verdict tile, a tile per requirement (met or not), the changes as chips, the `sshd_config` lines with Copy. A server that meets it shows no lines. | Tiles equal the evaluation's requirements; chips equal its changes | `script:testing/e2e/ssh-phase.mjs#E2E-H.2`, `script:testing/e2e/ssh-phase.mjs#E2E-H.2c` | P0 | +| **REQ-SSV-5** | **Each part ends with its issues**: the findings of that part, each linking to its evidence, and the baseline changes that fall in it. | Every finding of the evaluation (bar the published attacks) is named under exactly one part | `script:testing/e2e/visual-phase.mjs#E2E-VIS-8` | P0 | +| **REQ-SSV-6** | **Citations in tooltips**: no bullet list of sources on the SSH Configuration; each source is a tooltip (and a link where it is a requirement). | No `ul.cites` on the page; every requirement tile names its source | `script:testing/e2e/visual-phase.mjs#E2E-VIS-8` | P1 | +| **REQ-SSV-7** | **The SSH Summary** (§2): readiness with the stepper, the baseline, the weakest classical strength with a bits bar, the host key; the baseline as the first Fix first line in the shared row shape, no "Why it matters" block. | Four tiles; the weakest strength equals the evaluation's weakest rated algorithm | `script:testing/e2e/visual-phase.mjs#E2E-VIS-10` | P1 | +| **REQ-SSV-8** | **Fit and access**: the SSH Summary and Configuration fit 1440, 1100, 860 and 375 px with no sideways scroll, every control reachable by Tab, instrument text at 4.5:1 in light and dark; an SSH-1-only server's report still renders every part. | | `script:testing/e2e/visual-phase.mjs#E2E-VIS-7`, `script:testing/e2e/ssh-phase.mjs#E2E-H.2b` | P0 | +| **REQ-SSV-9** | **Sign-off**: a cryptographer checks every number and claim the SSH screens show (CRYPTO-REVIEW-002); the PM walks every SSH screen (QA-REPORT-013). | | `report:docs/product/CRYPTO-REVIEW-002-ssh-instruments.md` | P0 | + +## Out of scope + +- No change to the SSH evaluation, the baseline, the API or the PDF. +- The SSH servers list, Overview, Findings and Help keep their PRD-008 layout. diff --git a/docs/product/UX-DESIGN-011-instruments-not-paragraphs.md b/docs/product/UX-DESIGN-011-instruments-not-paragraphs.md index 8b6b5a9..f09bab0 100644 --- a/docs/product/UX-DESIGN-011-instruments-not-paragraphs.md +++ b/docs/product/UX-DESIGN-011-instruments-not-paragraphs.md @@ -131,7 +131,8 @@ Top to bottom: ### 3.5 SSH report -The algorithm tables use `bitsBar` and `pqGlyph` in place of the words column. No other change. +The algorithm tables use `bitsBar` and `pqGlyph` in place of the words column. Superseded by +[UX-DESIGN-012](UX-DESIGN-012-ssh-instruments.md) (PRD-014), which rebuilds the whole SSH report. ## 4. Responsive and accessible diff --git a/docs/product/UX-DESIGN-012-ssh-instruments.md b/docs/product/UX-DESIGN-012-ssh-instruments.md new file mode 100644 index 0000000..543e168 --- /dev/null +++ b/docs/product/UX-DESIGN-012-ssh-instruments.md @@ -0,0 +1,78 @@ +# UX-DESIGN-012 · The SSH report, as instruments (addendum to UX-DESIGN-011) + +Status: draft 2026-10-21 for [PRD-014](PRD-014-ssh-instruments.md). It applies +[UX-DESIGN-011](UX-DESIGN-011-instruments-not-paragraphs.md) to the SSH server report, which kept the PRD-008 layout +(UX-DESIGN-007 §4) apart from its algorithm tables. It replaces UX-DESIGN-011 §3.5. Everything in UX-DESIGN-011 §1 +(principles), §2 (components) and §4 (responsive and accessible) holds here unchanged. + +## 1. What changes, and why + +Today's SSH Configuration has: +- a baseline card of prose: a verdict sentence, a hint paragraph, a requirement list with a bullet list of citations + under each requirement, and the changes as a bullet list of sentences; +- six fact cards, each a verdict sentence, then bullets that repeat it, then a table; +- a readiness panel of three paragraphs, each with its own citation list. + +The same facts are said two or three times, once in a sentence and once in a table. The rebuild draws each fact +once, as an instrument, in the order an SSH reviewer checks, and moves every source into a tooltip or a link. + +## 2. Report › Summary (SSH) + +1. **Instruments**: four tiles in the TLS strip's order (label, figure, instrument): + - **Post-quantum readiness**: level and `levelStepper`; + - **Baseline**: *Meets* / *N changes* / *Not judged*, and the baseline's name; + - **Weakest classical strength**: the bits and the algorithm, with a `bitsBar`; + - **Host key**: the key a current client verifies, post-quantum or classical, and its fingerprint's start. + + Each opens its part of Configuration. An SSH server has no score, so there is no gauge. +2. **Fix first**: the baseline is the first numbered line when the server does not meet it, in the same row + shape as every finding (number, a severity chip, title, what to do, a link to its lines). The + "Why it matters" block goes. +3. **Checks** bar and **By area** tiles: shared with TLS, unchanged. + +## 3. Report › Configuration (SSH): the one-stop check + +A sticky `jumpNav`, each chip with its part's state dot, then six parts in a reviewer's order. Each part is a +`section.card.cfg` and ends with its issues (the findings that belong to it, each linking to its evidence) and the +baseline changes that fall in it. + +| # | Part | Draws | +|---|---|---| +| 1 | **Version and software** | The banner *A current client gets*: key exchange + host key + cipher + MAC (or "AEAD, no MAC"), the post-quantum key exchange marked. Status tiles: protocol (SSH-2 only / SSH-1 accepted / SSH-1 only), software (as announced; vendor advisories are not checked), lines before the identification. The identification string is the protocol tile's raw detail. | +| 2 | **Key exchange** | The readiness row (level, stepper, the three dimensions as chips, each dimension's detail and sources in its tooltip); the algorithm rows (§3.1); a *Strict key exchange* tile (Terrapin countermeasure). The next steps as a short numbered list, as on TLS. | +| 3 | **Host keys** | A ribbon of key cards, one per key fetched: algorithm and size, post-quantum glyph, *verified* chip (the key signed the exchange), the host-key names it answers to as chips, the SHA-256 fingerprint in mono, and a certificate line when the key is an OpenSSH certificate. A key the scan could not fetch is a dashed card that says why. Then the host-key algorithm rows. | +| 4 | **Ciphers** | The algorithm rows. | +| 5 | **MACs and compression** | The MAC rows. When a current client's cipher is an AEAD, the aside says no MAC is used. Compression as one status tile per method offered (none / delayed until after login / before login). | +| 6 | **Baseline changes** | The verdict as a status tile beside the baseline's name; each requirement as a status tile (met / not met, the reason as its state), linking to its source, the source's name in the tooltip; the changes as chips (− remove, + add, ↑ upgrade, ⟲ replace the key), each change's reason and source in its tooltip; the `sshd_config` lines in a mono block with *Copy the lines*, the one instruction on where to put them in one muted line. A server that meets the baseline shows no lines block. | + +*About this report* follows, collapsed. Published attacks stay on Weaknesses, as on TLS. + +### 3.1 The algorithm row + +One row per algorithm, in the order the server lists them, under a shared `bitsAxis`: + +| Name | Strength | Post-quantum | Baseline | +|---|---|---|---| +| the SSH name in mono; under it, what it is (the registry's display name) | `bitsBar` (basis in the tooltip); compression has none | `pqGlyph` for key exchange and host keys; a dot for ciphers and MACs (no quantum question) | ✓ *allowed* or ✕ *should be off*; a name the program does not know says so in the tooltip | + +- The row a current client uses is tinted, with an *in use* chip whose tooltip names the client (OpenSSH 10.6 + defaults). +- No rank numbers: in SSH the client's order decides (RFC 4253 §7.1), so the server's order is not a preference + and is not drawn as one. The part's aside says so once. +- A row that should be off is ringed in the problem tone; nothing else repeats it. + +## 4. What goes + +- The baseline card's verdict sentence and its hint paragraph. +- The bullet lists of citations (requirements, readiness dimensions): each source is a tooltip and a link. +- The fact cards' verdict sentences and fact bullets. +- The readiness panel of paragraphs (replaced by the readiness row). +- The algorithm tables' *What it is* column (now the row's sub-line) and the *used by OpenSSH 10.6* badge column. +- The old summary strip markup (`lvscale`), replaced by the shared stepper. + +## 5. Responsive + +- Rows: four columns at ≥ 861 px; at ≤ 860 px the baseline mark moves under the name, and at ≤ 520 px the bar's + value goes under the track (as on the TLS lanes). +- The key ribbon scrolls sideways inside itself; the `sshd_config` block scrolls inside itself. +- The page never scrolls sideways at 1440, 1100, 860 and 375 px. diff --git a/docs/program/QA-REPORT-013.md b/docs/program/QA-REPORT-013.md new file mode 100644 index 0000000..57a59c2 --- /dev/null +++ b/docs/program/QA-REPORT-013.md @@ -0,0 +1,68 @@ +# QA-REPORT-013 · The SSH report, as instruments (PRD-014) + +Date: 2026-10-21. Build: branch `feat/ssh-instruments`, stacked on `feat/classes-and-x509` (PRD-011 to 013, PR #23). +Requirements: [PRD-014](../product/PRD-014-ssh-instruments.md), [RTM](RTM.md) §PRD-014. Design: +[UX-DESIGN-012](../product/UX-DESIGN-012-ssh-instruments.md). Cryptography: +[CRYPTO-REVIEW-002](../product/CRYPTO-REVIEW-002-ssh-instruments.md). + +## Result + +| Gate | Command | Result | +|---|---|---| +| Unit and integration | `go test ./...` | pass | +| Traceability | `TestRequirementsTraceability` | 9 new rows (REQ-SSV-1 to 9), all done, each with evidence; PRD-014 added to the traced PRDs | +| Instruments | `TestInstrumentsAreSharedAndLabelled` | `report-ssh.js` draws only with `viz.js`; its local `strengthWords` and the algorithm tables are gone | +| Copy rules | `TestUICopyRules`, `TestSSHReportCopyHasNoTLSWords`, `TestSSHServersAreNamedAndNotScored` | pass | +| Lint | `golangci-lint run ./...`, and with `GOOS=linux` | 0 issues, 0 issues | +| UI scripts parse | `node --check` on the changed modules | all parse | +| Browser journey | `testing/e2e/journey.mjs`, the CI setup (fresh database, fresh demo targets, sign-in on with a token) | **237 passed, 0 failed** | +| Screen walk | below | done, 3 defects found and fixed | +| Cryptography | CRYPTO-REVIEW-002 | signed off, 2 defects fixed, 3 notes kept | + +## The new and rewritten checks + +- **E2E-VIS-6** (rewritten): every SSH algorithm row, kind by kind in the server's order, equals the evaluation's + `sshAlgorithms`: its name, its bits bar's words, its post-quantum glyph, its baseline mark and its in-use mark. + Stricter than before, which only looked for each bar's label somewhere on the page. +- **E2E-VIS-8** (new): six parts in a reviewer's order; a sticky jump row whose chips lead to them (a click puts the + part in view); every finding outside the published attacks named exactly once, under its own part; a tile per + baseline requirement that links to its source in a new tab with the source's name as its tooltip; a chip per + baseline change; no bullet list of sources; About this report last. +- **E2E-VIS-9** (new): the banner equals the scan's negotiated key exchange, host key, cipher and MAC ("no MAC" with + an AEAD); the protocol tile shows the identification string; strict key exchange as the report says; the + readiness level and stepper; a card per host key with its fingerprint and verification, and a dashed card per key + not fetched; the SSH-1-only server still draws all six parts, says *Does not apply* and has no rows. +- **E2E-VIS-10** (new): the SSH Summary's four tiles; the weakest strength is the evaluation's weakest rated + algorithm; the baseline is Fix first line 1 and the findings are numbered on from it in one list; no "Why it + matters"; the hardened server shows ✓ Meets and no baseline line. +- **E2E-VIS-1, VIS-7**: the SSH Summary joins the labelled-instrument check; the SSH Summary and Configuration join + the fit (1440, 1100, 860, 375 px), keyboard and contrast checks, light and dark. The contrast selector now + covers the SSH rows, marks, chips, banner chips and fingerprints. + +| Check | Was | Now, and why it is not a weakening | +|---|---|---| +| E2E-H.2 | the words *Should be off*, *Allowed · post-quantum* and *used by OpenSSH 10.6* on the page | the marks and glyphs that replaced those words: a row should be off; the hybrid key exchange is allowed, has the quantum-safe glyph and is in use; and each kind's in-use row is exactly what the scan negotiated (the old check did not compare with the scan at all) | + +E2E-H.2b, H.2c and every other SSH check pass unchanged. + +## Screen walk (PM and design) + +Every SSH screen, at 1440 px, 375 px and in dark mode, on the six demo servers. + +1. **The Host key tile was green for a 1024-bit RSA key** (also CRYPTO-REVIEW-002 fix 1). It now follows the key's + strength. +2. **The baseline's Fix first line listed every name it removes** (three lines of algorithm names on the legacy + server). It now counts them ("Remove 8 algorithms; add mlkem768x25519-sha256; replace a host key."); each + change's reason is in the tooltip and its chip sits in its part. +3. **The Baseline part's verdict tile sat in one narrow column** beside empty space. It spans two columns above + phone width. + +Seen and kept: the single *Strict key exchange* and *Compression* tiles (one fact each, in the part they belong to); +the *Software* tile in no tone (the scanner does not judge versions); the dot in the post-quantum column for ciphers +and MACs (CRYPTO-REVIEW-002 note 3). + +## Not covered + +- The PDF and the SSH servers list are unchanged by PRD-014 and were not re-walked. +- An OpenSSH host certificate and a host key that could not be fetched are drawn by code the demo servers do not + exercise (no demo server serves either); E2E-VIS-9 counts dashed cards against the report, which has none. diff --git a/docs/program/RTM.md b/docs/program/RTM.md index f298365..5eeeec6 100644 --- a/docs/program/RTM.md +++ b/docs/program/RTM.md @@ -402,10 +402,24 @@ WP-B to WP-G (SSH) ship in the second pull request, amended 2026-10-13 per [STRA | REQ-VIS-7 | P0 | — | UX-DESIGN-011 | — | script:testing/e2e/x509-phase.mjs#E2E-XV-2 | done | an EC key with key encipherment is marked (RFC 8813) | | REQ-VIS-8 | P1 | — | UX-DESIGN-011 | — | TestInventoryFiltersByStrength, script:testing/e2e/visual-phase.mjs#E2E-VIS-5 | done | | | REQ-VIS-9 | P2 | — | UX-DESIGN-011 | — | TestHeartbeatIsProbed | done | 0-RTT stays not probed | -| REQ-VIS-10 | P2 | — | UX-DESIGN-011 | — | script:testing/e2e/visual-phase.mjs#E2E-VIS-6 | done | the rest of the SSH report keeps its PRD-008 layout | +| REQ-VIS-10 | P2 | — | UX-DESIGN-011 | — | script:testing/e2e/visual-phase.mjs#E2E-VIS-6 | done | the whole SSH report was rebuilt in PRD-014 (REQ-SSV-2) | | REQ-VIS-11 | P0 | — | UX-DESIGN-011 | — | script:testing/e2e/visual-phase.mjs#E2E-VIS-7, script:testing/e2e/x509-phase.mjs#E2E-XV-5 | done | contrast measured on instrument text, light and dark | | REQ-VIS-12 | P0 | — | UX-DESIGN-011 | — | report:docs/product/CRYPTO-REVIEW-001-visual-rebuild.md, report:docs/program/QA-REPORT-012.md | done | | +## PRD-014 · The SSH report, as instruments ([PRD-014](../product/PRD-014-ssh-instruments.md); results in [QA-REPORT-013](QA-REPORT-013.md)) + +| Requirement | Pri | WP | Source | SR | Evidence | Status | Notes | +|---|---|---|---|---|---|---|---| +| REQ-SSV-1 | P0 | — | UX-DESIGN-012 | — | script:testing/e2e/visual-phase.mjs#E2E-VIS-8 | done | six parts; About this report last | +| REQ-SSV-2 | P0 | — | UX-DESIGN-012 | — | script:testing/e2e/visual-phase.mjs#E2E-VIS-6, script:testing/e2e/ssh-phase.mjs#E2E-H.2 | done | no rank numbers: in SSH the client's order decides (RFC 4253 §7.1) | +| REQ-SSV-3 | P0 | — | UX-DESIGN-012 | — | script:testing/e2e/visual-phase.mjs#E2E-VIS-9 | done | a host key is drawn at the strength of its best signature | +| REQ-SSV-4 | P0 | — | UX-DESIGN-012 | — | script:testing/e2e/ssh-phase.mjs#E2E-H.2, script:testing/e2e/ssh-phase.mjs#E2E-H.2c, script:testing/e2e/visual-phase.mjs#E2E-VIS-8 | done | the change chips sit in their parts; the Baseline part leads to them | +| REQ-SSV-5 | P0 | — | UX-DESIGN-012 | — | script:testing/e2e/visual-phase.mjs#E2E-VIS-8 | done | | +| REQ-SSV-6 | P1 | — | UX-DESIGN-012 | — | script:testing/e2e/visual-phase.mjs#E2E-VIS-8 | done | requirement tiles link to their source | +| REQ-SSV-7 | P1 | — | UX-DESIGN-012 | — | script:testing/e2e/visual-phase.mjs#E2E-VIS-10 | done | the host key tile's tone follows its strength (CRYPTO-REVIEW-002 fix 1) | +| REQ-SSV-8 | P0 | — | UX-DESIGN-012 | — | script:testing/e2e/visual-phase.mjs#E2E-VIS-7, script:testing/e2e/visual-phase.mjs#E2E-VIS-9, script:testing/e2e/ssh-phase.mjs#E2E-H.2b | done | contrast measured on the SSH instruments too | +| REQ-SSV-9 | P0 | — | UX-DESIGN-012 | — | report:docs/product/CRYPTO-REVIEW-002-ssh-instruments.md, report:docs/program/QA-REPORT-013.md | done | | + ## 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/archtest/rtm_test.go b/internal/archtest/rtm_test.go index 4668406..4d9ca94 100644 --- a/internal/archtest/rtm_test.go +++ b/internal/archtest/rtm_test.go @@ -27,6 +27,7 @@ var prdPaths = []string{ "../../docs/product/PRD-011-algorithm-classes-and-flow.md", "../../docs/product/PRD-012-certificate-viewer.md", "../../docs/product/PRD-013-visual-rebuild.md", + "../../docs/product/PRD-014-ssh-instruments.md", } const ( diff --git a/testing/e2e/ssh-phase.mjs b/testing/e2e/ssh-phase.mjs index 7fb29bf..132f346 100644 --- a/testing/e2e/ssh-phase.mjs +++ b/testing/e2e/ssh-phase.mjs @@ -102,12 +102,26 @@ export async function sshPhase({ check, js, go, sleep, shot, viewport, clickText } const noTLS = Object.values(tabs).every((x) => !/TLS hygiene|cipher suite|certificate chain/i.test(x)); const noScore = Object.values(tabs).every((x) => !/hygiene|out of 100/i.test(x)); - check('E2E-H.2', 'an SSH report: the Baseline tile and action, the sshd_config lines and requirements, per-algorithm baseline verdicts, every check, an ssh CBOM; no score and no TLS words', + // PRD-014 (UX-DESIGN-012 §3.1) draws each algorithm's baseline verdict as a mark, its post-quantum + // status as the glyph and the one a current client uses as an "in use" chip, in place of the words + // "Should be off", "Allowed · post-quantum" and "used by OpenSSH 10.6". The check reads those + // instruments instead: the hybrid key exchange is allowed, post-quantum and in use; something + // should be off; and the in-use row of each kind is what the scan negotiated (stricter than the words). + await go(`#/ssh/${by[9460].id}?tab=configuration`); + await sleep(1200); + const neg = (await js(`return (await fetch('/api/v1/scans/${d(9460).id}/report')).json()`)).negotiated || {}; + const rows = await js(`return [...document.querySelectorAll('.sshrow[data-kind]')].map((r) => ({ kind: r.dataset.kind, name: r.dataset.name, + used: r.classList.contains('is-used') && !!r.querySelector('.usedchip'), mark: r.querySelector('.bmark')?.textContent || '', safe: !!r.querySelector('svg.pqg-safe') }))`); + const hybrid = rows.find((r) => r.name === 'mlkem768x25519-sha256'); + const usedOf = (k) => rows.filter((r) => r.kind === k && r.used).map((r) => r.name).join(); + check('E2E-H.2', 'an SSH report: the Baseline tile and action, the sshd_config lines and requirements, per-algorithm baseline marks, the post-quantum glyph and the in-use rows, every check, an ssh CBOM; no score and no TLS words', /Baseline/.test(tabs.summary) && /2 changes/.test(tabs.summary) && /Meet CipherHorizon SSH baseline 2026/.test(tabs.summary) && /\d+ things? to fix/.test(tabs.summary) && /Make this server meet the baseline/.test(tabs.configuration) && /MACs hmac-sha2-256-etm@openssh\.com/.test(tabs.configuration) && - /What the baseline requires/.test(tabs.configuration) && /Should be off/.test(tabs.configuration) && /Allowed\s*· post-quantum/.test(tabs.configuration) && - /used by OpenSSH 10\.6/.test(tabs.configuration) && /checks run/.test(tabs.weaknesses) && /SSH-2\.0/.test(tabs.cbom) && noTLS && noScore, - JSON.stringify({ summary: tabs.summary.slice(0, 220), noTLS, noScore })); + /What the baseline requires/.test(tabs.configuration) && rows.some((r) => /should be off/.test(r.mark)) && + !!hybrid && /allowed/.test(hybrid.mark) && hybrid.safe && hybrid.used && + usedOf('kex') === neg.kex && usedOf('host-key') === neg.hostKey && usedOf('cipher') === neg.cipher && usedOf('mac') === (neg.mac || '') && + /checks run/.test(tabs.weaknesses) && /SSH-2\.0/.test(tabs.cbom) && noTLS && noScore, + JSON.stringify({ summary: tabs.summary.slice(0, 220), hybrid, used: ['kex', 'host-key', 'cipher', 'mac'].map(usedOf), neg, noTLS, noScore })); await go(`#/ssh/${by[9467].id}?tab=configuration`); await sleep(1200); diff --git a/testing/e2e/visual-phase.mjs b/testing/e2e/visual-phase.mjs index 3560b3d..7acec04 100644 --- a/testing/e2e/visual-phase.mjs +++ b/testing/e2e/visual-phase.mjs @@ -1,8 +1,9 @@ // PRD-013 (instruments, not paragraphs), driven through the browser on the other phases' data: the // shared instruments carry their meaning in words; the Summary is instruments, Fix first, the checks // bar and area tiles; the Configuration's suites, features and revocation agree with the API; the -// Inventory's charts open the lists they count; the SSH tables use the same instruments; and every -// rebuilt screen fits 1440, 1100, 860 and 375 px, in light and dark, with readable contrast. +// Inventory's charts open the lists they count; the SSH report (PRD-014) is the same one-stop page, +// its rows, banner, key cards, baseline tiles and Summary agreeing with the API; and every rebuilt +// screen fits 1440, 1100, 860 and 375 px, in light and dark, with readable contrast. const words = (st) => (!st ? 'not rated' : st.status === 'rated' ? `${st.bits} bits` : st.status === 'disallowed' ? `${st.bits} bits, disallowed` : st.status === 'broken' ? 'broken' : 'not rated'); @@ -13,7 +14,7 @@ const CONTRAST = ` const lum = ([r, g, b]) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }; const bgOf = (el) => { for (let e = el; e; e = e.parentElement) { const c = rgb(getComputedStyle(e).backgroundColor); if (c[3] === undefined || c[3] > 0.5) return c; } return rgb(getComputedStyle(document.body).backgroundColor); }; const ratio = (el) => { const a = lum(rgb(getComputedStyle(el).color)), b = lum(bgOf(el)); return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); }; - const SEL = '.bb-value, .pcell, .schip, .stile b, .stile-state, .gchip, .tchip, .cchip, .jump-chip, .vchip, .lstep-l, .hist-n, .hist-label, .fix-n, .action-title, .cfg-aside, .lane-alg, .lane-uses, .xid-v, .specval, .nmchip, .usechip'; + const SEL = '.bb-value, .pcell, .schip, .stile b, .stile-state, .gchip, .tchip, .cchip, .jump-chip, .vchip, .lstep-l, .hist-n, .hist-label, .fix-n, .action-title, .cfg-aside, .lane-alg, .lane-uses, .xid-v, .specval, .nmchip, .usechip, .sshrow-alg code, .sshrow-alg > span, .usedchip, .bmark, .chg, .nchip, .keycard-fp, .dimchip'; const low = [...document.querySelectorAll(SEL)].filter((el) => el.offsetParent !== null && el.textContent.trim()) .map((el) => ({ el: el.className.baseVal ?? el.className, text: el.textContent.trim().slice(0, 24), r: Math.round(ratio(el) * 100) / 100 })) .filter((x) => x.r < 4.5);`; @@ -22,7 +23,12 @@ export async function visualPhase({ check, js, go, sleep, shot, viewport }) { await viewport(1440, 900, false); const endpoints = await js(`return (await (await fetch('/api/v1/assets?kind=endpoint')).json()).filter((a) => a.lastDone)`); const tls = endpoints.filter((a) => a.protocol !== 'ssh'); - const ssh = endpoints.find((a) => a.protocol === 'ssh'); + // The SSH reports: the legacy server (every kind of mark, a broken host-key algorithm, many changes), + // the SSH-1-only one (nothing negotiated) and the hardened one (meets the baseline). + const sshAll = endpoints.filter((a) => a.protocol === 'ssh'); + const ssh = sshAll.find((a) => a.port === 9462) || sshAll[0]; + const ssh1 = sshAll.find((a) => a.port === 9465); + const sshGood = sshAll.find((a) => a.port === 9467); // The richest TLS report: the most suites (a legacy server shows every instrument). let target = tls[0], most = -1; for (const a of tls.slice(0, 8)) { @@ -42,7 +48,7 @@ export async function visualPhase({ check, js, go, sleep, shot, viewport }) { others: [...document.querySelectorAll('#main .ltbar, #main .lstep, #main .stackbar, #main svg.gauge')].map((x) => x.getAttribute('role') === 'img' && (x.getAttribute('aria-label') || '').length > 2) }`); const tally = {}; const pages = [['summary', `#/endpoints/${target.id}`], ['configuration', `#/endpoints/${target.id}?tab=configuration`], ['inventory', '#/inventory/algorithms']]; - if (ssh) pages.push(['ssh', `#/endpoints/${ssh.id}?tab=configuration`]); + if (ssh) pages.push(['ssh', `#/ssh/${ssh.id}?tab=configuration`], ['ssh-summary', `#/ssh/${ssh.id}`]); const leaf = ((rep.certificates || [])[0] || { chain: [] }).chain[0]; if (leaf) pages.push(['certificate', `#/certificates/${leaf.sha256}?scan=${scan}`]); for (const [name, hash] of pages) { @@ -146,26 +152,112 @@ export async function visualPhase({ check, js, go, sleep, shot, viewport }) { JSON.stringify({ cols: inv.cols.map((c) => c.n), rows: inv.rows, opened, sorted: ranks.slice(0, 12) }).slice(0, 500)); await shot('vis-inventory'); - // E2E-VIS-6: the SSH report's algorithm tables use the bits bar and the post-quantum glyph. + // E2E-VIS-6: the SSH algorithm rows are the evaluation's sshAlgorithms, kind by kind in the server's + // order, each with its bits bar, its post-quantum glyph (key exchange and host keys) and its baseline + // mark (PRD-014 REQ-SSV-2; was PRD-013 REQ-VIS-10, which read the old tables' cells). if (ssh) { const sev = await js(`return (await fetch('/api/v1/scans/${ssh.lastDone.id}/evaluation')).json()`); - await go(`#/endpoints/${ssh.id}?tab=configuration`); + await go(`#/ssh/${ssh.id}?tab=configuration`); await sleep(1600); - const s = await js(`return { bars: [...document.querySelectorAll('#main td.invbits .bitsbar')].map((b) => b.getAttribute('aria-label')), - pq: [...document.querySelectorAll('#main td.invpq')].map((c) => c.querySelector('svg.pqg-safe') ? 'safe' : c.querySelector('svg.pqg-vuln') ? 'vulnerable' : 'none') }`); - const rated = (sev.sshAlgorithms || []).filter((a) => a.kind !== 'compression' && a.strength); - const wrongBars = rated.filter((a) => !s.bars.includes(`${a.name}: ${words(a.strength)}`)).map((a) => a.name); - const kexPq = (sev.sshAlgorithms || []).filter((a) => a.kind === 'kex' && a.verdict === 'post-quantum').length; - check('E2E-VIS-6', 'the SSH report’s algorithm tables show each strength as the bits bar and key exchange and host keys with the post-quantum glyph (PRD-013 REQ-VIS-10)', - rated.length > 0 && wrongBars.length === 0 && s.pq.filter((p) => p === 'safe').length >= kexPq && s.pq.includes('vulnerable'), - JSON.stringify({ rated: rated.length, wrongBars: wrongBars.slice(0, 4), pq: s.pq.join('').length ? s.pq.slice(0, 12) : [] })); - await shot('vis-ssh'); - } else check('E2E-VIS-6', 'the SSH report’s algorithm tables use the bits bar and the post-quantum glyph', false, 'no SSH endpoint was scanned'); + const got = await js(`return [...document.querySelectorAll('.sshrow[data-kind]')].map((r) => ({ kind: r.dataset.kind, name: r.dataset.name, + bar: r.querySelector('.bitsbar')?.getAttribute('aria-label') || '', pq: r.querySelector('svg.pqg-safe') ? 'quantum-safe' : r.querySelector('svg.pqg-vuln') ? 'quantum-vulnerable' : 'none', + mark: r.querySelector('.bmark-off') ? 'off' : r.querySelector('.bmark-ok') ? 'allowed' : '', used: r.classList.contains('is-used') }))`); + const want = (sev.sshAlgorithms || []).filter((a) => a.kind !== 'compression'); + const pqWant = (a) => (a.kind === 'kex' || a.kind === 'host-key' ? (a.verdict === 'post-quantum' ? 'quantum-safe' : a.verdict === 'unknown' ? 'none' : 'quantum-vulnerable') : 'none'); + const wrong = want.map((a, i) => [a, got[i] || {}]).filter(([a, g]) => g.kind !== a.kind || g.name !== a.name || g.bar !== `${a.name}: ${words(a.strength)}` + || g.pq !== pqWant(a) || g.mark !== (a.baseline || '') || g.used !== !!a.used).map(([a, g]) => ({ want: a.name, got: g })); + check('E2E-VIS-6', 'the SSH algorithm rows equal the evaluation’s algorithms in the server’s order, each with its bits bar, post-quantum glyph, baseline mark and in-use mark (PRD-014 REQ-SSV-2)', + want.length > 0 && got.length === want.length && wrong.length === 0 && got.some((g) => g.mark === 'off') && got.some((g) => g.pq === 'quantum-vulnerable'), + JSON.stringify({ want: want.length, got: got.length, wrong: wrong.slice(0, 3) }).slice(0, 500)); + await shot('vis-ssh', { maxH: 5000 }); + + // E2E-VIS-8: one Configuration page in a reviewer's order, a jump row leading to each part, every + // finding named once under its own part, the baseline as tiles and chips, and no list of sources. + const ids = ['sec-ssh-version', 'sec-key-exchange', 'sec-host-keys', 'sec-encryption', 'sec-integrity', 'sec-baseline']; + const cfg = await js(`const parts = [...document.querySelectorAll('.ssh-config > section.cfg')]; + const issueAt = {}; + for (const p of parts) for (const a of p.querySelectorAll('.detcard-issues a[href^="#chk-"]')) (issueAt[a.getAttribute('href').slice(5)] = issueAt[a.getAttribute('href').slice(5)] || []).push(p.id); + const jump = [...document.querySelectorAll('.ssh-config nav.jump a')].map((a) => ({ href: a.getAttribute('href'), dot: !!a.querySelector('.jump-dot') })); + const reqs = [...document.querySelectorAll('#sec-baseline .reqtile')].map((t) => ({ href: t.getAttribute('href'), title: t.title, target: t.target })); + const chips = [...document.querySelectorAll('.ssh-config .chg')].map((c) => c.title.split(String.fromCharCode(10))[0]); + const last = document.querySelector('.ssh-config > :last-child'); + const p = document.getElementById('sec-integrity'); + p.scrollIntoView({ block: 'start' }); + window.scrollTo(0, 0); + document.querySelector('.ssh-config nav.jump a[href="#sec-integrity"]').click(); + await new Promise((r) => setTimeout(r, 400)); + const t = p.getBoundingClientRect().top; + return { order: parts.map((x) => x.id), jump, issueAt, reqs, chips, cites: document.querySelectorAll('.ssh-config ul.cites').length, + lastIsAbout: !!last && last.id === 'sec-details', jumped: t >= -2 && t < innerHeight / 2, sticky: getComputedStyle(document.querySelector('.ssh-config nav.jump')).position }`); + const findings = [...new Map((sev.findings || []).filter((f) => f.section !== 'vulnerabilities').map((f) => [f.id, f])).values()]; + const misplaced = findings.filter((f) => (cfg.issueAt[f.id] || []).join() !== `sec-${f.section}`).map((f) => ({ id: f.id, at: cfg.issueAt[f.id] })); + const b = sev.baseline || { requires: [], changes: [] }; + const reqsOk = cfg.reqs.length === (b.requires || []).length && (b.requires || []).every((r, i) => cfg.reqs[i] && cfg.reqs[i].href === r.cite.url && cfg.reqs[i].title === `Source: ${r.cite.title}` && cfg.reqs[i].target === '_blank'); + const chipsOk = cfg.chips.length === (b.changes || []).length && (b.changes || []).every((c) => cfg.chips.includes(c.text)); + check('E2E-VIS-8', 'the SSH Configuration is six parts in a reviewer’s order under a sticky jump row that leads to each; every finding is named once under its own part; each requirement is a tile linking to its source; each baseline change a chip; no list of sources; About last (PRD-014 REQ-SSV-1, 4, 5, 6)', + cfg.order.join() === ids.join() && cfg.jump.map((j) => j.href).join() === ids.map((x) => `#${x}`).join() && cfg.jump.every((j) => j.dot) && cfg.sticky === 'sticky' && cfg.jumped + && findings.length > 0 && misplaced.length === 0 && reqsOk && chipsOk && cfg.cites === 0 && cfg.lastIsAbout, + JSON.stringify({ order: cfg.order, jumped: cfg.jumped, misplaced, reqs: cfg.reqs.length, chips: cfg.chips.length, changes: (b.changes || []).length, cites: cfg.cites }).slice(0, 500)); + + // E2E-VIS-9: version, key exchange and host keys as instruments that agree with the report. + const srep = await js(`return (await fetch('/api/v1/scans/${ssh.lastDone.id}/report')).json()`); + const inst = await js(`const tile = (sec, name) => { const t = [...document.querySelectorAll(sec + ' .stile')].find((x) => x.querySelector('b')?.textContent === name); return t ? { tone: [...t.classList].find((c) => /^stile-(good|attention|problem|na)$/.test(c)), state: t.querySelector('.stile-state').textContent, detail: t.querySelector('.stile-detail')?.textContent || '' } : null; }; + return { banner: [...document.querySelectorAll('#sec-ssh-version .negotiated .nchip')].map((c) => c.textContent), protocol: tile('#sec-ssh-version', 'Protocol'), strict: tile('#sec-key-exchange', 'Strict key exchange'), + level: document.querySelector('#sec-key-exchange .readyrow-level b')?.textContent, steps: document.querySelectorAll('#sec-key-exchange .lstep-seg.on').length, + cards: [...document.querySelectorAll('#sec-host-keys .keycard')].map((c) => ({ missing: c.classList.contains('is-missing'), fp: c.querySelector('.keycard-fp')?.textContent || '', verified: /verified ✓/.test(c.textContent), bar: !!c.querySelector('.bitsbar') })) }`); + const n = srep.negotiated || {}; + const keys = srep.hostKeys || []; + const failed = (srep.hostKeyAttempts || []).filter((x) => x.outcome !== 'key').length; + const r = sev.readiness || {}; + let ssh1View = null; + if (ssh1) { + await go(`#/ssh/${ssh1.id}?tab=configuration`); + await sleep(1400); + ssh1View = await js(`return { parts: document.querySelectorAll('.ssh-config > section.cfg').length, protocol: [...document.querySelectorAll('#sec-ssh-version .stile')].find((t) => t.querySelector('b')?.textContent === 'Protocol')?.querySelector('.stile-state').textContent, + na: [...document.querySelectorAll('#sec-ciphers p.muted, #sec-encryption p.muted')].map((p) => p.textContent).join(' '), rows: document.querySelectorAll('.sshrow[data-kind]').length }`); + } + check('E2E-VIS-9', 'the SSH banner is what a current client negotiates; the protocol and strict key exchange tiles, the readiness level and stepper, and a card per host key (verified as the scan says) agree with the report; an SSH-1-only server still draws every part (PRD-014 REQ-SSV-3, 8)', + inst.banner.slice(0, 3).join() === [n.kex, n.hostKey, n.cipher].join() && inst.banner[3] === (n.mac || 'no MAC') + && inst.protocol && inst.protocol.detail === srep.identification.raw && inst.protocol.tone === (srep.identification.ssh1 ? 'stile-problem' : 'stile-good') + && inst.strict && inst.strict.tone === (srep.algorithms.strictKex ? 'stile-good' : inst.strict.tone) && /^On/.test(inst.strict.state) === !!srep.algorithms.strictKex + && inst.level === (r.level < 0 ? '?' : `L${r.level}`) && inst.steps === Math.max(0, r.level + 1) + && inst.cards.filter((c) => !c.missing).length === keys.length && inst.cards.filter((c) => c.missing).length === failed + && keys.every((k) => inst.cards.some((c) => c.fp === k.fingerprint && c.verified === !!k.verified && c.bar)) + && (!ssh1 || (ssh1View.parts === 6 && ssh1View.protocol === 'SSH-1 only' && /Does not apply/.test(ssh1View.na) && ssh1View.rows === 0)), + JSON.stringify({ banner: inst.banner, n, protocol: inst.protocol, strict: inst.strict, level: inst.level, cards: inst.cards.length, keys: keys.length, ssh1View }).slice(0, 600)); + + // E2E-VIS-10: the SSH Summary is four instruments; the weakest strength is the evaluation's weakest + // rated algorithm; the baseline is line 1 of Fix first in the shared row shape, with no prose block. + await go(`#/ssh/${ssh.id}`); + await sleep(1400); + const ss = await js(`return { labels: [...document.querySelectorAll('.sumstrip .sumtile')].map((t) => t.querySelector('.sumtile-label')?.textContent), + weakest: document.querySelector('.sumtile-strength .bitsbar')?.getAttribute('aria-label') || '', stepper: !!document.querySelector('.sumstrip .lstep'), + first: (() => { const li = document.querySelector('#fix ol.fixlist > li'); return li ? { n: li.querySelector('.fix-n')?.textContent, title: li.querySelector('.action-title')?.textContent, link: li.querySelector('.action-more')?.textContent } : null; })(), + nums: [...document.querySelectorAll('#fix ol.fixlist > li .fix-n')].map((x) => x.textContent), prose: /Why it matters/.test(document.querySelector('.report.summary').innerText), lists: document.querySelectorAll('#fix ol').length }`); + const rank = { broken: 0, weak: 1, acceptable: 2, strong: 3 }; + const band = (st) => (!st ? 'na' : st.status === 'broken' ? 'broken' : st.status !== 'rated' && st.status !== 'disallowed' ? 'na' : st.bits < 80 ? 'broken' : st.bits < 112 ? 'weak' : st.bits < 128 ? 'acceptable' : 'strong'); + const w = (sev.sshAlgorithms || []).filter((a) => band(a.strength) !== 'na').sort((x, y) => (rank[band(x.strength)] - rank[band(y.strength)]) || ((x.strength.bits || 0) - (y.strength.bits || 0)))[0]; + let good = null; + if (sshGood) { + await go(`#/ssh/${sshGood.id}`); + await sleep(1200); + good = await js(`return { first: document.querySelector('#fix ol.fixlist > li .action-title')?.textContent || '', tile: document.querySelectorAll('.sumstrip .sumtile')[1]?.innerText || '' }`); + } + check('E2E-VIS-10', 'the SSH Summary has readiness with the stepper, the baseline, the weakest classical strength (the evaluation’s weakest) and the host key; the baseline is Fix first line 1 in the shared row shape, numbered on from it, with no “Why it matters” (PRD-014 REQ-SSV-7)', + ss.labels.join() === 'Post-quantum readiness,Baseline,Weakest classical strength,Host key' && ss.stepper && !!w && ss.weakest === `${w.name}: ${words(w.strength)}` + && ss.first && ss.first.n === '1' && /^Meet /.test(ss.first.title) && /sshd_config/.test(ss.first.link) && ss.nums.every((x, i) => x === String(i + 1)) && ss.lists === 1 && !ss.prose + && (!good || (/✓ Meets/.test(good.tile) && !/^Meet /.test(good.first))), + JSON.stringify({ ss, weakest: w && w.name, good }).slice(0, 500)); + await shot('vis-ssh-summary'); + } else { + for (const id of ['E2E-VIS-6', 'E2E-VIS-8', 'E2E-VIS-9', 'E2E-VIS-10']) check(id, 'the SSH report’s instruments', false, 'no SSH server was scanned'); + } // E2E-VIS-7: every rebuilt screen fits 1440, 1100, 860 and 375 px with no sideways scroll, every // control is reachable by Tab, and instrument text meets AA contrast in light and dark. const screens = [['summary', `#/endpoints/${target.id}`], ['configuration', `#/endpoints/${target.id}?tab=configuration`], ['inventory', '#/inventory/algorithms']]; if (leaf) screens.push(['certificate', `#/certificates/${leaf.sha256}?scan=${scan}`]); + if (ssh) screens.push(['ssh-summary', `#/ssh/${ssh.id}`], ['ssh-configuration', `#/ssh/${ssh.id}?tab=configuration`]); const fit = []; for (const [w, h, mobile] of [[1440, 900, false], [1100, 900, false], [860, 1000, false], [375, 812, true]]) { await viewport(w, h, false, mobile); @@ -192,6 +284,6 @@ export async function visualPhase({ check, js, go, sleep, shot, viewport }) { await viewport(1440, 900, false); const bad = fit.filter((x) => x.over > 1 || x.unreachable > 0); const lowC = contrast.filter((c) => c.low.length); - check('E2E-VIS-7', 'Summary, Configuration, the Inventory and the certificate page fit 1440, 1100, 860 and 375 px, every control is reachable by Tab, and instrument text meets 4.5:1 contrast in light and dark (PRD-013 REQ-VIS-11)', + check('E2E-VIS-7', 'Summary, Configuration, the Inventory, the certificate page and the SSH Summary and Configuration fit 1440, 1100, 860 and 375 px, every control is reachable by Tab, and instrument text meets 4.5:1 contrast in light and dark (PRD-013 REQ-VIS-11, PRD-014 REQ-SSV-8)', bad.length === 0 && lowC.length === 0, JSON.stringify({ bad, lowContrast: lowC }).slice(0, 700)); } diff --git a/web/static/js/views/report-ssh.js b/web/static/js/views/report-ssh.js index 6a99780..a948f23 100644 --- a/web/static/js/views/report-ssh.js +++ b/web/static/js/views/report-ssh.js @@ -1,11 +1,13 @@ -// The SSH server report (PRD-008 WP-D, WP-G; UX-DESIGN-007 §4). The tabs, verdict, action items, -// every-check list and CBOM are report.js's; this module gives them SSH's own areas, facts, tiles -// and words. Everything the server sent reaches the page through the textContent-only DOM builder. +// The SSH server report (PRD-008 WP-D, WP-G; PRD-014, UX-DESIGN-012). The tabs, verdict, Fix first, +// every-check list and CBOM are report.js's; this module gives them SSH's areas, summary tiles and +// words, and draws SSH's own Configuration: one page in the order an SSH reviewer checks, every +// quantity an instrument from viz.js, every source in a tooltip. Everything the server sent reaches +// the page through the textContent-only DOM builder. import { h } from '../dom.js'; import { levelClass, levelName } from '../charts.js'; -import { badge, button, citations, toast } from '../ui.js'; -import { registerProfile, versionState, plural } from './report.js'; -import { bitsBar, pqGlyph } from '../viz.js'; +import { button, toast } from '../ui.js'; +import { registerProfile, plural } from './report.js'; +import { bitsBar, bitsAxis, pqGlyph, statusTile, levelStepper, jumpNav, bandOf } from '../viz.js'; export const SSH_SECTIONS = [ ['ssh-version', 'SSH version & software', 'Which protocol version the server speaks and what it says it runs.'], @@ -30,213 +32,356 @@ const WHY = { const GROUP_WORD = { protocol: 'SSH version', 'key-exchange': 'Key exchange', pqc: 'Post-quantum', 'host-key': 'Host keys', cipher: 'Ciphers', mac: 'Integrity (MACs)', feature: 'Compression', vulnerability: 'Published attacks' }; -// Each algorithm's status against the baseline (allowed or should be off), marked post-quantum -// where it is; a name this program does not know is not on the baseline either. -const statusOf = (a) => { - if (a.baseline === 'off') return ['bad', '✖', 'Should be off', a.verdict === 'unknown' ? 'not on the baseline; not in this program’s registry' : 'not on the baseline']; - if (a.verdict === 'post-quantum') return ['good', '✔', 'Allowed', 'post-quantum']; - return ['good', '✔', 'Allowed', 'on the baseline']; -}; +const algsOf = (ev, kind) => (ev.sshAlgorithms || []).filter((a) => a.kind === kind); const offCount = (all) => all.filter((a) => a.baseline === 'off').length; - -// 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'); -} - +const keyLabel = (k) => (k.algorithm === 'RSA' && k.bits ? `RSA ${k.bits}` : k.curve ? `${k.algorithm} ${k.curve}` : k.algorithm); // Key exchange and host keys are what a quantum computer breaks; ciphers and MACs are not a quantum question. -const pqOf = (a) => (a.kind === 'kex' || a.kind === 'host-key' ? (a.verdict === 'post-quantum' ? 'quantum-safe' : a.verdict === 'unknown' ? '' : 'quantum-vulnerable') : ''); +const pqOf = (a) => (a.kind === 'kex' || a.kind === 'host-key' ? (a.verdict === 'post-quantum' ? 'quantum-safe' : a.verdict === 'unknown' ? 'unknown' : 'quantum-vulnerable') : ''); +const citesTitle = (list) => (list || []).map((c) => c.title).filter(Boolean).join('\n'); -/** 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', 'Classical strength', 'Post-quantum', '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: 'invbits' }, kind === 'compression' ? '' : a.strength ? bitsBar(a.strength, { name: a.name }) : strengthWords(a.strength)), - h('td', { class: 'invpq' }, pqGlyph(pqOf(a))), - h('td', null, versionState(statusOf(a))), - h('td', null, a.used ? badge('used by OpenSSH 10.6', 'info', 'What a current client negotiates') : null))))))); -} - -const keyLabel = (k) => (k.algorithm === 'RSA' && k.bits ? `RSA ${k.bits}` : k.curve ? `${k.algorithm} ${k.curve}` : k.algorithm); +// ---- the areas' one-line verdicts (the Summary's By area tiles) ------------------------------- function versionBody(ctx) { const id = ctx.rep.identification; - if (!id) return { facts: ['No identification was read.'] }; - const facts = [`The server identifies as ${id.raw}.`, id.ssh1 ? (id.ssh1Only ? 'It speaks only SSH-1.' : 'It accepts SSH-1 as well as SSH-2.') : 'SSH-1 is not offered.']; - if ((id.preamble || []).length) facts.push(`It sent ${plural(id.preamble.length, 'line')} before its identification.`); - return { verdict: `SSH-${id.protoVersion}, ${id.software || 'software not named'}. ${id.ssh1 ? 'SSH-1 is accepted.' : 'SSH-1 is not offered.'}`, facts }; + if (!id) return { verdict: 'No identification was read.' }; + return { verdict: id.ssh1Only ? `SSH-1 only: ${id.software || 'software not named'}.` : `SSH-${id.protoVersion}, ${id.software || 'software not named'}. ${id.ssh1 ? 'SSH-1 is accepted too.' : 'SSH-1 is not offered.'}` }; } function kexBody(ctx) { - if (!ctx.rep.algorithms) return { facts: [] }; - const { ev, rep } = ctx; - const r = ev.readiness; - const pq = (ev.sshAlgorithms || []).filter((a) => a.kind === 'kex' && a.verdict === 'post-quantum').map((a) => a.name); - const facts = [ - pq.length ? `Post-quantum hybrid key exchanges offered: ${pq.join(', ')}.` : 'No post-quantum key exchange is offered.', - // What a current client uses is said once, in the Key establishment dimension below. - r.hndlExposed ? 'Sessions recorded today could be decrypted by a future quantum computer (harvest now, decrypt later).' : null, - rep.algorithms && rep.algorithms.strictKex ? 'Strict key exchange is on (the countermeasure to Terrapin).' : 'Strict key exchange is not offered.', - ].filter(Boolean); - const dim = (title, d) => h('div', { class: 'dim' }, h('h4', null, title, badge(d.status, 'neutral')), h('div', null, d.detail), citations(d.citations)); - const lead = h('div', { class: 'pqpanel' }, - h('div', { class: 'readiness' }, h('div', { class: `bigLevel ${levelClass(r.level)}` }, r.level < 0 ? '?' : `L${r.level}`, h('small', null, r.label)), - h('div', null, h('p', null, r.summary))), - dim('Key establishment', r.keyEstablishment), dim('Authentication', r.authentication), dim('Symmetric strength', r.symmetric), - h('h3', null, 'What to do next'), h('ol', { class: 'next' }, r.nextSteps.map((s) => h('li', null, s))), - h('p', { class: 'hint' }, 'The level scale is this product’s own; each fact above cites its standard.')); - return { facts, all: h('div', null, lead, algoTable(ev, 'kex', 'Key exchange methods')) }; + if (!ctx.rep.algorithms) return {}; + const pq = algsOf(ctx.ev, 'kex').filter((a) => a.verdict === 'post-quantum').map((a) => a.name); + return { verdict: pq.length ? `Post-quantum hybrid offered: ${pq.join(', ')}.` : 'No post-quantum key exchange is offered.' }; } function hostKeysBody(ctx) { const keys = ctx.rep.hostKeys || []; - const attempts = ctx.rep.hostKeyAttempts || []; - const facts = keys.length ? [`${plural(keys.length, 'host key')}: ${keys.map(keyLabel).join(', ')}.`, - keys.every((k) => k.verified) ? 'Every key’s signature over the key exchange verified.' : 'Some signatures could not be verified (see each key).'] - : []; - const table = keys.length ? h('div', { class: 'tablewrap' }, h('table', { class: 'compact' }, - h('thead', null, h('tr', null, ['Key', 'Fingerprint', 'Answers', 'Signature'].map((c) => h('th', { scope: 'col' }, c)))), - h('tbody', null, keys.map((k) => h('tr', null, - h('td', null, h('b', null, keyLabel(k)), k.pq ? [' ', badge('post-quantum', 'good')] : null, - k.certificate ? h('div', { class: 'hint' }, `OpenSSH certificate from CA ${k.certificate.caFingerprint}`, - (k.certificate.principals || []).length ? `, for ${k.certificate.principals.join(', ')}` : '') : null), - h('td', null, h('code', { class: 'fp' }, k.fingerprint)), - h('td', { class: 'muted' }, (k.servedBy || []).join(', ')), - h('td', null, k.verified ? versionState(['good', '✔', 'Verified', 'the key signed the exchange']) - : versionState(['warn', '?', 'Not verified', k.verifyNote || 'no reason given']))))))) : null; - const failed = attempts.filter((a) => a.outcome !== 'key'); - return { facts, all: h('div', null, table, algoTable(ctx.ev, 'host-key', 'Host key algorithms offered'), - failed.length ? h('p', { class: 'hint' }, `Not fetched: ${failed.map((a) => `${a.algorithm} (${a.detail || a.outcome})`).join('; ')}.`) : null) }; + if (!keys.length) return {}; + return { verdict: `${keys.map(keyLabel).join(', ')}. ${keys.every((k) => k.verified) ? 'Every signature verified.' : 'Not every signature could be verified.'}` }; } -function encryptionBody(ctx) { - if (!ctx.rep.algorithms) return { facts: [] }; - const all = (ctx.ev.sshAlgorithms || []).filter((a) => a.kind === 'cipher'); +const countVerdict = (all, one) => { const bad = offCount(all); - const n = ctx.rep.negotiated || {}; - return { facts: [`${plural(all.length, 'cipher')} offered${bad ? `, ${bad} that should be off` : ', none that should be off'}.`, - n.cipher ? `A current client uses ${n.cipher}.` : null].filter(Boolean), - all: algoTable(ctx.ev, 'cipher') }; + return `${plural(all.length, one)} offered, ${bad ? `${bad} to turn off` : 'none to turn off'}.`; +}; +function encryptionBody(ctx) { + return ctx.rep.algorithms ? { verdict: countVerdict(algsOf(ctx.ev, 'cipher'), 'cipher') } : {}; } - function integrityBody(ctx) { - if (!ctx.rep.algorithms) return { facts: [] }; - const all = (ctx.ev.sshAlgorithms || []).filter((a) => a.kind === 'mac'); - const bad = offCount(all); - const n = ctx.rep.negotiated || {}; - const comp = (ctx.ev.sshAlgorithms || []).filter((a) => a.kind === 'compression').map((a) => a.name); - return { facts: [`${plural(all.length, 'MAC')} offered${bad ? `, ${bad} that should be off` : ', none that should be off'}.`, - n.cipher && !n.mac ? 'The cipher a current client uses carries its own integrity, so no MAC is used.' : n.mac ? `A current client uses ${n.mac}.` : null, - comp.length ? `Compression: ${comp.join(', ')}.` : null].filter(Boolean), - all: h('div', null, algoTable(ctx.ev, 'mac'), algoTable(ctx.ev, 'compression', 'Compression')) }; + return ctx.rep.algorithms ? { verdict: countVerdict(algsOf(ctx.ev, 'mac'), 'MAC') } : {}; } - function vulnerabilitiesBody(ctx) { - if (!ctx.rep.algorithms) return { facts: [] }; - const list = ctx.ev.vulnerabilities || []; - if (!list.length) return { facts: [] }; + const list = ctx.rep.algorithms ? ctx.ev.vulnerabilities || [] : []; + if (!list.length) return {}; const exposed = list.filter((a) => a.status === 'exposed'); - const order = { exposed: 0, 'not-exposed': 1, 'not-tested': 2 }; - const word = { exposed: ['Possible', 'bad'], 'not-exposed': ['Not possible', 'good'], 'not-tested': ['Not tested', 'neutral'] }; - return { - facts: [exposed.length ? `Exposed to ${exposed.map((a) => a.name).join(', ')}.` : 'Not exposed to any attack this scan can infer.'], - detail: h('div', { class: 'tablewrap' }, h('table', { class: 'compact attacktable' }, - h('thead', null, h('tr', null, ['Attack', 'Here', 'Why'].map((c) => h('th', { scope: 'col' }, c)))), - h('tbody', null, list.slice().sort((x, y) => order[x.status] - order[y.status]).map((x) => h('tr', null, - h('td', null, h('b', null, x.name)), h('td', null, badge(word[x.status][0], word[x.status][1])), h('td', { class: 'muted' }, x.why)))))), - }; -} - -/** The summary tiles: readiness on its scale, the baseline verdict, the host key. An SSH server has no score. */ + return { verdict: exposed.length ? `Exposed to ${exposed.map((a) => a.name).join(', ')}.` : 'Not exposed to any attack this scan can infer.' }; +} + +// ---- the Summary: four instruments, and the baseline as the first thing to fix ------------------ + +/** The weakest classical strength among the server's algorithms, and which one sets it. */ +function weakestSSH(ev) { + const rank = { broken: 0, weak: 1, acceptable: 2, strong: 3 }; + const list = (ev.sshAlgorithms || []).filter((a) => bandOf(a.strength) !== 'na'); + return list.slice().sort((a, b) => (rank[bandOf(a.strength)] - rank[bandOf(b.strength)]) || ((a.strength.bits || 0) - (b.strength.bits || 0)))[0] || null; +} +// A host key is as strong as the best signature it can make; a weak signature algorithm is its own row. +function keyStrength(ev, k) { + const rank = (a) => ({ broken: -1, na: -2 }[bandOf(a.strength)] ?? a.strength.bits); + return algsOf(ev, 'host-key').filter((a) => (k.servedBy || []).includes(a.name) && a.strength).sort((a, b) => rank(b) - rank(a))[0] || null; +} +const SECTION_OF_KIND = { kex: 'key-exchange', 'host-key': 'host-keys', cipher: 'encryption', mac: 'integrity', compression: 'integrity' }; + +/** The summary tiles: readiness, the baseline, the weakest strength, the host key. An SSH server has no score. */ function sshStrip(ctx) { const { ev, rep } = ctx; const r = ev.readiness, b = ev.baseline; - const tile = (sec, cls, big, label, sub) => h('a', { class: `sumtile ${cls}`, href: `#sec-${sec}`, onclick: (e) => { e.preventDefault(); ctx.goDetails(sec); } }, - h('div', { class: 'sumtile-big' }, big), h('div', { class: 'sumtile-label' }, label), h('div', { class: 'sumtile-sub' }, sub)); - const ready = tile('key-exchange', `bigLevel sumtile-lead ${levelClass(r.level)}`, r.level < 0 ? '?' : `L${r.level}`, 'Post-quantum readiness', r.label || levelName(r.level)); - ready.appendChild(h('div', { class: 'lvscale', 'aria-hidden': 'true' }, [0, 1, 2, 3, 4].map((l) => h('span', { class: l <= r.level ? 'on' : '' })))); + const tile = (sec, cls, label, ...body) => h('a', { class: `sumtile ${cls}`, href: `#sec-${sec}`, onclick: (e) => { e.preventDefault(); ctx.goDetails(sec); } }, + h('div', { class: 'sumtile-label' }, label), ...body); + const fig = (big, sub, cls = '') => h('div', { class: 'sumtile-figure' }, h('span', { class: `sumtile-big ${cls}` }, big), h('span', { class: 'sumtile-sub' }, sub)); + const ready = tile('key-exchange', `sumtile-lead bigLevel ${levelClass(r.level)}`, 'Post-quantum readiness', + fig(r.level < 0 ? '?' : `L${r.level}`, r.label || levelName(r.level), levelClass(r.level)), levelStepper(r.level)); const [baseBig, baseTone, baseSub] = !b || !b.judged ? ['Not judged', 'lvu', b && b.reason ? b.reason : 'the server’s lists were not read'] - : b.meets ? ['✓ Meets', 'lv3', b.title] - : [`${b.changes.length} change${b.changes.length === 1 ? '' : 's'}`, 'lv0', `to meet ${b.title}`]; + : b.meets ? ['✓ Meets', 'lv3', b.title] : [plural(b.changes.length, 'change'), 'lv0', `to meet ${b.title}`]; + const base = tile('baseline', `bigLevel ${baseTone}`, 'Baseline', fig(baseBig, baseSub, baseTone)); + const weak = weakestSSH(ev); + const strength = tile(weak ? SECTION_OF_KIND[weak.kind] : 'key-exchange', 'sumtile-strength', 'Weakest classical strength', + fig(weak ? (weak.strength.status === 'broken' ? 'broken' : String(weak.strength.bits)) : '·', weak ? `${weak.strength.status === 'broken' ? '' : 'bits · '}${weak.name}` : 'not rated'), + weak ? bitsBar(weak.strength, { name: weak.name }) : null); const keys = rep.hostKeys || []; const k = keys.find((x) => (x.servedBy || []).includes((rep.negotiated || {}).hostKey)) || keys[0]; - const keySub = k ? `${k.pq ? 'post-quantum' : 'classical'} · ${k.fingerprint.slice(0, 19)}…` : 'no host key fetched'; - return h('div', { class: 'sumstrip', role: 'group', 'aria-label': 'Summary' }, - ready, - tile('baseline', `bigLevel ${baseTone}`, baseBig, 'Baseline', baseSub), - tile('host-keys', `bigLevel ${k ? (k.pq ? 'lv4' : 'lv3') : 'lvu'}`, k ? keyLabel(k) : 'None', 'Host key', keySub)); -} - -/** - * baselineBlock leads the Configuration tab (UX-DESIGN-007 §4.3): the verdict, what the baseline - * requires (met or not, with its source) and, when the server does not meet it, the sshd_config - * lines that would make it, with a Copy button. - */ -function baselineBlock(ctx) { + // The key's tone follows its strength: post-quantum, strong, acceptable, or weak and broken. + const kband = k && keyStrength(ev, k) ? bandOf(keyStrength(ev, k).strength) : 'na'; + const ktone = !k ? 'lvu' : kband === 'broken' || kband === 'weak' ? 'lv0' : kband === 'acceptable' ? 'lv1' : k.pq ? 'lv4' : 'lv3'; + const key = tile('host-keys', `bigLevel ${ktone}`, 'Host key', + fig(k ? keyLabel(k) : 'None', k ? `${k.pq ? 'post-quantum' : 'classical'} · ${k.fingerprint.slice(0, 19)}…` : 'no host key fetched', ktone)); + return h('div', { class: 'sumstrip', role: 'group', 'aria-label': 'Where this server stands' }, ready, base, strength, key); +} + +const KIND_WORD = { kex: ['key exchange', 'key exchanges'], 'host-key': ['host-key algorithm', 'host-key algorithms'], cipher: ['cipher', 'ciphers'], + mac: ['MAC', 'MACs'], compression: ['compression method', 'compression methods'] }; + +/** The baseline's changes in a line: "Remove 8 algorithms; add mlkem768x25519-sha256; replace a host key." + * Each change's reason is in the row's tooltip. */ +function changesLine(changes) { + const parts = []; + const removes = changes.filter((c) => c.action === 'remove'); + const kinds = [...new Set(removes.map((c) => c.kind))]; + if (removes.length === 1) parts.push(`remove ${removes[0].name}`); + else if (removes.length) parts.push(`remove ${removes.length} ${kinds.length === 1 && KIND_WORD[kinds[0]] ? KIND_WORD[kinds[0]][1] : 'algorithms'}`); + for (const c of changes.filter((x) => x.action === 'add')) parts.push(`add ${c.name}`); + const rk = changes.filter((c) => c.action === 'replace-key').length; + if (rk) parts.push(rk === 1 ? 'replace a host key' : `replace ${rk} host keys`); + for (const c of changes.filter((x) => x.action === 'upgrade')) parts.push(c.text.replace(/\.$/, '')); + const s = parts.join('; '); + return s ? `${s[0].toUpperCase()}${s.slice(1)}.` : ''; +} + +/** The first Fix first line of a server that does not meet its baseline (report.js draws the row). */ +function baselineAction(ctx) { const b = ctx.ev.baseline; - if (!b) return h('div'); - const card = h('section', { class: 'card baseline', id: 'sec-baseline', 'aria-labelledby': 'baseline-h' }, h('h2', { id: 'baseline-h' }, `Baseline: ${b.title}`)); - if (!b.judged) { - card.appendChild(h('p', { class: 'muted' }, `Not judged. ${b.reason || ''}`)); - return card; + if (!b || !b.judged || b.meets) return null; + return { id: 'baseline', chip: plural(b.changes.length, 'change'), title: `Meet ${b.title}`, fix: changesLine(b.changes), + why: b.changes.map((c) => c.text).join('\n'), link: b.sshdConfig ? 'The sshd_config lines' : 'The changes', go: () => ctx.goDetails('baseline') }; +} + +// ---- the Configuration: the one-stop check (UX-DESIGN-012 §3) --------------------------------- + +const toneOf = (st) => ({ problem: 'problem', attention: 'attention', good: 'good' }[st] || 'na'); + +// Where a baseline change is drawn: with the algorithms it names; an upgrade with the version when the +// server speaks only SSH-1, else with the key exchange (strict key exchange). +function changeSection(ch, rep) { + if (ch.action === 'upgrade') return rep.identification && rep.identification.ssh1Only ? 'ssh-version' : 'key-exchange'; + return SECTION_OF_KIND[ch.kind] || 'integrity'; +} +const CHANGE_GLYPH = { remove: '−', add: '+', upgrade: '↑', 'replace-key': '⟲' }; +const CHANGE_WORD = { remove: 'remove', add: 'add', upgrade: 'upgrade', 'replace-key': 'replace' }; + +/** One baseline change as a chip: what to do to which name; the reason and its source in the tooltip. */ +function changeChip(c) { + const named = c.action === 'remove' || c.action === 'add'; + return h('span', { class: `chg chg-${c.action}`, title: `${c.text}${c.cite && c.cite.title ? `\nSource: ${c.cite.title}` : ''}` }, + h('span', { class: 'chg-glyph', 'aria-hidden': 'true' }, CHANGE_GLYPH[c.action] || '·'), + h('span', { class: 'sr-only' }, `${CHANGE_WORD[c.action] || c.action} `), + named ? h('code', null, c.name) : h('span', null, c.text.replace(/\.$/, ''))); +} + +/** A part of the page: its heading and aside, its body, then its issues and baseline changes. */ +function part(ctx, id, title, tone, aside, ...body) { + const issues = ctx.issues[id] || []; + const changes = ctx.changes[id] || []; + return h('section', { class: `card cfg ssh-cfg cfg-${tone}`, id: `sec-${id}`, 'aria-labelledby': `sec-${id}-h` }, + h('div', { class: 'cfg-head' }, h('h2', { id: `sec-${id}-h` }, title), aside ? h('span', { class: 'cfg-aside' }, aside) : null), ...body, + issues.length || changes.length ? h('div', { class: 'detcard-issues' }, + issues.length ? h('p', null, h('b', null, issues.length === 1 ? 'Issue: ' : 'Issues: '), + issues.map((f, i) => [i ? ' · ' : '', h('a', { href: `#chk-${f.id}`, class: 'xlink', onclick: (e) => { e.preventDefault(); ctx.goIssue(f.id); } }, f.title)])) : null, + changes.length ? h('p', { class: 'chg-row' }, h('b', null, 'Baseline: '), changes.map(changeChip)) : null) : null); +} + +/** A part the scan could not read: why, in one muted line. */ +const notRead = (ctx, id) => { + const sec = (ctx.ev.sections || []).find((s) => s.id === id); + if (!sec || !sec.reason) return h('p', { class: 'muted' }, 'Not read by this scan.'); + return h('p', { class: 'muted' }, `${sec.status === 'not-applicable' ? 'Does not apply' : 'Not read'}: ${sec.reason}.`); +}; + +/** The algorithm rows of one kind, in the order the server lists them, under the shared axis. */ +function algoRows(ctx, kind, label) { + const rows = algsOf(ctx.ev, kind); + if (!rows.length) return null; + const client = (ctx.rep.negotiated || {}).client || 'a current client'; + return h('div', { class: 'sshalgs', role: 'group', 'aria-label': label }, + h('div', { class: 'sshrow sshrow-axis', 'aria-hidden': 'true' }, h('span', { class: 'muted' }, label), bitsAxis(), h('span'), h('span', { class: 'muted' }, 'Baseline')), + rows.map((a) => { + const off = a.baseline === 'off'; + return h('div', { class: `sshrow${a.used ? ' is-used' : ''}${off ? ' is-off' : ''}`, 'data-kind': a.kind, 'data-name': a.name }, + h('span', { class: 'sshrow-alg' }, h('code', null, a.name), + h('span', null, a.display || (a.verdict === 'unknown' ? 'not in this program’s registry' : ''), + a.used ? h('span', { class: 'usedchip', title: `What ${client} negotiates` }, 'in use') : null)), + bitsBar(a.strength, { name: a.name }), + h('span', { class: 'sshrow-pq' }, pqGlyph(pqOf(a))), + a.baseline === '' ? h('span', { class: 'bmark bmark-na', title: 'A signal, not an algorithm: the baseline does not judge it' }, '·') + : h('span', { class: `bmark ${off ? 'bmark-off' : 'bmark-ok'}`, title: off ? (a.verdict === 'unknown' ? 'Not on the baseline, and not in this program’s registry' : 'Not on the baseline') : 'On the baseline' }, + h('span', { 'aria-hidden': 'true' }, off ? '✕' : '✓'), off ? 'should be off' : 'allowed')); + })); +} + +// 1. version and software +function versionPart(ctx, tone) { + const { rep } = ctx; + const id = rep.identification; + const n = rep.negotiated; + const plus = () => h('span', { class: 'nplus', 'aria-hidden': 'true' }, '+'); + const pqKex = n && algsOf(ctx.ev, 'kex').some((a) => a.name === n.kex && a.verdict === 'post-quantum'); + const banner = n && n.kex ? h('div', { class: 'negotiated', role: 'group', 'aria-label': 'What a current client gets', title: n.client || null }, + h('span', { class: 'negotiated-label' }, 'A current client gets'), + h('code', { class: `nchip${pqKex ? ' nchip-pq' : ''}` }, pqKex ? pqGlyph('quantum-safe') : null, n.kex), + n.hostKey ? [plus(), h('code', { class: 'nchip' }, n.hostKey)] : null, + n.cipher ? [plus(), h('code', { class: 'nchip' }, n.cipher)] : null, + n.cipher ? [plus(), n.mac ? h('code', { class: 'nchip' }, n.mac) : h('span', { class: 'nchip nchip-none', title: 'The cipher authenticates itself (AEAD), so no MAC is used' }, 'no MAC')] : null, + h('span', { class: 'negotiated-alpn' }, n.verified ? 'signature verified' : n.completed ? 'completed, not verified' : 'not completed')) : null; + if (!id) return part(ctx, 'ssh-version', 'Version and software', tone, null, notRead(ctx, 'ssh-version')); + const software = statusTile('Software', `${id.software || 'Not named'}${id.comment ? ` (${id.comment})` : ''}`, 'na'); + software.title = 'As the server announces it. Its version is not matched against vendor advisories (see Weaknesses).'; + const pre = id.preamble || []; + return part(ctx, 'ssh-version', 'Version and software', tone, null, banner, h('div', { class: 'stiles' }, + statusTile('Protocol', id.ssh1Only ? 'SSH-1 only' : id.ssh1 ? 'SSH-2, and SSH-1 accepted' : 'SSH-2 only', id.ssh1 ? 'problem' : 'good', null, null, id.raw), + software, + statusTile('Lines before the identification', pre.length ? plural(pre.length, 'line') : 'None', 'na', null, null, pre.length ? pre.join('\n') : null))); +} + +// 2. key exchange +function kexPart(ctx, tone) { + const { ev, rep } = ctx; + const r = ev.readiness || {}; + const readiness = h('div', { class: 'readyrow' }, + h('span', { class: 'readyrow-level' }, h('b', null, r.level < 0 ? '?' : `L${r.level}`), h('span', null, r.label || '')), + levelStepper(r.level), + h('span', { class: 'readyrow-dims' }, [['Key establishment', r.keyEstablishment], ['Authentication', r.authentication], ['Symmetric', r.symmetric]].filter(([, d]) => d).map(([t, d]) => + h('span', { class: 'dimchip', title: [d.detail, citesTitle(d.citations)].filter(Boolean).join('\n\nSources:\n') }, h('span', { class: 'muted' }, `${t} `), d.status)))); + const next = (r.nextSteps || []).length ? h('ol', { class: 'next compact' }, r.nextSteps.map((s) => h('li', null, s))) : null; + if (!rep.algorithms) return part(ctx, 'key-exchange', 'Key exchange', tone, null, readiness, notRead(ctx, 'key-exchange'), next); + const terrapin = (ev.vulnerabilities || []).find((v) => v.name === 'Terrapin'); + const exposed = terrapin && terrapin.status === 'exposed'; + const strict = rep.algorithms.strictKex ? statusTile('Strict key exchange', 'On: the countermeasure to Terrapin', 'good') + : statusTile('Strict key exchange', exposed ? 'Not offered: exposed to Terrapin' : 'Not offered', exposed ? 'problem' : 'attention'); + strict.title = 'CVE-2023-48795; OpenSSH 9.6 and later'; + return part(ctx, 'key-exchange', 'Key exchange', tone, 'Every list here is in the server’s order; in each, the client’s order decides (RFC 4253 §7.1).', + readiness, algoRows(ctx, 'kex', 'Key exchange methods'), h('div', { class: 'stiles' }, strict), next); +} + +// 3. host keys +function hostKeysPart(ctx, tone) { + const { rep, ev } = ctx; + if (!rep.algorithms) return part(ctx, 'host-keys', 'Host keys', tone, null, notRead(ctx, 'host-keys')); + const keys = rep.hostKeys || []; + const n = rep.negotiated || {}; + const cards = keys.map((k) => { + const s = keyStrength(ev, k); + const inUse = (k.servedBy || []).includes(n.hostKey); + return h('li', { class: `chaincard keycard${inUse ? ' is-leaf' : ''}${k.verified ? '' : ' hasissue'}` }, + h('span', { class: 'cc-role' }, inUse ? 'In use' : 'Also offered'), + h('span', { class: 'servedcn keycard-name' }, pqGlyph(k.pq ? 'quantum-safe' : 'quantum-vulnerable'), keyLabel(k)), + s ? bitsBar(s.strength, { name: keyLabel(k) }) : null, + h('span', { class: 'cc-chips' }, + h('span', { class: `tchip ${k.verified ? 'tchip-good' : 'tchip-warn'}`, title: k.verified ? 'The key signed the key exchange, and the signature verified' : (k.verifyNote || 'no reason given') }, + k.verified ? 'Signature verified ✓' : 'Not verified ?'), + (k.servedBy || []).map((x) => h('span', { class: 'cchip', title: 'A host-key algorithm this key answers to' }, x))), + h('code', { class: 'cc-names keycard-fp', title: 'SHA-256 fingerprint, as ssh-keygen -l shows it' }, k.fingerprint), + k.certificate ? h('span', { class: 'cc-foot' }, `OpenSSH certificate from CA ${k.certificate.caFingerprint}`, + (k.certificate.principals || []).length ? `, for ${k.certificate.principals.join(', ')}` : '') : null); + }); + for (const a of (rep.hostKeyAttempts || []).filter((x) => x.outcome !== 'key')) { + cards.push(h('li', { class: 'chaincard keycard is-missing' }, h('span', { class: 'cc-role cc-role-bad' }, 'Not fetched'), + h('span', { class: 'servedcn' }, h('code', null, a.algorithm)), h('span', { class: 'cc-foot' }, a.detail || a.outcome))); } - card.appendChild(b.meets - ? h('p', null, versionState(['good', '✔', 'Meets the baseline', 'every algorithm offered is on it, and every requirement holds'])) - : h('p', null, versionState(['bad', '✖', 'Does not meet the baseline', `${plural(b.changes.length, 'change')} to make`]))); + return part(ctx, 'host-keys', 'Host keys', tone, 'Each key the server proved it holds. A key is as strong as its best signature.', + cards.length ? h('ol', { class: 'chainribbon keyribbon', 'aria-label': 'Host keys' }, cards) : h('p', { class: 'muted' }, 'No host key was fetched.'), + algoRows(ctx, 'host-key', 'Host-key algorithms')); +} + +// 4. ciphers +function cipherPart(ctx, tone) { + const a = ctx.rep.algorithms; + if (!a) return part(ctx, 'encryption', 'Ciphers', tone, null, notRead(ctx, 'encryption')); + const twoLists = (a.cipherC2S || []).join() !== (a.cipherS2C || []).join(); + return part(ctx, 'encryption', 'Ciphers', tone, twoLists ? 'Each direction has its own list; both are shown, merged.' : null, algoRows(ctx, 'cipher', 'Ciphers')); +} + +// 5. MACs and compression +function integrityPart(ctx, tone) { + if (!ctx.rep.algorithms) return part(ctx, 'integrity', 'MACs and compression', tone, null, notRead(ctx, 'integrity')); + const n = ctx.rep.negotiated || {}; + const comp = algsOf(ctx.ev, 'compression').map((a) => a.name); + const before = comp.includes('zlib'); + const words = comp.map((c) => (c === 'zlib@openssh.com' ? 'zlib@openssh.com, after login' : c === 'zlib' ? 'zlib, before login' : c)); + const tile = statusTile('Compression', comp.length ? words.join(' · ') : 'Not listed', before ? 'attention' : comp.length ? 'good' : 'na'); + if (before) tile.title = 'Compression before login exposes the decompressor to anyone who can connect'; + return part(ctx, 'integrity', 'MACs and compression', tone, + n.cipher && !n.mac ? `${n.cipher} authenticates itself, so a current client uses no MAC.` : null, + algoRows(ctx, 'mac', 'MACs'), h('div', { class: 'stiles' }, tile)); +} + +const SSH_PARTS = [['ssh-version', 'Version & software'], ['key-exchange', 'Key exchange'], ['host-keys', 'Host keys'], ['encryption', 'Ciphers'], + ['integrity', 'MACs & compression'], ['baseline', 'Baseline']]; + +// 6. baseline changes +function baselinePart(ctx, tone) { + const b = ctx.ev.baseline; + if (!b) return null; + const aside = h('span', null, 'This product’s own; each entry cites its source. ', h('a', { href: '#/help?topic=baseline' }, 'The baseline, entry by entry')); + if (!b.judged) return part(ctx, 'baseline', 'Baseline', tone, aside, h('div', { class: 'stiles' }, statusTile(b.title, `Not judged: ${b.reason || 'the server’s lists were not read'}`, 'na'))); + const verdict = statusTile(b.title, b.meets ? 'Meets the baseline' : `Does not meet it · ${plural(b.changes.length, 'change')} to make`, b.meets ? 'good' : 'problem'); + verdict.classList.add('stile-verdict'); + const reqs = (b.requires || []).map((r) => { + const t = statusTile(r.text, r.met ? 'Met' : `Not met${r.detail ? `: ${r.detail.replace(/\.$/, '')}` : ''}`, r.met ? 'good' : 'problem', r.cite && r.cite.url ? r.cite.url : null); + t.classList.add('reqtile'); + if (r.cite) { + t.title = `Source: ${r.cite.title}`; + if (r.cite.url) { t.target = '_blank'; t.rel = 'noopener noreferrer'; } + } + return t; + }); + // The changes are drawn with the algorithms they name; this part leads to them. + const where = SSH_PARTS.filter(([id]) => id !== 'baseline' && (ctx.changes[id] || []).length); + const goTo = (id) => document.getElementById(`sec-${id}`)?.scrollIntoView({ block: 'start' }); + const body = [h('div', { class: 'stiles' }, verdict), + reqs.length ? [h('h3', { class: 'cfg-sub' }, 'What the baseline requires'), h('div', { class: 'stiles' }, reqs)] : null, + where.length ? h('p', { class: 'chg-where' }, h('span', { class: 'muted' }, 'The changes, in their parts: '), + where.map(([id, label]) => h('a', { href: `#sec-${id}`, class: 'jump-chip', onclick: (e) => { e.preventDefault(); goTo(id); } }, `${label} · ${ctx.changes[id].length}`))) : null]; if (b.sshdConfig) { const lines = b.sshdConfig.replace(/\n$/, '').split('\n'); - card.appendChild(h('h3', null, 'Make this server meet the baseline')); - card.appendChild(h('pre', { class: 'sshdconf', 'aria-label': 'sshd_config lines', tabindex: '0' }, - lines.map((l) => h('span', { class: l.startsWith('#') ? 'cmt' : null }, `${l}\n`)))); - card.appendChild(h('div', { class: 'row' }, - button('Copy the lines', async () => { - try { await navigator.clipboard.writeText(b.sshdConfig); toast('Copied', 'success'); } catch { toast('Select the lines and copy them', 'error'); } - }, 'secondary', { class: 'btn btn-secondary btn-sm' }), - h('span', { class: 'hint' }, 'Put these lines at the top of /etc/ssh/sshd_config, or in a file under /etc/ssh/sshd_config.d/ that is read first: sshd uses the first value of each keyword. Check with sshd -t, reload sshd, and scan again.'))); - } - card.appendChild(h('h3', null, 'What the baseline requires')); - // A list, not a table: each requirement keeps its verdict whole at any width. - card.appendChild(h('ul', { class: 'reqs' }, (b.requires || []).map((r) => h('li', null, - h('div', { class: 'req-text' }, r.text), - h('div', null, r.met ? versionState(['good', '✔', 'Met', '']) : versionState(['bad', '✖', 'Not met', r.detail || ''])), - citations([r.cite]))))); - if (b.changes.length) { - card.appendChild(h('h3', null, 'The changes')); - card.appendChild(h('ul', { class: 'changes' }, b.changes.map((c) => h('li', null, c.text)))); + body.push(h('h3', { class: 'cfg-sub' }, 'Make this server meet the baseline'), + h('pre', { class: 'sshdconf', 'aria-label': 'sshd_config lines', tabindex: '0' }, lines.map((l) => h('span', { class: l.startsWith('#') ? 'cmt' : null }, `${l}\n`))), + h('div', { class: 'row' }, + button('Copy the lines', async () => { + try { await navigator.clipboard.writeText(b.sshdConfig); toast('Copied', 'success'); } catch { toast('Select the lines and copy them', 'error'); } + }, 'secondary', { class: 'btn btn-secondary btn-sm' }), + h('span', { class: 'hint' }, 'Put them first in sshd_config (sshd keeps the first value of each keyword), then sshd -t, reload, and scan again.'))); } - card.appendChild(h('p', { class: 'hint' }, 'The baseline is this product’s own; each entry cites its source. The full list: cipherhorizon ssh baselines.')); - return card; + return part(ctx, 'baseline', 'Baseline', tone, aside, ...body); } -/** baselineAction is the first action item of a server that does not meet its baseline: the lines to copy. */ -function baselineAction(ctx) { - const b = ctx.ev.baseline; - if (!b || !b.judged || b.meets) return null; - return h('div', { class: 'action-group' }, - h('div', { class: 'action-group-head' }, h('h3', null, 'Baseline'), - h('p', { class: 'action-why' }, h('b', null, 'Why it matters: '), `The server offers algorithms ${b.title} leaves out, or lacks what it requires.`)), - h('ol', { class: 'actions' }, h('li', { class: 'action sev-medium', id: 'issue-baseline' }, - h('div', { class: 'action-head' }, badge(`${plural(b.changes.length, 'change')}`, 'bad'), h('span', { class: 'action-title' }, `Meet ${b.title}`)), - h('p', { class: 'action-fix' }, h('b', null, 'What to do: '), b.changes.slice(0, 3).map((c) => c.text).join(' '), b.changes.length > 3 ? ` And ${plural(b.changes.length - 3, 'more')}.` : ''), - h('a', { href: '#sec-baseline', class: 'xlink action-more', onclick: (e) => { e.preventDefault(); ctx.goDetails('baseline'); } }, 'The sshd_config lines to copy')))); +/** The whole Configuration tab for an SSH server, and the jump row that leads through it. */ +function sshConfiguration(ctx, about) { + const { ev, rep } = ctx; + const sec = Object.fromEntries((ev.sections || []).map((x) => [x.id, x.status])); + // Each finding once, under its part; the published attacks stay on Weaknesses. + const issues = {}; + const seen = new Set(); + for (const f of ev.findings || []) { + if (seen.has(f.id) || f.section === 'vulnerabilities') continue; + seen.add(f.id); + (issues[f.section] = issues[f.section] || []).push(f); + } + const changes = {}; + const b = ev.baseline; + if (b && b.judged) { + for (const c of b.changes || []) { + const at = changeSection(c, rep); + (changes[at] = changes[at] || []).push(c); + } + } + const pctx = { ...ctx, issues, changes }; + const tones = Object.fromEntries(SSH_PARTS.map(([id]) => [id, toneOf(sec[id])])); + tones.baseline = !b || !b.judged ? 'na' : b.meets ? 'good' : 'attention'; + const goTo = (id) => document.getElementById(id)?.scrollIntoView({ block: 'start' }); + return h('div', { class: 'report configuration ssh-config' }, + jumpNav(SSH_PARTS.filter(([id]) => id !== 'baseline' || b).map(([id, label]) => ({ id: `sec-${id}`, label, tone: tones[id] })), goTo), + versionPart(pctx, tones['ssh-version']), + kexPart(pctx, tones['key-exchange']), + hostKeysPart(pctx, tones['host-keys']), + cipherPart(pctx, tones.encryption), + integrityPart(pctx, tones.integrity), + baselinePart(pctx, tones.baseline), + about); } registerProfile('ssh', { sections: SSH_SECTIONS, builders: () => ({ 'ssh-version': versionBody, 'key-exchange': kexBody, 'host-keys': hostKeysBody, encryption: encryptionBody, integrity: integrityBody, vulnerabilities: vulnerabilitiesBody }), - strip: sshStrip, why: WHY, groupWord: GROUP_WORD, hygiene: null, matrix: baselineBlock, extraAction: baselineAction, skipCards: ['vulnerabilities'], + strip: sshStrip, why: WHY, groupWord: GROUP_WORD, hygiene: null, configuration: sshConfiguration, extraAction: baselineAction, skipCards: ['vulnerabilities'], stageTitles: { identification: 'Identification', algorithms: 'Algorithm lists', negotiation: 'Negotiation', 'host-keys': 'Host keys' }, schema: 'ssh-report/1', }); diff --git a/web/static/js/views/report.js b/web/static/js/views/report.js index 6c54661..f9abe32 100644 --- a/web/static/js/views/report.js +++ b/web/static/js/views/report.js @@ -146,14 +146,21 @@ export function summaryView(ctx) { const items = h('section', { class: 'card fixcard', id: 'fix', 'aria-labelledby': 'fix-h' }, h('div', { class: 'cfg-head' }, h('h2', { id: 'fix-h' }, 'Fix first'), h('a', { href: '#', class: 'xlink', onclick: (e) => { e.preventDefault(); ctx.goTab('weaknesses'); } }, 'Every check'))); - if (extra) items.appendChild(extra); - if (!act.length) { - if (!extra) items.appendChild(h('p', { class: 'muted' }, 'Nothing needs fixing. The checks that found nothing are listed under Weaknesses.')); + if (!act.length && !extra) { + items.appendChild(h('p', { class: 'muted' }, 'Nothing needs fixing. The checks that found nothing are listed under Weaknesses.')); } else { // One numbered line per finding, worst first: what it is and what to do. Why it matters is in - // its evidence (UX-DESIGN-011 §3.1). - items.appendChild(h('ol', { class: 'fixlist actions' }, act.map((f, i) => h('li', { class: `action sev-${f.severity}`, id: `issue-${f.id}` }, - h('span', { class: 'fix-n' }, String(i + 1)), + // its evidence (UX-DESIGN-011 §3.1). A profile's own action (SSH: the baseline) is line 1. + const off = extra ? 1 : 0; + items.appendChild(h('ol', { class: 'fixlist actions' }, + extra ? h('li', { class: 'action sev-medium', id: `issue-${extra.id}` }, + h('span', { class: 'fix-n' }, '1'), + badge(extra.chip, 'warn'), + h('span', { class: 'fix-main' }, h('span', { class: 'action-head' }, h('span', { class: 'action-title' }, extra.title)), + h('span', { class: 'action-fix', title: extra.why || null }, extra.fix)), + h('a', { href: `#sec-${extra.id}`, class: 'xlink action-more', onclick: (e) => { e.preventDefault(); extra.go(); } }, extra.link)) : null, + act.map((f, i) => h('li', { class: `action sev-${f.severity}`, id: `issue-${f.id}` }, + h('span', { class: 'fix-n' }, String(i + 1 + off)), sevBadge(f.severity), h('span', { class: 'fix-main' }, h('span', { class: 'action-head' }, h('span', { class: 'action-title' }, f.title), h('span', { class: `urgency u-${f.severity}` }, URGENCY[f.severity]), diff --git a/web/static/style.css b/web/static/style.css index 942fa05..8a19456 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -1884,3 +1884,36 @@ td.invpq { width: 1%; text-align: center; } @media (max-width: 520px) { .lane-axis, .lane-row { grid-template-columns: minmax(96px, 120px) minmax(0, 1fr) 22px; } } .suite-strength-narrow { display: none; } @media (max-width: 520px) { .suite-strength-narrow { display: block; margin-top: 4px; font-size: 11.5px; color: var(--muted); } table.suites th.suite { position: sticky; left: 0; background: var(--surface); z-index: 1; max-width: 230px; } } +/* ---- UX-DESIGN-012: the SSH Configuration ------------------------------------------------------ */ +.sshalgs { display: flex; flex-direction: column; gap: 2px; } +.sshrow { display: grid; grid-template-columns: minmax(170px, 300px) minmax(0, 1fr) 28px 128px; column-gap: 14px; align-items: center; padding: 5px 8px; border-radius: 3px; } +.sshrow-axis { font-size: 12px; padding-bottom: 2px; } +.sshrow-alg { display: flex; flex-direction: column; min-width: 0; } .sshrow-alg code { font-size: 13px; font-weight: 500; overflow-wrap: anywhere; } +.sshrow-alg > span { font-size: 11.5px; color: var(--muted); } +.sshrow-pq { display: flex; justify-content: center; } +.sshrow.is-used { background: var(--primary-soft); } +.sshrow.is-off { box-shadow: inset 3px 0 0 var(--bad); } +.usedchip { margin-left: 6px; font-size: 11px; font-weight: 600; color: var(--link); background: var(--surface); border-radius: 3px; padding: 1px 6px; } +.bmark { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; font-weight: 600; white-space: nowrap; } +.bmark-ok { color: var(--good); } .bmark-off { color: var(--bad); } .bmark-na { color: var(--muted); } +@media (min-width: 521px) { .sshrow .bitsbar, .sshrow-axis .bb-axis-row { grid-template-columns: minmax(60px, 1fr) 9.5em; } } +.nchip-none { color: var(--text-2); } +.cfg-sub { margin: 4px 0 -4px; font-size: 13.5px; font-weight: 600; } +.reqtile { color: var(--text); } a.reqtile:hover { border-color: var(--border-strong); text-decoration: none; } +.chg-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 6px 0 0; } +.detcard-issues > p { margin: 0; } .detcard-issues > p + p { margin-top: 6px; } +.chg { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; border-radius: 3px; padding: 2px 8px; background: var(--surface); border: 1px solid var(--border); overflow-wrap: anywhere; } +.chg code { font-size: 12px; } +.chg-glyph { font-weight: 700; } .chg-remove .chg-glyph, .chg-replace-key .chg-glyph { color: var(--bad); } .chg-add .chg-glyph, .chg-upgrade .chg-glyph { color: var(--good); } +.chg-where { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0; font-size: 13px; } +.keyribbon { gap: 16px; } .keyribbon .chaincard + .chaincard::before { content: none; } +.keycard { flex-basis: 300px; } .keycard .bitsbar { margin-top: 2px; } +.keycard-name { display: inline-flex; align-items: center; gap: 6px; } +.keycard-fp { font-family: var(--font-mono); word-break: break-all; } +@media (max-width: 1100px) { .sshrow { grid-template-columns: minmax(140px, 220px) minmax(0, 1fr) 24px 118px; column-gap: 10px; } } +@media (max-width: 860px) { + .sshrow { grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) 22px; row-gap: 2px; } + .sshrow > .bmark { grid-column: 1; grid-row: 2; } .sshrow-axis > span:last-child { display: none; } +} +@media (max-width: 520px) { .sshrow { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 22px; column-gap: 8px; padding: 5px 4px; } .keycard { flex-basis: 260px; } } +@media (min-width: 521px) { .stile-verdict { grid-column: span 2; } } From 2485210c54ef2c30ac93d38f83a1191a7254935c Mon Sep 17 00:00:00 2001 From: Khalid Saifullah Khan Date: Wed, 21 Oct 2026 21:05:43 +0530 Subject: [PATCH 2/3] fix(eval): SSH advice fits the server, truncated MACs, rules with nothing to judge (eval/11) From the evaluation of PR #24: - next steps and the strict key exchange change name OpenSSH releases only to an OpenSSH server; any other server is told what to offer; - a truncated MAC is as strong as its tag (hmac-sha1-96 is 96 bits, not 128; SP 800-107 Rev. 1 section 5.3.5); - the RSA size rule says it does not apply when there is no RSA host key, instead of reading Met; - each should-be-off row says why on the row, in a wider column. eval/11 re-evaluates stored scans on start. Tests: TestSSHNextStepsNameOpenSSHOnlyForOpenSSH, TestRSASizeRuleWithoutAnRSAKeyDoesNotApply, TestTruncatedMACIsBoundedByItsTag. --- internal/eval/eval.go | 4 +- internal/eval/ssh.go | 27 ++++++++--- internal/eval/ssh_strength.go | 5 +++ internal/eval/sshbaseline.go | 25 ++++++++--- internal/eval/sshbaseline_test.go | 75 +++++++++++++++++++++++++++++++ web/static/js/views/report-ssh.js | 17 ++++--- web/static/style.css | 4 +- 7 files changed, 139 insertions(+), 18 deletions(-) diff --git a/internal/eval/eval.go b/internal/eval/eval.go index 080bc30..387913e 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/10" +const Version = "eval/11" // 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"). @@ -138,6 +138,8 @@ type Evaluation struct { // eval/10 (PRD-011): every algorithm the endpoint uses, by class, with its strength, // post-quantum status and uses. Each suite part also says its class, and gains its hash part. Algorithms []AlgorithmUse `json:"algorithms,omitempty"` + // eval/11 (PRD-014 QA): a truncated SSH MAC is bounded by its tag; SSH next steps name OpenSSH + // releases only to OpenSSH servers; a baseline requirement with nothing to judge does not apply. } // Check is one check the evaluation ran. Title names what was looked for (a problem, as a diff --git a/internal/eval/ssh.go b/internal/eval/ssh.go index 9979752..95a3ebc 100644 --- a/internal/eval/ssh.go +++ b/internal/eval/ssh.go @@ -582,7 +582,7 @@ func sshReadiness(c *sshCtx) Readiness { r.KeyEstablishment = ke r.Authentication = sshAuthDimension(rep, pqKeys) r.Symmetric = sshSymmetric(c) - r.Summary, r.NextSteps = sshNarrative(r.Level) + r.Summary, r.NextSteps = sshNarrative(r.Level, isOpenSSH(rep)) r.Citations = []Citation{citHNDL, citSSHMLKEM, citFIPS203, citFIPS204, citIR8547, citOpenSSH100, citOpenSSH106} return r } @@ -663,7 +663,14 @@ func sshSymmetric(c *sshCtx) Dimension { return d } -func sshNarrative(level int) (string, []string) { +// isOpenSSH reports whether the server identifies as OpenSSH, whose releases the next steps can name. +func isOpenSSH(rep *sshprobe.Report) bool { + return rep != nil && rep.Identification != nil && strings.HasPrefix(rep.Identification.Software, "OpenSSH") +} + +// sshNarrative is the readiness summary and next steps. OpenSSH servers are told which release does +// what; any other server is told what to offer, with OpenSSH as the reference point. +func sshNarrative(level int, openssh bool) (string, []string) { switch level { case LevelUndetermined: return "The scan could not read the server's algorithm lists, so readiness is unknown.", @@ -673,7 +680,8 @@ func sshNarrative(level int) (string, []string) { []string{"Replace the server software with a current SSH-2 implementation (OpenSSH 10 or later)."} case LevelExposed: return "Only classical key exchange: recorded sessions could be decrypted by a future quantum computer.", - []string{"Upgrade to OpenSSH 9.9 or later and put mlkem768x25519-sha256 first in KexAlgorithms (the default from OpenSSH 10.0).", + []string{pick2(openssh, "Upgrade to OpenSSH 9.9 or later and put mlkem768x25519-sha256 first in KexAlgorithms (the default from OpenSSH 10.0).", + "Offer mlkem768x25519-sha256 first in the key exchange list: a server library or release that has it is needed (OpenSSH has it from 9.9, by default from 10.0)."), "Keep curve25519-sha256 for clients that are not ready."} case LevelHybridAvail: return "A post-quantum hybrid key exchange is offered, but a current client does not end up using it.", @@ -681,7 +689,7 @@ func sshNarrative(level int) (string, []string) { case LevelHybridNeg: return "A current client gets a post-quantum hybrid key exchange: recorded sessions are protected.", []string{"Keep the hybrid method first; remove classical-only methods as clients allow.", - "Plan post-quantum host keys (ssh-mldsa44-ed25519, enabled in OpenSSH 10.6)."} + pick2(openssh, "Plan post-quantum host keys (ssh-mldsa44-ed25519, enabled in OpenSSH 10.6).", "Plan post-quantum host keys (ssh-mldsa44-ed25519; OpenSSH enables it from 10.6) once your server software supports them.")} } return "Post-quantum key exchange and a post-quantum host key.", []string{"Maintain; keep classical host keys only while clients need them."} @@ -709,7 +717,8 @@ func sshPQCFindings(c *sshCtx, r Readiness) []Finding { if len(fips) == 0 && len(sntrup) > 0 { add("SSH.PQ-NOT-FIPS", "The post-quantum key exchange is not a NIST-standardised one", Low, "Offered post-quantum methods: "+strings.Join(sntrup, ", ")+". Streamlined NTRU Prime is not a NIST standard; ML-KEM (FIPS 203) is.", - "Upgrade to OpenSSH 9.9 or later and offer mlkem768x25519-sha256 first; programmes bound to FIPS 203 need ML-KEM.", + pick2(isOpenSSH(c.rep), "Upgrade to OpenSSH 9.9 or later and offer mlkem768x25519-sha256 first; programmes bound to FIPS 203 need ML-KEM.", + "Offer mlkem768x25519-sha256 first (OpenSSH has it from 9.9); programmes bound to FIPS 203 need ML-KEM."), citSSHSntrup, citFIPS203, citSSHMLKEM) } } @@ -776,3 +785,11 @@ func sshAlgorithmViews(c *sshCtx, b *SSHBaseline) []SSHAlgorithm { } return out } + +// pick2 is a when the condition holds, else b. +func pick2(cond bool, a, b string) string { + if cond { + return a + } + return b +} diff --git a/internal/eval/ssh_strength.go b/internal/eval/ssh_strength.go index 670bc82..943876a 100644 --- a/internal/eval/ssh_strength.go +++ b/internal/eval/ssh_strength.go @@ -26,6 +26,11 @@ func sshStrength(kind, name string, a registry.Algorithm, ok bool, keys []sshpro st = sshNamed(a.Catalogue, a.KeyBits, a.Cipher) case registry.KindMAC: st = sshNamed(a.Catalogue, 0, "") + // A truncated tag bounds forgery: an attacker guesses it with probability 2^-tag (SP 800-107 + // Rev. 1 section 5.3.5), so hmac-sha1-96 is 96 bits, not HMAC-SHA-1's 128. + if a.TagBits > 0 && st.Known() && a.TagBits < st.Bits { + st = algo.Strength{Bits: a.TagBits, Status: st.Status, Basis: fmt.Sprintf("%s; truncated to a %d-bit tag, which bounds forgery (NIST SP 800-107 Rev. 1, section 5.3.5)", st.Basis, a.TagBits)} + } default: return nil } diff --git a/internal/eval/sshbaseline.go b/internal/eval/sshbaseline.go index b61b7bf..6690bb1 100644 --- a/internal/eval/sshbaseline.go +++ b/internal/eval/sshbaseline.go @@ -199,11 +199,14 @@ const ( // RequireView is one requirement and whether the server meets it. type RequireView struct { - ID string `json:"id"` - Text string `json:"text"` - Met bool `json:"met"` - Detail string `json:"detail,omitempty"` - Cite Citation `json:"cite"` + ID string `json:"id"` + Text string `json:"text"` + Met bool `json:"met"` + // NotApplicable: there was nothing to judge (no RSA host key for an RSA size rule). Met stays + // true, so the verdict is unchanged; the page says "does not apply" instead of "met". + NotApplicable bool `json:"notApplicable,omitempty"` + Detail string `json:"detail,omitempty"` + Cite Citation `json:"cite"` } // baselineKinds are the kinds a baseline judges, in sshd_config order. @@ -324,9 +327,19 @@ func sshConformance(c *sshCtx, b *SSHBaseline) *Conformance { v.Met = c.alg.StrictKex if !v.Met { v.Detail = "kex-strict-s-v00@openssh.com is not offered." - out.Changes = append(out.Changes, BaselineChange{Action: ChangeUpgrade, Text: "Upgrade to OpenSSH " + r.Since + " or later for strict key exchange.", Cite: r.Cite}) + out.Changes = append(out.Changes, BaselineChange{Action: ChangeUpgrade, Text: pick2(isOpenSSH(rep), "Upgrade to OpenSSH "+r.Since+" or later for strict key exchange.", + "Offer strict key exchange (kex-strict-s-v00@openssh.com; OpenSSH has it from "+r.Since+")."), Cite: r.Cite}) } case ReqRSASize: + rsa := 0 + for _, hk := range rep.HostKeys { + if hk.Algorithm == "RSA" { + rsa++ + } + } + if rsa == 0 { + v.NotApplicable, v.Detail = true, "The server has no RSA host key." + } for _, hk := range rep.HostKeys { if hk.Algorithm == "RSA" && hk.Bits > 0 && hk.Bits < b.MinRSABits { v.Met = false diff --git a/internal/eval/sshbaseline_test.go b/internal/eval/sshbaseline_test.go index 097b1b7..e09259d 100644 --- a/internal/eval/sshbaseline_test.go +++ b/internal/eval/sshbaseline_test.go @@ -304,3 +304,78 @@ func TestSSHRemediationNamesTheDirective(t *testing.T) { t.Errorf("only %d SSH rules fired: %v", len(seen), seen) } } + +// QA of PR #24: a requirement with nothing to judge says so. A server with no RSA host key meets the +// RSA size rule, but "met" would claim a check that never ran: it does not apply. +func TestRSASizeRuleWithoutAnRSAKeyDoesNotApply(t *testing.T) { + for name, tc := range map[string]struct { + rep *sshprobe.Report + na bool + met bool + }{ + "no RSA host key": {modernSSH(), true, true}, + "a 3072-bit RSA key": {stockOpenSSH10(), false, true}, + "a 2048-bit RSA key": {legacySSH(), false, false}, + } { + ev := eval.EvaluateSSH(tc.rep, eval.Input{Now: now}) + var got *eval.RequireView + for i, r := range ev.Baseline.Requires { + if r.ID == eval.ReqRSASize { + got = &ev.Baseline.Requires[i] + } + } + if got == nil { + t.Fatalf("%s: no RSA size requirement", name) + } + if got.NotApplicable != tc.na || got.Met != tc.met { + t.Errorf("%s: notApplicable %v met %v, want %v %v (%s)", name, got.NotApplicable, got.Met, tc.na, tc.met, got.Detail) + } + } +} + +// QA of PR #24: next steps name OpenSSH releases only to an OpenSSH server; any other server is told +// what to offer. +func TestSSHNextStepsNameOpenSSHOnlyForOpenSSH(t *testing.T) { + noPQ := func(software string) *sshprobe.Report { + r := modernSSH() + r.Identification.Software = software + r.Algorithms.Kex = []string{"curve25519-sha256"} + r.Negotiated.Kex = "curve25519-sha256" + return r + } + open := eval.EvaluateSSH(noPQ("OpenSSH_9.6"), eval.Input{Now: now}) + if !strings.HasPrefix(open.Readiness.NextSteps[0], "Upgrade to OpenSSH 9.9") { + t.Errorf("an OpenSSH server is told %q", open.Readiness.NextSteps[0]) + } + other := eval.EvaluateSSH(noPQ("Go"), eval.Input{Now: now}) + if strings.HasPrefix(other.Readiness.NextSteps[0], "Upgrade to OpenSSH") || !strings.Contains(other.Readiness.NextSteps[0], "mlkem768x25519-sha256") { + t.Errorf("a Go server is told %q", other.Readiness.NextSteps[0]) + } + noStrict := modernSSH() + noStrict.Identification.Software = "Go" + noStrict.Algorithms.StrictKex = false + for _, c := range eval.EvaluateSSH(noStrict, eval.Input{Now: now}).Baseline.Changes { + if c.Action == eval.ChangeUpgrade && strings.HasPrefix(c.Text, "Upgrade to OpenSSH") { + t.Errorf("a Go server is told %q", c.Text) + } + } +} + +// QA of PR #24: a truncated MAC is as strong as its tag (SP 800-107 Rev. 1 section 5.3.5). +func TestTruncatedMACIsBoundedByItsTag(t *testing.T) { + r := modernSSH() + r.Algorithms.MACC2S = []string{"hmac-sha1", "hmac-sha1-96", "hmac-sha2-256-etm@openssh.com"} + r.Algorithms.MACS2C = r.Algorithms.MACC2S + want := map[string]int{"hmac-sha1": 128, "hmac-sha1-96": 96, "hmac-sha2-256-etm@openssh.com": 256} + for _, a := range eval.EvaluateSSH(r, eval.Input{Now: now}).SSHAlgorithms { + if w, ok := want[a.Name]; ok { + if a.Strength == nil || a.Strength.Bits != w { + t.Errorf("%s: %+v, want %d bits", a.Name, a.Strength, w) + } + delete(want, a.Name) + } + } + if len(want) > 0 { + t.Errorf("not listed: %v", want) + } +} diff --git a/web/static/js/views/report-ssh.js b/web/static/js/views/report-ssh.js index a948f23..a60d9d6 100644 --- a/web/static/js/views/report-ssh.js +++ b/web/static/js/views/report-ssh.js @@ -201,8 +201,9 @@ function algoRows(ctx, kind, label) { bitsBar(a.strength, { name: a.name }), h('span', { class: 'sshrow-pq' }, pqGlyph(pqOf(a))), a.baseline === '' ? h('span', { class: 'bmark bmark-na', title: 'A signal, not an algorithm: the baseline does not judge it' }, '·') - : h('span', { class: `bmark ${off ? 'bmark-off' : 'bmark-ok'}`, title: off ? (a.verdict === 'unknown' ? 'Not on the baseline, and not in this program’s registry' : 'Not on the baseline') : 'On the baseline' }, - h('span', { 'aria-hidden': 'true' }, off ? '✕' : '✓'), off ? 'should be off' : 'allowed')); + : h('span', { class: `bmark ${off ? 'bmark-off' : 'bmark-ok'}`, title: off ? (ctx.offWhy[a.name] || (a.verdict === 'unknown' ? 'Not on the baseline, and not in this program’s registry' : 'Not on the baseline')) : 'On the baseline' }, + h('span', { 'aria-hidden': 'true' }, off ? '✕' : '✓'), off ? 'should be off' : 'allowed', + off && ctx.offWhy[a.name] ? h('span', { class: 'bmark-why' }, ctx.offWhy[a.name].split(/[:,(]/)[0].trim()) : null)); })); } @@ -275,7 +276,7 @@ function hostKeysPart(ctx, tone) { cards.push(h('li', { class: 'chaincard keycard is-missing' }, h('span', { class: 'cc-role cc-role-bad' }, 'Not fetched'), h('span', { class: 'servedcn' }, h('code', null, a.algorithm)), h('span', { class: 'cc-foot' }, a.detail || a.outcome))); } - return part(ctx, 'host-keys', 'Host keys', tone, 'Each key the server proved it holds. A key is as strong as its best signature.', + return part(ctx, 'host-keys', 'Host keys', tone, 'Each key the server proved it holds. Its bar is the strongest signature it can make; a weaker algorithm it also answers to has its own row below.', cards.length ? h('ol', { class: 'chainribbon keyribbon', 'aria-label': 'Host keys' }, cards) : h('p', { class: 'muted' }, 'No host key was fetched.'), algoRows(ctx, 'host-key', 'Host-key algorithms')); } @@ -314,7 +315,8 @@ function baselinePart(ctx, tone) { const verdict = statusTile(b.title, b.meets ? 'Meets the baseline' : `Does not meet it · ${plural(b.changes.length, 'change')} to make`, b.meets ? 'good' : 'problem'); verdict.classList.add('stile-verdict'); const reqs = (b.requires || []).map((r) => { - const t = statusTile(r.text, r.met ? 'Met' : `Not met${r.detail ? `: ${r.detail.replace(/\.$/, '')}` : ''}`, r.met ? 'good' : 'problem', r.cite && r.cite.url ? r.cite.url : null); + const t = r.notApplicable ? statusTile(r.text, `Does not apply${r.detail ? `: ${r.detail.replace(/\.$/, '').replace(/^The server has/, 'the server has')}` : ''}`, 'na', r.cite && r.cite.url ? r.cite.url : null) + : statusTile(r.text, r.met ? 'Met' : `Not met${r.detail ? `: ${r.detail.replace(/\.$/, '')}` : ''}`, r.met ? 'good' : 'problem', r.cite && r.cite.url ? r.cite.url : null); t.classList.add('reqtile'); if (r.cite) { t.title = `Source: ${r.cite.title}`; @@ -362,7 +364,12 @@ function sshConfiguration(ctx, about) { (changes[at] = changes[at] || []).push(c); } } - const pctx = { ...ctx, issues, changes }; + // Why each algorithm should be off, from the baseline's own change ("Stop offering x: why."). + const offWhy = {}; + for (const c of (b && b.judged ? b.changes || [] : [])) { + if (c.action === 'remove') offWhy[c.name] = c.text.replace(`Stop offering ${c.name}: `, '').replace(/\.$/, ''); + } + const pctx = { ...ctx, issues, changes, offWhy }; const tones = Object.fromEntries(SSH_PARTS.map(([id]) => [id, toneOf(sec[id])])); tones.baseline = !b || !b.judged ? 'na' : b.meets ? 'good' : 'attention'; const goTo = (id) => document.getElementById(id)?.scrollIntoView({ block: 'start' }); diff --git a/web/static/style.css b/web/static/style.css index 8a19456..3459c82 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -1886,7 +1886,7 @@ td.invpq { width: 1%; text-align: center; } @media (max-width: 520px) { .suite-strength-narrow { display: block; margin-top: 4px; font-size: 11.5px; color: var(--muted); } table.suites th.suite { position: sticky; left: 0; background: var(--surface); z-index: 1; max-width: 230px; } } /* ---- UX-DESIGN-012: the SSH Configuration ------------------------------------------------------ */ .sshalgs { display: flex; flex-direction: column; gap: 2px; } -.sshrow { display: grid; grid-template-columns: minmax(170px, 300px) minmax(0, 1fr) 28px 128px; column-gap: 14px; align-items: center; padding: 5px 8px; border-radius: 3px; } +.sshrow { display: grid; grid-template-columns: minmax(170px, 300px) minmax(0, 1fr) 28px 160px; column-gap: 14px; align-items: center; padding: 5px 8px; border-radius: 3px; } .sshrow-axis { font-size: 12px; padding-bottom: 2px; } .sshrow-alg { display: flex; flex-direction: column; min-width: 0; } .sshrow-alg code { font-size: 13px; font-weight: 500; overflow-wrap: anywhere; } .sshrow-alg > span { font-size: 11.5px; color: var(--muted); } @@ -1917,3 +1917,5 @@ td.invpq { width: 1%; text-align: center; } } @media (max-width: 520px) { .sshrow { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 22px; column-gap: 8px; padding: 5px 4px; } .keycard { flex-basis: 260px; } } @media (min-width: 521px) { .stile-verdict { grid-column: span 2; } } +.bmark-why { display: block; font-size: 11.5px; font-weight: 400; color: var(--text-2); white-space: normal; } +.sshrow .bmark { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 4px; } .sshrow .bmark-why { flex-basis: 100%; } From 2e43c3ee1f8ff6e5909baac02d99fd2bf987e8c9 Mon Sep 17 00:00:00 2001 From: Khalid Saifullah Khan Date: Wed, 21 Oct 2026 21:19:32 +0530 Subject: [PATCH 3/3] docs: second evaluation of the SSH rebuild (CRYPTO-REVIEW-002, QA-REPORT-013) Records the owner-requested re-evaluation: five crypto defects fixed (two from the first review, three in eval/11), the should-be-off reasons on the row, the methodology's truncated-MAC rule and the CHANGELOG. Journey 238 passed, 0 failed. --- CHANGELOG.md | 5 ++++ docs/methodology.md | 2 +- .../CRYPTO-REVIEW-002-ssh-instruments.md | 30 ++++++++++++------- docs/program/QA-REPORT-013.md | 18 +++++++++-- 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90b0796..371428c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,11 @@ the CBOM/report/eval schema versions, the database) can change in a minor releas ### Fixed +- **SSH evaluation (eval/11; stored scans are re-evaluated on start):** + - a truncated MAC is as strong as its tag: hmac-sha1-96 is 96 bits, not 128 (NIST SP 800-107 Rev. 1); + - the next steps name OpenSSH releases only to a server that identifies as OpenSSH; + - a baseline rule with nothing to judge (the RSA size rule on a server with no RSA key) says it does not apply + instead of *Met*. - **Single-DES suites (56-bit key) are Insecure, not Weak** (#18). - **The TLS hygiene figure on a report's Summary opened *About this report*.** It now opens how the score was worked out, on Weaknesses. diff --git a/docs/methodology.md b/docs/methodology.md index c6053df..414bf2a 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -152,7 +152,7 @@ The code is `internal/algo/strength.go` and `strength_of.go`. | 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, a KDF or a PRF (the TLS PRF, HKDF): SHA-1 → 128, SHA-256 and longer → 256 | SP 800-57 Part 1 Table 3 | +| Hashes and MACs | Signatures, where collision resistance is needed: SHA-256 → 128, SHA-384 → 192, SHA-512 → 256; SHA-1 and MD5 → broken. HMAC, a KDF or a PRF (the TLS PRF, HKDF): SHA-1 → 128, SHA-256 and longer → 256. A truncated MAC (hmac-sha1-96, umac-64) is bounded by its tag: the smaller of the two (eval/11) | SP 800-57 Part 1 Table 3; SP 800-107 Rev. 1 §5.3.5 | | Signature algorithms | The weaker of the hash (collision strength) and the signer's key, in every spelling (ECDSA-SHA256, SHA256-RSA, sha256WithRSAEncryption, SHA256withRSA). Without the key's size, the hash, and the basis says the key was not given | SP 800-57 Part 1 Tables 2 and 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 | diff --git a/docs/product/CRYPTO-REVIEW-002-ssh-instruments.md b/docs/product/CRYPTO-REVIEW-002-ssh-instruments.md index 6a6c84b..ddf7512 100644 --- a/docs/product/CRYPTO-REVIEW-002-ssh-instruments.md +++ b/docs/product/CRYPTO-REVIEW-002-ssh-instruments.md @@ -13,7 +13,7 @@ Data: the CI journey's SSH demo servers: 9460 (hybrid key exchange, OpenSSH-styl ## Verdict -**Signed off**, with the two defects below fixed in this branch and three notes left as they are, each with its +**Signed off**, with the five defects below fixed in this branch (3 to 5 from a second evaluation the owner asked for) and two notes left as they are, each with its reason. No number on the SSH screens disagrees with its cited source. ## Numbers checked @@ -71,17 +71,25 @@ reason. No number on the SSH screens disagrees with its cited source. 2. **"Listed in the server's order; the client's order decides" stood on Key exchange alone**, which could read as if the other lists were preferences. RFC 4253 §7.1 applies the rule to every list; the aside now says so. +3. **Truncated MACs were rated by their HMAC** (the first review's note 1, now fixed). hmac-sha1-96 showed 128 bits, + but a 96-bit tag lets a forger guess right with probability 2⁻⁹⁶ per attempt: SP 800-107 Rev. 1 §5.3.5 bounds a + truncated MAC by its tag. The strength is now the smaller of the HMAC's and the tag's (hmac-sha1-96 → 96, + umac-64 → 64), with the reason in its basis. `TestTruncatedMACIsBoundedByItsTag`. eval/11. +4. **The next steps told every server to "Upgrade to OpenSSH 9.9"**, including the demo servers that identify as Go. + OpenSSH releases are now named only to a server that identifies as OpenSSH; any other is told what to offer + (mlkem768x25519-sha256 first; strict key exchange), with OpenSSH as the reference point. + `TestSSHNextStepsNameOpenSSHOnlyForOpenSSH`. +5. **"RSA host keys are at least 3072 bits: Met" on servers with no RSA key.** Nothing was judged, so *Met* claimed a + check that never ran. The requirement now carries `notApplicable` and the tile says *Does not apply: the server has + no RSA host key*; the verdict is unchanged. `TestRSASizeRuleWithoutAnRSAKeyDoesNotApply`. + ## Notes left as they are -1. **Truncated MACs are rated by their HMAC.** hmac-sha1-96 shows 128 bits: SP 800-57 Table 3 rates the HMAC, not - its tag length, and a 96-bit tag bounds a forgery guess at 2⁻⁹⁶ per attempt, online. The row is marked - *should be off* and SSH.MAC-WEAK names the truncation, so no verdict is wrong; a tag-length cap - (SP 800-107 Rev. 1, truncated MAC tags) would be a change to the evaluation, which PRD-014 leaves alone. Proposed as a - follow-up for the strength catalogue. -2. **SHA-1 key exchanges are rated by their group.** diffie-hellman-group14-sha1 shows 112 bits (the 2048-bit - group); its SHA-1 exchange hash is what puts it on *should be off* (SSH.KEX-SHA1, RFC 9142). Same reasoning as - note 1. -3. **Ciphers and MACs carry a dot, not a shield.** The TLS class map and the Inventory mark AES as quantum-safe +1. **SHA-1 key exchanges are rated by their group.** diffie-hellman-group14-sha1 shows 112 bits (the 2048-bit + group); its SHA-1 exchange hash is what puts it on *should be off* (SSH.KEX-SHA1, RFC 9142), and the row now says so + beside the mark (*a SHA-1 key exchange*). The bar stays the group's strength and the mark carries the SHA-1 risk; + kept. +2. **Ciphers and MACs carry a dot, not a shield.** The TLS class map and the Inventory mark AES as quantum-safe (NIST IR 8547; Grover gives no practical break), while the SSH rows give ciphers and MACs no post-quantum verdict, as PRD-013 drew them. Both are true statements; the dot's tooltip says *no post-quantum verdict*, not *safe* or *vulnerable*. Kept, so the SSH page does not imply that its ciphers were classified by a source they @@ -89,5 +97,5 @@ reason. No number on the SSH screens disagrees with its cited source. ## Sign-off -The numbers and claims on the SSH Summary and Configuration are correct against their sources, with fixes 1 and 2 +The numbers and claims on the SSH Summary and Configuration are correct against their sources, with fixes 1 to 5 in this branch. Signed off for PRD-014 REQ-SSV-9, cryptography. diff --git a/docs/program/QA-REPORT-013.md b/docs/program/QA-REPORT-013.md index 57a59c2..677d31c 100644 --- a/docs/program/QA-REPORT-013.md +++ b/docs/program/QA-REPORT-013.md @@ -15,9 +15,9 @@ Requirements: [PRD-014](../product/PRD-014-ssh-instruments.md), [RTM](RTM.md) § | Copy rules | `TestUICopyRules`, `TestSSHReportCopyHasNoTLSWords`, `TestSSHServersAreNamedAndNotScored` | pass | | Lint | `golangci-lint run ./...`, and with `GOOS=linux` | 0 issues, 0 issues | | UI scripts parse | `node --check` on the changed modules | all parse | -| Browser journey | `testing/e2e/journey.mjs`, the CI setup (fresh database, fresh demo targets, sign-in on with a token) | **237 passed, 0 failed** | +| Browser journey | `testing/e2e/journey.mjs`, the CI setup (fresh database, fresh demo targets, sign-in on with a token) | **238 passed, 0 failed** (after the second evaluation, with PR #23's E2E-VIS-FOLD) | | Screen walk | below | done, 3 defects found and fixed | -| Cryptography | CRYPTO-REVIEW-002 | signed off, 2 defects fixed, 3 notes kept | +| Cryptography | CRYPTO-REVIEW-002 | signed off, 5 defects fixed, 2 notes kept | ## The new and rewritten checks @@ -61,6 +61,20 @@ Seen and kept: the single *Strict key exchange* and *Compression* tiles (one fac the *Software* tile in no tone (the scanner does not judge versions); the dot in the post-quantum column for ciphers and MACs (CRYPTO-REVIEW-002 note 3). +## Second evaluation (owner request, after the PR opened) + +The owner asked for a proper evaluation of this PR. Walked again as PM, design and cryptographer on the merged branch +(with PR #23's folding versions), at 1440 and 375 px: + +4. **The reason a row should be off was only in a tooltip**, so *hmac-sha2-256: 256 bits, should be off* read as a + contradiction. Each such row now says why under the mark (*encrypt-and-MAC*, *a CBC mode cipher*, *a 96-bit tag*), + in a Baseline column wide enough to hold it on one line (150 px at 1100 px and under, 160 above). +5. Cryptography found three more defects in the evaluation itself, fixed with tests: truncated MACs bounded by their + tag, OpenSSH releases named only to OpenSSH servers, and a rule with nothing to judge saying it does not apply + (CRYPTO-REVIEW-002 fixes 3 to 5). The evaluation is now eval/11, so stored scans are re-evaluated on start. +6. The host keys' aside said "a key is as strong as its best signature", which hid that a weaker algorithm the key + answers to is a row of its own. It now says both. + ## Not covered - The PDF and the SSH servers list are unchanged by PRD-014 and were not re-walked.