fix(test): F3 skip-as-pass in test_x509.py + F7 negative KAT vectors - #83
Conversation
Dropping the single symbol `ecdsa_verify` from build/labels.txt made tools/test_x509.py skip its entire ECDSA group, report "ALL 7 TESTS PASSED" and exit 0. The skipped assertions left the denominator instead of counting against it, so the suite reported success while verifying nothing about ECDSA. The realistic trigger is a libs/nistcurves bump renaming an export — exactly the change this suite exists to catch, and live risk today with the siblings two minor versions ahead of the pin. Every group the suite claims to run is now declared in REQUIRED_LABEL_SETS and is required: - main() fails closed in a preflight, before launching VICE: exit 1 with a summary line naming the group and its missing labels. - run_tests() counts each un-runnable group as a failure and names it. This matters independently: tools/run_all_tests.py calls run_tests() directly and only sees (passed, failed), so the arity is unchanged and a vanished group now turns that runner red too. - "no tests ran" is a failure rather than a shrug. - CV_LABELS is deliberately NOT required — no group drives it, and a group that does not exist cannot be silently skipped. Also converts the one other skip-as-pass in this file: a P-384 certificate that fails to parse reported "SKIP: may not be supported yet" and returned (0, 0), which would have hidden a P-384 DER regression. It is now a failure. P-384 cert *parsing* works today (group 2 passes 2/2 — measured, see below); only P-384 ECDSA *verify* is stubbed at the TLS layer. Acceptance (VICE, ip65 build, -reu via default_vice_config): ecdsa_verify absent from the labels view before: "[+] X.509/ECDSA: ALL 7 TESTS PASSED" EXIT_CODE=0 after: "[-] X.509/ECDSA: ABORTED -- declared test group(s) cannot run: ECDSA P-256 verify (group 3) [missing: ecdsa_verify]" EXIT_CODE=1 labels intact after: "[+] X.509/ECDSA: ALL 11 TESTS PASSED" EXIT_CODE=0 The denominator moving 7 -> 11 is the fix working: the ECDSA group now contributes its 4 assertions instead of evaporating. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The oracle ran three CAVP vectors, all Result=P, all expecting C=0. That cannot distinguish a working verifier from one that answers "valid" unconditionally — against `ecdsa_verify` stubbed to `clc; rts` it reported 3/3 and exited 0. An oracle with no negative case does not test verification, it tests that the routine returns. Adds three genuine CAVP SigVer P-256/SHA-256 `Result = F` records, one per modification class: [3h] F (3 - S changed) [3i] F (2 - R changed) [3j] F (1 - Message changed) Every `Result = F` record in the file has Q on the curve and r, s in [1, n-1], so none of these can be rejected by a cheap range or point-validity gate — each forces the full verify math and the recovered-R.x comparison. Provenance, established before adding anything: all 15 records in libs/nistcurves/tools/vectors/nist_p256_sigver.rsp were checked host-side against OpenSSL (via `cryptography`) plus independent raw-int on-curve and range checks. Zero disagreements with the .rsp Result column. After transcription, each of the six vectors now in the file was matched byte-for-byte back to a .rsp record and re-verified through OpenSSL *as transcribed here* (prehashed), so a typo could not slip through. No vector was produced by running this implementation. Mechanically: each vector carries `expect_carry` (mirroring the .rsp Result column, never an observed C64 result); the runner compares against it and says plainly when a NIST-invalid signature was accepted. Two structural guards keep F7 from regressing — the run aborts if the vector set ever contains zero negatives, and it fails if any declared vector does not produce a verdict. Acceptance (VICE, ip65 build, -reu via default_vice_config): pristine "[+] ECDSA KAT oracle: ALL 6 VECTORS PASSED (3 valid accepted, 3 invalid rejected)" EXIT_CODE=0 mutant: `clc; rts` at the top of ecdsa_verify (always VALID) verified present in the built binary, not just in the source — `ecdsa_verify` resolved from build/labels.txt to $6B23, mapped through the PRG load address $0801 to file offset 25378, bytes there read `18 60 ...` = CLC, RTS " Passed: 3/6 / Failed: 3/6" "[-] ECDSA KAT oracle: 3 VECTOR(S) FAILED" EXIT_CODE=1 The mutant's three passes are exactly the three vectors this file used to contain — i.e. the old oracle would have reported a clean 3/3 against a verifier that validates everything. The mutation was reverted before commit and the rebuilt PRG hashes identical to the pristine build (db31111031e2f30c52c9116576d74f21e2cc6ddee025041a881345efa7ba8f60). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JC-000
commented
Aug 13, 2026
Independently verified by the supervising session. F7 vectors — the thing most worth double-checking. I re-derived all six against OpenSSL from the branch's own Genuine CAVP records, correctly transcribed. (My first pass reported three disagreements — that was my harness reading a F3 preflight, reproduced by deleting the Failing in a preflight before VICE launches is better than failing during the run — it costs nothing and the message names exactly what is missing. Two design calls worth endorsing explicitly. Keeping the On urgency, tempered by Lane F: the nistcurves v0.8.0 bump would NOT have fired the rename scenario — bare
Confirms audit findings F3 and F7. |
…cond door) `tools/run_all_tests.py --skip-slow` omitted "x509" from the suite list by never adding it, so the aggregate printed a TOTAL and exited 0 with no trace that the whole X.509/ECDSA suite had not run. That is F3's shape one level up: the skipped assertions left the denominator instead of being accounted for, and the dropped suite is precisely the one F3 is about. The numbers make the failure mode concrete. A full aggregate run reports TOTAL: 264/264 with `x509 11/11`; `--skip-slow` reported TOTAL: 253/253. Same "everything passed" shape, 11 assertions lighter, and nothing in the output says which 11 or why. Skipped suites are now recorded as (name, reason) and surfaced three ways: named on the TOTAL line, given their own SKIP row in the per-suite table, and followed by an explicit warning that the aggregate does not certify them. Exit code stays 0. An operator passing --skip-slow made a deliberate choice, unlike F3's missing label, which is an unrequested environmental failure. The rule the two cases share is the one that matters: an involuntary skip is a failure, an explicit skip is allowed but must never be silent. Same convention Lane G adopted for test_x25519.py's --fast gate in PR #81. Acceptance (`--skip-slow --workers 4`, real aggregate runs): before TOTAL: 253/253 passed, 0 failed (no x509 row, no mention anywhere in the output) EXIT_CODE=0 after TOTAL: 253/253 passed, 0 failed -- 1 suite(s) SKIPPED: x509 ... SKIP x509 --- did not run: --skip-slow (X.509 DER parsing + ECDSA P-256 verify) WARNING: the suite(s) above did not run. This aggregate result does not certify them, and their assertions are absent from the TOTAL. EXIT_CODE=0 Reported by Lane G while reviewing the F3 fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JC-000
commented
Aug 13, 2026
Cross-PR note before review — the totals cited in this PR body go stale once #81 lands. #81 adds two x25519 assertions (the RFC 7748 vectors it un-gates), so on the merged tree:
Behaviour is identical and the demonstration still holds — but this PR's argument asks the reader to notice an 11-assertion gap between two totals, and a reviewer running the merged tree would see neither figure. Worth updating the body so the numbers match what the reviewer actually gets, rather than leaving them to doubt the demonstration instead of the arithmetic. A slightly ironic failure mode for a PR about misleading output. Verified on a merged probe: So the |
JC-000
commented
Aug 13, 2026
The withdrawn "byte-identical to a rebuild" claim is now backed by evidence — settled by the supervising session using the rebuild Lane G completed. Withdrawing it was right: the rebuild path fails in a fresh worktree without the So the claim was true, and is now checkable. The blob in circulation is reproducible from the pinned submodule — worth recording because the alternative would have been materially worse than a documentation bug: an ip65 artifact nobody could regenerate from the pin, which would have put every ip65 result in this audit in question. The body can stay as it is — stating only what was verified is never wrong — but if you would rather restore the claim, the sha above is the evidence for it. |
… the zp_config rebuild Three findings from the contract-side review of #122. All three verified against the v0.11.2 source before acting. 1. SHARED_CONSUMES IS NOT PROFILE-INDEPENDENT (comment was wrong) The §8.0 block claimed CONSUMES = $0007 "PROFILE-INDEPENDENT". Measured with od65 on the staged archives: archive PRIMITIVES CONSUMES lib-p256-verify (REU) $0000 $0007 lib-p256-verify-onchip $0000 $0005 lib-p256-comb-onchip $0000 $0005 Only PRIMITIVES is profile-independent. Under FP_ONCHIP_MUL the manifest zeroes the reu_mul bit in BOTH masks because that build genuinely does not read the primitive, and upstream hard-asserts it. That is §8.0's three-state table working: a deferral switch drops a bit from ownership only; a profile gate drops it from ownership AND consumption. No assert change needed — all three pass either way, since $0005 & ~$0007 = 0. The comment now carries the table so the next person to od65 an onchip archive does not "discover" a contradiction. 2. THE COVERAGE ASSERT CANNOT FIRE TODAY (scope was oversold) APP_OWNED is $0007, covering every §8.0 bit allocated so far, and CONSUMES is drawn from those same bits — so CONSUMES & ~(APP_OWNED | PRIMITIVES) is identically zero. The comment sold it as catching "a deferral requested but not honoured"; it cannot. That case is covered by the PRIMITIVES = 0 assert (defines missing from the manifest TU) or by a duplicate-external link error (defines missing from the code TUs), and the poly_prod rendezvous is caught by neither — only by the comb/onchip KAT. Kept, because it is the clause's canonical form and arms itself when a fourth primitive is allocated. 3. THE ZP_CONFIG REBUILD WAS THE SURVIVING TAIL OF #119 "No member set is edited" was true, but the wrapper still rebuilt zp_config*.o out-of-band and re-archived — passing only the ZP overrides and NOT CONTRACT_DEFINES. Since zp_config.s gates its bare exports on `.ifndef LIB_NO_BARE_EXPORTS`, that one member re-exported the bare `zp_*` names every other member was built to suppress. One archive, two configurations (SPEC §6.2) — and the resurrected names are exactly the #83 ZP collision family the gate exists to prevent. Dormant with one contract library in the link; live the day a second joins. Fixed by the sanctioned route: the three overrides move to CONTRACT_ZP_DEFINES (upstream since nist#104, covered by v0.11.2's staleness stamp). That retires the rebuild, the member-name discovery case block, the staging directory AND the re-archive step. The archive we link is now byte-for-byte the one upstream's make produced, which is what makes §6.1's "no copying intermediates around" true by construction. The od65 post-check is kept and strengthened: it verifies the three slots from the emitted object (canonical spellings only — the bare aliases vanish under the gate, and a guard that can go vacuous is worse than none) and adds a new check that the bare zp_ptr2 is NOT re-exported. It reads od65's hex field directly rather than the parenthesised decimal. EVIDENCE All five PRGs byte-identical to the pre-fix build (7a02e213d014, b574b344cdca, 6acfa0ef9cfd, d59254702819, dcde8eb8f150) — the bare export was unreferenced, so removing it changes no shipped byte. The comb and onchip KATs already run at exactly those hashes (6/6 each), so their evidence carries; re-running identical bytes would prove nothing new. make package-verify: 31/31 logic, 11/11 artifact, RELEASE ARTIFACTS VERIFIED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Audit remediation, Lane C: F3 (a suite that reports success while running nothing) and F7 (a KAT oracle with no negative vector). Both VICE-only, no hardware. All runs use
default_vice_config()(mandatory-reu).Three commits: one per finding, plus a third for a second instance of F3 found in
run_all_tests.pyduring review (see "F3, second door").F3 — a missing label deleted a whole test group, and the suite still said "ALL 7 TESTS PASSED"
The defect, in one sentence: dropping the single symbol
ecdsa_verifyfrombuild/labels.txtmadetools/test_x509.pyskip its entire ECDSA group, reportALL 7 TESTS PASSED, and exit 0 — the skipped assertions left the denominator instead of counting against it.The realistic trigger is a
libs/nistcurvesbump renaming an export, which is exactly the change this suite exists to catch, and it is live risk now: the siblings are two minor versions ahead of the pin and a bump is being planned. While a skipped group can report success, every other guarantee in the suite is conditional on symbols a dependency bump can rename.The fix
Every group the suite claims to run is declared in
REQUIRED_LABEL_SETSand is required:main()fails closed in a preflight, before launching VICE — exit 1, and the summary line names the group and its missing labels.run_tests()counts each un-runnable group as a failure and names it. This matters independently of the preflight:tools/run_all_tests.pyimportsrun_testsdirectly and only ever sees(passed, failed), so the return arity is deliberately unchanged and a vanished group now turns that runner red too.CV_LABELSis deliberately not required — no group drives it, and a group that does not exist cannot be silently skipped.Also fixed the one other skip-as-pass in the same file: a P-384 certificate that failed to parse reported
SKIP: may not be supported yetand returned(0, 0), which would have hidden a P-384 DER regression completely. Now a failure. P-384 certificate parsing works today (group 2 measured green, 2/2); only P-384 ECDSA verify is stubbed at the TLS layer.Acceptance — both directions, verbatim
Reproduced with the real script against a real VICE session, using a
build/labels.txtwith exactly the two.ecdsa_verifylines removed (ecdsa_verify_256/_384deliberately left intact — a rename of one export, not a demolition).Direction A —
ecdsa_verifyabsent from the labels view.Before (baseline
f0127a0, reproducing the audit finding):After:
Direction B — labels intact.
The denominator moving 7 → 11 is the fix working: the ECDSA group now contributes its 4 assertions instead of evaporating.
The audit's ready-made probe at
hi/e4_silent_skip.py(which callsrun_tests()directly with a labels view that drops the symbol, rather than editing a file) was reused as a second regression check against therun_tests()backstop path.F7 — the KAT oracle had no negative vector, so it could not fail
The defect, in one sentence:
tools/test_ecdsa_kat_oracle.pyran three CAVP vectors, allResult = P, all expectingC=0— against a verify forced to always report valid it reported 3/3 and exited 0.An oracle with no negative case does not test verification; it tests that the routine returns.
The fix
Three genuine CAVP SigVer P-256/SHA-256
Result = Frecords, one per modification class:[3h]F (3 - S changed)[3i]F (2 - R changed)[3j]F (1 - Message changed)Every
Result = Frecord in the file has Q on the curve and r, s in [1, n-1], so none of these is rejectable by a cheap range or point-validity gate — each one forces the full verify math and the recovered-R.xcomparison. (Confirmed by raw-int arithmetic, not by asking a library.)Each vector now carries
expect_carry, mirroring the.rspResult column — never an observed C64 result. Two structural guards keep F7 from regressing: the run aborts if the vector set ever contains zero negatives, and fails if any declared vector does not produce a verdict.Vector provenance — verified before adding, and again after transcription
A wrong vector, or one generated by running this implementation, would be a serious own goal. So:
libs/nistcurves/tools/vectors/nist_p256_sigver.rspwere checked host-side against OpenSSL (viacryptography48.0.0), plus independent raw-int on-curve and range checks. Zero disagreements with the.rspResult column. This also re-confirmed the extraction convention: the 3 positives already in the file are records [3], [4], [14] and their hardcoded hashes equalSHA-256(Msg)exactly..rsprecord, itsexpect_carrychecked against the Result column, and re-verified through OpenSSL as transcribed in the oracle (prehashed) — so a hand-transcription typo could not slip past. 0 problems.No vector was produced by running this implementation.
Acceptance — both directions, verbatim
Pristine:
Mutant —
clc; rtsat the top ofecdsa_verify(always report VALID, execute no crypto).Per protocol, the mutant was proven present in the built binary, not merely in the source.
ecdsa_verifywas resolved from the builtbuild/labels.txtto$6B23, mapped through the PRG load address$0801to file offset 25378, and the bytes actually there decoded:with, per failing vector:
The mutant's three passes are exactly the three vectors this file used to contain — the old oracle would have reported a clean 3/3 against a verifier that validates everything. That is F7 demonstrated directly rather than argued.
The mutation was reverted before commit; the rebuilt PRG hashes identical to the pristine build (
db31111031e2f30c52c9116576d74f21e2cc6ddee025041a881345efa7ba8f60). No mutation is in this branch.F3, second door —
--skip-slowsilently dropped the entire x509 suiteFound by Lane G while reviewing the F3 fix, and squarely the same defect one level up:
tools/run_all_tests.py --skip-slowomittedx509from the suite list by never adding it, so the aggregate printed a TOTAL and exited 0 with no trace that the whole X.509/ECDSA suite had not run.The numbers make it concrete. A full aggregate reports
TOTAL: 264/264withx509 11/11;--skip-slowreportedTOTAL: 253/253— the same "everything passed" shape, 11 assertions lighter, and nothing saying which 11 or why. Given F3 is precisely about the x509 ECDSA group reporting success while running nothing, this was a second door into the same room.Skipped suites are now recorded as
(name, reason)and surfaced three ways: named on the TOTAL line, given their own SKIP row, and followed by an explicit warning.Acceptance (
--skip-slow --workers 4, real aggregate runs both ways):Before:
(no x509 row, no mention anywhere)
After:
Exit code deliberately stays 0. An operator passing
--skip-slowmade a choice; F3's missing label is an unrequested environmental failure. The rule the two cases share is the one that matters, and it is the rule this PR is really about:This matches the convention Lane G adopted for
test_x25519.py's--fastgate in #81, so the two suites agree on the principle rather than diverging on the exit code.Blast radius
Nothing turned red. Both fixed suites pass at HEAD (11/11 and 6/6), and the aggregate is unchanged at 253/253 with
--skip-slow/ 264/264 without (266/255 once #81 lands — see the note above).run_tests()intest_x509.pykeeps its 2-tuple arity deliberately, so thetools/run_all_tests.py:86importer is unaffected. #81 changestest_x25519.run_teststo a 3-tuple and updates its own importer in the same file; the two PRs touch different hunks ofrun_all_tests.py(suite-list + summary block here, x25519 import branch there). Lane G has verified this rather than assumed it:git merge --no-commit --no-ffis clean, and the merged tree was then run both ways —TOTAL: 266/266full andTOTAL: 255/255 passed, 0 failed -- 1 suite(s) SKIPPED: x509with the flag, i.e. both lanes' changes present in a single verdict. Textual auto-merge and composing behaviour are separate claims; both were checked.The only counter changes anywhere are denominators growing — 7→11 in
test_x509.py, 3→6 in the KAT oracle — which is the intended effect: assertions that used to evaporate now show up in the denominator and get run.The P-384-parse skip→fail conversion is the one place I converted a currently-passing skip into a potential failure. It is green today (group 2, 2/2, observed in three separate runs).
Audit of the other
tools/test_*.pyfor the same shapeThe
check_label*(...) → print SKIP → return 0, 0idiom is duplicated across the suite; 26return 0, 0skip sites total. Findings, with what I did about each:test_x509.pytest_ecdsa_kat_oracle.pymain()fatal + the new total-verdict check)test_tls_handshake.pytest_tls_record.pytest_tls_p384_negotiation.pytest_http.pylen(response) > 256on a fixed ~160-byte response; dead branchThese skips are dormant, not live: every label they guard resolves against the current build. That is precisely the F3 risk profile — one upstream rename away from silently deleting a group while the suite still reports success.
Two findings worth acting on separately
1. A second site of Lane A's dead-register-read defect (routed as F10). The coordinator asked me to check whether my files read the processor flags via a
"P"key. They do not, and are immune by construction:test_x509.pyandtest_ecdsa_kat_oracle.pycapture carry with a 6502 trampoline (LDA #$00 / ROL A / STA $0352) — the CPU materialises the carry into memory and the host reads it withread_bytes. No register dict is consulted anywhere in either file, and there is no weaker fallback:jsr_with_carryeither returns a real 0/1 or raises, and every caller counts the exception as FAIL.But
tools/test_tls_handshake.py:625-627has the identical defect:jsr()returnsread_registers(), whose keys come from VICE's monitor register-name table where the status register isFL(c64-test-harnessbackends/vice_binary.py:1325resolvesFL/FLAGS/SR)."P"is never a key, socarryis pinned at 0 and the "parser returned C=1" branch is unreachable.The interaction is the part that matters: latent behind that branch, at
:654-657, is an F3-shaped silent skip (return 0, 0when the routine is aclc; rtsstub). It is unreachable only because the"P"bug pins carry to 0 — so fixing"P"→FLalone would trade a dead assertion for a vanished test group. The two must land together. Filed and routed to Lane A as F10 on a separate branch; not touched here, to keep the ownership boundary clean.2. An entire suite that reports success without running, on the primary dev platform.
tools/test_http_integration.py:91gates on/sys/class/net/tap-c64— a Linux sysfs path — and thensys.exit(0). Measured on macOS, which is this project's documented dev/CI platform:A whole integration suite, permanently green in under a second. It looks superseded by the macOS feth rig (
tools/rig-up-macos.sh), so the right fix is probably deletion or a platform-appropriate rig rather than a mechanical patch — listed as follow-up, not fixed here, since that is a judgement call about the suite's future rather than a small change.3. Cross-referenced instance from Lane G (PR #81), same class, different mechanism.
tools/test_x25519.pygates its only two end-to-endx25519_scalarmultvectors behind--slow; the default run printsRESULTS: 71/71 passed, 0/71 failedand exits 0, with the summary never mentioning the skip — all 71 assertions are field arithmetic. Vector 2 (U_2ending0x93, bit 255 set) is exactly the vector that catches the upstream c64-x25519 #64 MSB bug present in the pinned v0.6.0 — so the skipped test was the regression gate for the bump this audit is meant to de-risk. Lane G then timed it properly: 37.9 s with both vectors vs 4.8 s without, ~16.5 s per vector — the in-file "~100 min each" comment is off by a factor of ~360. With no real cost to trade against, they made the vectors run by default (--fastto skip) and named the skip in the verdict. Fixed in #81, not here, to avoid two PRs editing one file; noted because it shows the shape recurs across suites rather than being atest_x509quirk.Reproducing
A fresh worktree needs three steps, not two:
Skip the middle step and
makedies withld65: Error: Input file '../ip65/ip65/ip65_tcp.lib' not found. Theip65-build/ip65-c64.binblob is gitignored (.gitignore:6), so a fresh tree has none and must build one; the submodule ships ip65 sources, andmake ip65-libsis what compiles them into the.libarchives the blob link consumes.The ip65 artifact chain is reproducible from source — verified, not assumed. The blob circulating in existing clones (built 2026-05-06) and a fresh from-source rebuild against the pinned submodule
25a9c5aaare byte-identical:Lane G reproduced the same hash independently in a separate worktree, so that is three agreeing builds. And the PRG built through the fully-from-source chain hashes
db31111031e2f30c52c9116576d74f21e2cc6ddee025041a881345efa7ba8f60at 47,105 B — identical to the binary every acceptance run in this PR was produced against, and matching the size recorded for the post-#68 refit.Two
CLAUDE.mdBuild-section errors surfaced while establishing the above. Routed to Lane E (their F8/F9 territory) rather than fixed here, with Lane G supplying the full reproduction::29calls this "the committed blob" — it is gitignored and untracked (git ls-files ip65-build/returns onlyip65.cfgandip65_stub.s).:27saysmake ip65-libsis "only needed if the ip65 submodule changes" — it is needed on every fresh clone.Individually wrong; together worse, because they reinforce. One says no rebuild is needed, the other says the build step is conditional, and a fresh-clone reader following both is steered exactly past the only step that works. Two lanes rediscovered this by tripping over it. It is the same shape as F8, and a good example of the general point: this is an assertion about
make's behaviour, stored somewheremakecannot reach.🤖 Generated with Claude Code