fix(tests): generate the gitignored test certs on demand (#93) - #95
Conversation
Starting point for #93. Delegates to tools/package/listener/gen_certs.py rather than duplicating it, so packaged-listener and in-tree tests produce identical material. Not yet wired into any caller. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tools/https_e2e/certs/ is empty in a fresh clone by design — the certs are throwaway self-signed test material and .gitignore tracks only the README. tools/uci/test_https_local.py then died with ERROR: cert/key not found at .../server.pem / .../server.key naming no fix, and the certs README claimed the listener generates them on demand. That claim was half true, which is why it survived: https_listener.py really does generate both profiles. But test_https_local.py inlines its own listener with its own CERT_PATH/KEY_PATH and never crossed that path. So the repo had two generators (https_listener's inline pair and tools/package/listener/gen_certs.py) and the one script an outside contributor is most likely to run reached neither. Consolidated rather than adding a third: - gen_certs.generate() takes curve= (p256 default, p384), which also selects filenames and signature hash; --curve on the CLI. The packaged listener's behaviour is unchanged. - ensure_certs.py is the single in-tree entry point (ensure_certs / cert_paths), delegating to gen_certs so in-tree tests and the packaged listener mint identical material. Failures raise SystemExit with one actionable line — a missing `cryptography` says how to install it — never a traceback. - https_listener._ensure_certs* are now thin delegates; 105 lines of duplicated cert building (and a deprecated datetime.utcnow()) go. - test_https_local.py generates on demand from main(), gated on not EXTERNAL_LISTENER — that mode serves TLS out of band and by contract loads no repo cert, so it must not mint one it will never use. test_https_local_p384.py sets CERT_PROFILE="p384" and drops its two exit-2 existence checks. - certs/README rewritten to describe what actually happens, starting with "this directory is empty in a fresh clone, that is fine". Adjacent gap found while measuring: "ERROR: PRG not found" fires before the cert check in the same script and also named no fix. boot_check.py and test_https_bad_finished.py already pointed at `make BACKEND=uci`; the other six tools/uci/ scripts now do too. Measured (no hardware needed — the cert path is well before any device call). From an empty certs dir: test_https_local.py mints the pair, loads it into the inline listener, and runs on to the device stage; a second run regenerates nothing; EXTERNAL_LISTENER=1 mints nothing. Both profiles load into a real ssl context and complete a TLS 1.3 handshake with hostname verification as www.foo.bar; curve, signature hash, CN and SAN asserted per profile. With cryptography blocked at sys.meta_path the helper prints its one line and returns 2. build_listener_zip.sh still builds; make BACKEND=uci links at 62,977 B. Closes#93. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
f9b2cab to
07d668cCompareJC-000
commented
Aug 14, 2026
Rebased onto master after #96 and reconciled in P-384 is the same algorithm over different numbers, so #96's implementation That leaves the dependency at zero on both paths, not just the packaged one: The packaged listener's behaviour is unchanged — The risk you flagged — the P-384 path calling APIs the rewrite no longer One thing found while measuring, and not from this diff: at current master so |
Closes#93 (reported by @Armitage64).
Rebased onto master after #96. See "Reconciliation with #96" below for the
design decision that rebase forced — it changed the shape of the fix.
The report
tools/https_e2e/certs/is empty in a fresh clone, sopython3 tools/uci/test_https_local.pyfails on a missing certificate —and the certs README already claimed the listener generates them on demand.
What was actually going on
The README's claim was half true, which is probably why it survived:
tools/https_e2e/https_listener.pyreally does generate both cert profiles(
_ensure_certs_p256/_ensure_certs_p384), and everything built on it(
evil_listener.py,test_https_bad_finished.py,tests/test_vice_https_macos.py)has always worked from an empty directory.
But
tools/uci/test_https_local.pyinlines its own listener with its ownCERT_PATH/KEY_PATHconstants, so it never crossed that path — it justprinted
ERROR: cert/key not foundand exited 2. The repo already had twogenerators, and the one script an outside contributor is most likely to run
reached neither.
Certs stay gitignored — they are throwaway self-signed test material and
committing them would be wrong.
Reconciliation with #96 — option (1), pure Python for both curves
#96 removed the
cryptographydependency from the packaged listener byreimplementing cert generation in pure Python. This PR had extended the old
cryptography-based generator with acurve=parameter, so the two changescollided in
gen_certs.py.Taken: pure-Python generation for both curves. P-384 is the same algorithm
over different numbers, so #96's implementation extends to it by parameterising
the arithmetic rather than duplicating it:
_add/_multake(p, a), defaulting to the P-256 constants, so everyexisting call site is unchanged.
_ecdsa_signtakes the curve and uses itsnand generator; the hash comesfrom the profile (SHA-256 / SHA-384).
_Curverecord holds what differs — parameters, coordinate width, curveand signature OIDs, hash, filenames. Two entries in
CURVES.secp384r1(1.3.132.0.34) andecdsa-with-SHA384(1.2.840.10045.4.3.3).Net effect: the dependency is now zero on both paths, not just the packaged
one. The in-tree P-384 tests previously needed
cryptographyfor nothing butminting a cert on a different curve, and that reason is gone. Option (2)'s split
would have reintroduced the package on the test path for ~30 lines of
arithmetic, and left two generators to keep in step.
The packaged listener is untouched in behaviour:
curvedefaults top256,generate()'s existing keyword call inlistener.pystill resolves, and theselftest passes (below). Each profile pairs its curve with the equal-width hash,
so FIPS 186-4's leftmost-bits truncation is a no-op; that is stated in
_ecdsa_sign's docstring for whoever pairs them differently one day.Changes
gen_certs.py:curve=(p256 default, p384) selecting parameters, OIDs,hash and filenames;
--curveon the CLI. Pure Python throughout.ensure_certs.py: the single in-tree entry point (ensure_certs,cert_paths), covering both profiles and delegating togen_certsso in-treetests and the packaged listener mint identical material. Failures raise
SystemExitwith one actionable line, never a traceback. Themissing-
cryptographymessage is gone with the dependency; what remains is amissing/broken generator file, which the message now names.
https_listener.py:_ensure_certs*become thin delegates. 105 lines ofduplicated cert building go, along with a deprecated
datetime.utcnow().test_https_local.py: generates on demand frommain(), gated onnot EXTERNAL_LISTENER— that mode serves TLS out of band and by contractloads no repo cert, so it must not mint one it will never use.
test_https_local_p384.py: setsCERT_PROFILE = "p384"; its two exit-2existence checks are gone. Its real blocker (the broken P-384 build) is
untouched, but it is no longer also blocked on certs.
tools/https_e2e/certs/README: rewritten to describe what actuallyhappens, opening with "this directory is empty in a fresh clone, that's fine".
ERROR: PRG not foundfires before the cert check in thesame script and also named no fix.
boot_check.pyandtest_https_bad_finished.pyalready pointed atmake BACKEND=uci; the othersix
tools/uci/scripts now do too.Measurements (all re-run after the rebase)
P-384 leads, since it is the profile the pure-Python generator gained and so the
one most likely to be wrong. Both oracles below are independent of the code
under test —
cryptographyverifies a signature it did not produce.www.foo.bar, SANfoo.bar+www.foo.barcryptographyoracle)openssl verifyopenssl)www.foo.barPlus:
SKIP_REBUILD=1 SKIP_VICE=1 make package-verify, and directlyTLS_CHACHA20_POLY1305_SHA256suite each get the canonical bodytest_https_local.pyU64_HOST=127.0.0.1)EXTERNAL_LISTENER=1, empty certs dirhttps_listenerp256 + p384 from empty dirsys.meta_path)cannot generate test certs: No module named 'gen_certs' (expected the stdlib-only generator at ...), rc=2, no tracebackgen_certs.pystandalone--curve p521rejected by argparsemake BACKEND=uciat the v0.9.1 pin66e37037deb9b295…make packagedoes not run to completion at current master, for a reasonthat is not in this diff: both onchip variants fail to link with
so the D64s, the listener bundle and
MANIFEST.txtare never produced. The REUvariants build (uci 62,977 B, ip65 47,105 B). This branch touches no
.s,.cfg,Makefileor submodule — only Python and docs — so it cannot be thecause; reported to the packaging lane, who reproduced it byte-identically and
root-caused it to a profile-blind assert (the onchip archive legitimately
reports
$0005, not$0007, since FP_ONCHIP_MUL owns no REU multiply — theassert's suggested remedy is a red herring).
The selftest line in the table above was reached through
package-verifyonlybecause my
dist/happened to contain a listener bundle I had built by handearlier; the aborted
make packagenever produced one. Do not read that run's"2/2 checks passed / RELEASE ARTIFACTS VERIFIED" as a release gate. Measured
on this dist, with the build record present and no D64s:
SKIP_REBUILDSKIP_VICE2/2VERIFIED, exit 0 — my run0/1, exit 1 (red, on the bundle assertion)+ SKIP_LISTENER0/0VERIFIED, exit 0The last row is the defect the packaging lane is fixing in #98, and it is
sharper than "the gate passes vacuously": the disk checks glob, so an empty
dist produces no records at all, while the listener check asserts presence and
fails loudly. Whether a vacuous run reads green therefore depends on which style
the one surviving check happened to use — invisible until that check is skipped.
After #98 this invocation reports
PARTIAL VERIFICATION — Not a release gate.What stands on its own here is the selftest's own 4 checks, the part that
exercises
gen_certs.py, which also passes when run directly.(Two corrections to earlier revisions of this description, both caught by the
packaging lane:
build-info.txtis written, withresult=FAILEDrecords —what I had actually done was run
package-verifybefore ever runningmake package, and I wrote that stale error up as the current symptom.)The one command that unblocks an existing clone right now:
🤖 Generated with Claude Code