Skip to content

fix(tests): generate the gitignored test certs on demand (#93) - #95

Merged
JC-000 merged 2 commits into
masterfrom
fix/autogen-test-certs-impl
Aug 14, 2026
Merged

fix(tests): generate the gitignored test certs on demand (#93)#95
JC-000 merged 2 commits into
masterfrom
fix/autogen-test-certs-impl

Conversation

@JC-000

@JC-000JC-000 commented Aug 14, 2026

Copy link
Copy Markdown
Owner

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, so
python3 tools/uci/test_https_local.py fails 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.py really 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 own
CERT_PATH / KEY_PATH constants, so it never crossed that path — it just
printed ERROR: cert/key not found and exited 2. The repo already had two
generators, 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 cryptography dependency from the packaged listener by
reimplementing cert generation in pure Python. This PR had extended the old
cryptography-based generator with a curve= parameter, so the two changes
collided 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 / _mul take (p, a), defaulting to the P-256 constants, so every
    existing call site is unchanged.
  • _ecdsa_sign takes the curve and uses its n and generator; the hash comes
    from the profile (SHA-256 / SHA-384).
  • A _Curve record holds what differs — parameters, coordinate width, curve
    and signature OIDs, hash, filenames. Two entries in CURVES.
  • P-384 constants are from SEC 2 / FIPS 186-4; the DER additions are
    secp384r1 (1.3.132.0.34) and ecdsa-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 cryptography for nothing but
minting 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: curve defaults to p256,
generate()'s existing keyword call in listener.py still resolves, and the
selftest 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; --curve on the CLI. Pure Python throughout.
  • ensure_certs.py: the single in-tree entry point (ensure_certs,
    cert_paths), covering both profiles and delegating to gen_certs so in-tree
    tests and the packaged listener mint identical material. Failures raise
    SystemExit with one actionable line, never a traceback. The
    missing-cryptography message is gone with the dependency; what remains is a
    missing/broken generator file, which the message now names.
  • https_listener.py: _ensure_certs* become thin delegates. 105 lines of
    duplicated cert building go, along with a deprecated datetime.utcnow().
  • 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"; its two exit-2
    existence 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 actually
    happens, opening with "this directory is empty in a fresh clone, that's fine".
  • Adjacent gap: 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.

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 — cryptography verifies a signature it did not produce.

Checkp384p256
Generated from an empty certs dirPASSPASS
Curve / sig hash / key widthsecp384r1, sha384, 48 Bsecp256r1, sha256, 32 B
CN www.foo.bar, SAN foo.bar + www.foo.barPASSPASS
Self-signature verifies (cryptography oracle)PASSPASS
openssl verifyPASSPASS
Private key matches the cert's public key (openssl)PASSPASS
TLS 1.3 handshake + hostname verification as www.foo.barPASSPASS
Idempotent (second call regenerates nothing)PASSPASS

Plus:

CheckResult
Listener selftest — via SKIP_REBUILD=1 SKIP_VICE=1 make package-verify, and directlySELFTEST PASSED (4 checks): cert minted stdlib-only, then stdlib client and the C64's TLS_CHACHA20_POLY1305_SHA256 suite each get the canonical body
Empty certs dir -> test_https_local.pymints the pair, loads it into the inline listener, runs on to the device stage (fails only on a deliberately fake U64_HOST=127.0.0.1)
Second runsilent, regenerates nothing
EXTERNAL_LISTENER=1, empty certs dirno generation, no files: "no inline server, no repo certs"
https_listener p256 + p384 from empty dirgenerate, serve, GET 200
Generator unimportable (blocked at sys.meta_path)cannot generate test certs: No module named 'gen_certs' (expected the stdlib-only generator at ...), rc=2, no traceback
gen_certs.py standalonep256, p384, idempotent re-run; --curve p521 rejected by argparse
make BACKEND=uci at the v0.9.1 pinlinks, 62,977 B, 66e37037deb9b295…

make package does not run to completion at current master, for a reason
that is not in this diff: both onchip variants fail to link with

ld65: Error: src/lib_contract_asserts.s(166): libs/nistcurves
shared-primitive ownership claim moved — re-derive the archive member
drops in tools/integration/build_nistcurves_p256.sh

so the D64s, the listener bundle and MANIFEST.txt are never produced. The REU
variants build (uci 62,977 B, ip65 47,105 B). This branch touches no .s,
.cfg, Makefile or submodule — only Python and docs — so it cannot be the
cause; 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 — the
assert's suggested remedy is a red herring).

The selftest line in the table above was reached through package-verify only
because my dist/ happened to contain a listener bundle I had built by hand
earlier; the aborted make package never 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:

dist / flagsresult
bundle present, SKIP_REBUILDSKIP_VICE2/2 VERIFIED, exit 0 — my run
bundle absent, same flags0/1, exit 1 (red, on the bundle assertion)
bundle absent, + SKIP_LISTENER0/0VERIFIED, exit 0

The 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, with result=FAILED records —
what I had actually done was run package-verify before ever running
make package, and I wrote that stale error up as the current symptom.)

The one command that unblocks an existing clone right now:

python3 tools/https_e2e/ensure_certs.py

🤖 Generated with Claude Code

JC-000and others added 2 commits August 14, 2026 09:43
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>
@JC-000
JC-000force-pushed the fix/autogen-test-certs-impl branch from f9b2cab to 07d668cCompareAugust 14, 2026 14:50
@JC-000

Copy link
Copy Markdown
OwnerAuthor

Rebased onto master after #96 and reconciled in gen_certs.py. Took option
(1): pure Python 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/_mul take (p, a) defaulting to the P-256 constants, _ecdsa_sign
takes the curve, and a small _Curve record carries what differs (parameters,
coordinate width, curve + signature OIDs, hash, filenames). About 40 lines.

That leaves the dependency at zero on both paths, not just the packaged one:
the in-tree P-384 tests needed cryptography for nothing but minting a cert on
a different curve. Option (2) 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's behaviour is unchanged — curve defaults to p256 and
listener.py's existing keyword call resolves as before. --selftest passes
(4 checks, including the openssl s_client -ciphersuites TLS_CHACHA20_POLY1305_SHA256 leg).

The risk you flagged — the P-384 path calling APIs the rewrite no longer
imports — is covered by two oracles independent of the code under test: the
cryptography package verifying a self-signature it did not produce, and
openssl verify. Both pass on both profiles, along with a real TLS 1.3
handshake with hostname verification. Full table in the PR description; P-384
leads it.

One thing found while measuring, and not from this diff: at current master
both onchip variants fail to link —

ld65: Error: src/lib_contract_asserts.s(166): libs/nistcurves
shared-primitive ownership claim moved — re-derive the archive member
drops in tools/integration/build_nistcurves_p256.sh

so make package aborts before writing dist/build-info.txt and
make package-verify refuses to start. The REU variants build fine (uci
62,977 B, ip65 47,105 B). This branch changes no .s, .cfg, Makefile or
submodule, so it cannot be the cause — flagging it for the packaging lane.

@JC-000
JC-000 merged commit d2e82a0 into masterAug 14, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Certificate missing and is not created when running tools/uci/test_https_local.py

1 participant

@JC-000