Skip to content

c64-https v0.6.0 — post-quantum hybrid key exchange

Latest

Choose a tag to compare

@JC-000 JC-000 released this 18 Oct 13:23
· 15 commits to master since this release
569274c

c64-https v0.6.0

For demonstration and educational purposes only. It is not cryptographically secure.

Built from the commit tagged v0.6.0. Submodule pins (tools/check_upstream_pins.py):

  • libs/mlkem v0.6.0 (new);
  • libs/nistcurves v0.15.0 (unchanged);
  • libs/x25519 v0.16.0 (unchanged);
  • ip65 25a9c5a (untagged, unchanged).

This release covers the merged PRs since v0.5.1: #303, #306-#308, #310, #311 (build, rig and docs fixes), #312-#315 (the post-quantum hybrid key exchange, below) and this release's packaging change. The main change: both UCI images now offer the hybrid key exchange X25519MLKEM768. They have completed fetches of the Wikipedia Commodore 64 article over it on hardware. ip65-onchip stays classic and is not byte-identical to v0.5.1 (see below).

No classic UCI image ships any more. uci-onchip and uci-comb are the same products as before with PQ_HYBRID=1 added to their make arguments. To build the v0.5.1-style X25519-only UCI image, omit it: make BACKEND=uci USE_NISTCURVES_ONCHIP=1 TRUST_STORE=1 (or USE_NISTCURVES_ONCHIP_COMB=1).


Assets

image for PRG sha256
c64-https-ip65-onchip stock C64 + RR-Net, no REU, no turbo e69bfce7b552e765df02cb253b4ff12cecaca3e978669376b7af5ffad9343dce
c64-https-uci-onchip U64E / C64 Ultimate, turbo, no REU 3b4dd718cbdc296f2083c90ce4d89a25e1fbd032e849c66f25227ef4d0ac4d5a
c64-https-uci-comb U64E / C64 Ultimate, turbo + REU (fastest) 41202de9fa6b9da769fce87a3223e7d1c74630f873b4b64a940afacddb7cbeb6
c64-https-uci-m3-demo U64E with the TLS-socket (ESP32) firmware, see below 916d41628a17b9436bbf58a71f7350c721a06e459cf5c33e5baeb1ad61af9a24 (carried forward from v0.5.1, unchanged)
disk sha256
c64-https-ip65-onchip.d64 30c5ba3974eed49a248e0041c1bf1e3e2e9584d772c710b086f50dc18a840e38
c64-https-uci-onchip.d64 70356cd1a7334c009ac37641d6cc7f4954550fa9d530e0ab913dda7a2b5d2302
c64-https-uci-comb.d64 293883c77a30364674425a17b3121a1adab08d21d4e2f7f8c72362e494916faa
c64-https-uci-m3-demo.d64 ae2cc5b7d40190ddfc911cb40e7a7d62e406c134f304b3fe9e2f04ea540ff498 (carried forward)

Each image ships as a .prg and as a bootable .d64. The full asset set is:

  • the three .prg and three .d64 above;
  • MANIFEST.txt, build-info.txt, c64-https-listener.py;
  • the M3 demo's c64-https-uci-m3-demo.prg, c64-https-uci-m3-demo.d64, c64-https-uci-m3-demo-README.txt and c64-https-uci-m3-demo-SHA256SUMS (v0.5.1's files, unchanged);
  • a top-level SHA256SUMS covering every other asset, generated by tools/package/write_release_sums.sh <assets-dir> (it refuses a missing file and verifies its own output).

The M3 demo's own checksum file names its README README.txt, the name inside the demo package; rename the asset to that before running shasum -c on it.

The 1541 filenames (ip65-noreu, uci-noreu, uci-comb) and the PRG sizes (47,105 B, 47,105 B, 51,201 B) are the same as v0.5.1.


Post-quantum hybrid key exchange

  • The ClientHello offers X25519MLKEM768 (0x11EC: ML-KEM-768 encapsulation key || X25519 share) first and X25519 (0x001D) second; the server picks. A 0x11EC answer feeds mlkem_ss || x25519_ss into the handshake secret (draft-ietf-tls-ecdhe-mlkem, ML-KEM first). A 0x001D answer completes with X25519 alone, as before.

  • A server that picks X25519 does not get the v0.5.1 client. KeyGen still runs on every connection; the ClientHello is about 1.2 KB larger and is sent in several sections; and the ServerHello is read by a stricter streaming reader (an empty session-id echo, exactly the two extensions we asked for, a malformed or oversize record header while waiting for the ServerHello is now fatal).

  • ML-KEM-768 comes from libs/mlkem (c64-mlkem v0.6.0). Its code is staged in the PRG at $A000 and copied to $E000 at boot; the polynomial area is $D000-$DFFF (RAM under I/O) and the decapsulation-key scratch is the first 2,400 B of the TCP ring. Intermediates are scrubbed after KeyGen and after Decaps, and both shared secrets are zeroed once the handshake secret exists. PQ_HYBRID is refused on ip65, uci-m3 and the uci REU profile.

  • Cost (U64E at 48 MHz, local OpenSSL 3.6.5 listener started with -groups X25519 for the classic image and -groups X25519MLKEM768 for the PQ image (the group differs per column and cannot be avoided: the classic client offers no hybrid group), release arguments plus HTTPS_SNI, time from the SYS trigger to client Finished, 0.5 s poll, n=3 each; the classic image is the same arguments without PQ_HYBRID):

    image classic (PQ_HYBRID off) hybrid difference
    uci-onchip 52.4, 52.1, 52.6 s (mean 52.4) 55.2, 54.8, 54.6 s (mean 54.9) +2.5 s (+4.8%)
    uci-comb 41.0, 41.3, 40.6 s (mean 41.0) 43.9, 43.1, 43.4 s (mean 43.5) +2.5 s (+6.1%)

    Whole Wikipedia fetch with the release PRGs, rig wall-clock: 191-192 s onchip, 220-222 s comb; the classic comb control took 218 s (n=1).

  • What it protects, and what it does not. The hybrid changes the key exchange only: a passive recorder who later has a quantum computer cannot recover a session's keys from the recorded handshake unless it can also break X25519 or ML-KEM-768 (harvest now, decrypt later). Authentication is unchanged and classical: the server proves itself with ECDSA P-256, and the UCI images' only pin is trust-on-first-use of that leaf key. And the protection is only as good as its secrets: this client's DRBG is seeded from SID voice 3 and CIA timer noise, which is weak, and the ML-KEM seed and X25519 private key both come from it. Read this as a demonstration of the protocol path, not as quantum safety.

  • Servers: OpenSSL 3.6.5 s_server and en.wikipedia.org negotiate 11EC with our hello (tables below); browserleaks.com did in an earlier live run. github.com and lwn.net do not offer 11EC (openssl s_client -groups X25519MLKEM768 gets handshake_failure); a uci-comb fetch of github.com/robots.txt falls back to X25519 and completes.

Other changes since v0.5.1

  • PQ stages, in order: the sectioned ClientHello with 16-bit lengths and HelloRetryRequest refusal (#312); libs/mlkem pinned and placed behind PQ_HYBRID=1, link-only (#313); the streaming hybrid ServerHello, KeyGen in tls_connect, Decaps (#314); the hybrid key schedule (#315).
  • The linker output no longer depends on the build locale (#311), a linker-config edit relinks the PRG (#306), a bare pytest is green on a uci or an ip65 build (#308), and the rig trampolines set the trust-store mode (#307). Docs: #303 (where the UCI images keep the trust store), #310 (M3 demo provenance).
  • tools/uci/rig_https_banner.py prints tls_kex_group; REQUIRE_KEX=EC makes any other group part of the exit decision (decide_exit).

ip65-onchip is not the v0.5.1 binary

c64-https-ip65-onchip.prg is e69bfce7…; v0.5.1's was 810f1158…. Rebuilt at each step: 1c377d34… after #311 (link order only), then e69bfce7… from #312 on, unchanged by #313-#315. #312 changed ip65 code: it added the HelloRetryRequest check to the ip65 ServerHello parser and changed the shared ClientHello builder. It does not offer the hybrid.

Validated in emulation, not on silicon. The hardware-free ip65 end-to-end (tests/rig_vice_https_macos.py, E2E_PROFILE=onchip, E2E_NO_WARP=1) PASSED on these bytes: ethernet-capable VICE 3.10 x64sc (the patched pcap build at ~/opt/vice-eth/bin/x64sc) on a feth/pcap link with the rig's dnsmasq, a host TLS 1.3 listener on port 24530, honest 1 MHz, 2,328 s wall (DHCP OK at +35 s; after G: TCP connected +3 s, CH +475 s, SH +931 s, ServerHello processed (PROC) +950 s, last flight record received +2,266 s, server Finished +2,269 s, request sent +2,287 s, connection closed +2,293 s), http_status=200, body TLS13 OK FROM C64 TEST (22 B, as expected). The image under test was built with the exact release arguments plus the rig's port: make BACKEND=ip65 USE_NISTCURVES_ONCHIP=1 TRUST_RELEASE=1 HTTPS_PORT=24530, sha256 9b1ef4b1582242ef0820a33499c60f1f6a69db803f45de6431451fe34a4c7463. It differs from the release PRG e69bfce7… in exactly 4 bytes, the two HTTPS_PORT words (443 = $01BB vs 24530 = $5FD2, each stored twice); nothing else. What this does NOT cover: real RR-Net/cs8900a silicon (the physical rig was not run), server name validation (the ip65 image has none and accepts any certificate whose signature verifies), and anything faster than 1 MHz.

M3 demo

Carried forward from v0.5.1 unchanged (PRG, .d64, README, checksum file). The M3 build does not link the 6510 TLS or crypto code, and building BACKEND=uci-m3 HTTPS_HOST=en.wikipedia.org HTTPS_PATH=/wiki/Commodore_64 at this release reproduces the v0.5.1 PRG byte for byte (916d4162…), so the hardware validation recorded for it still describes these bytes. It was not re-run for this release, and tools/package/build_m3_demo.sh was not used: it would rewrite the README around this commit and re-quote firmware provenance that nobody re-verified.


Validated

Built on macOS 27.0.1, GNU Make 3.81, ca65/ld65 V2.18, LANG=en_US.UTF-8; the PRG hashes are locale-independent since #311. make package-verify passed 14/14 with no SKIP (PRG reproducibility from a second clean build, load ranges off the I/O page, .d64 read-back byte-compare and VICE boot to the banner for all three images, listener selftest).

Hardware, exact release PRGs (U64E, fw 3.15 branch build, 48 MHz, rig_https_banner.py: menu path, G, typed host en.wikipedia.org, path /wiki/Commodore_64; completeness by the response's own framing; REQUIRE_KEX=EC makes any other group a failure). The device's real /USB1/TRUST.A|B were used as users run it; no key-change prompt appeared. Every run:

# image (PRG sha256 prefix) tls_kex_group HTTP body elapsed
1 uci-comb (41202de9) $EC 200 760,609 B == Content-Length, PASS 220 s
2 uci-comb (41202de9) $EC 200 760,609 B == Content-Length, PASS 222 s
3 uci-comb (41202de9) $EC 200 760,609 B == Content-Length, PASS 222 s
4 uci-onchip (3b4dd718), device REU enabled (16 MB) $EC 200 760,609 B == Content-Length, PASS 191 s
5 uci-onchip (3b4dd718), device REU enabled (16 MB) $EC 200 760,609 B == Content-Length, PASS 192 s
6 uci-onchip (3b4dd718), device REU Disabled $EC 200 760,609 B == Content-Length, PASS 190 s
control BACKEND=uci USE_NISTCURVES_ONCHIP_COMB=1 TRUST_STORE=1 TRUST_RELEASE=1 on this tree, PQ_HYBRID off (fee4d5b1) n/a 200 760,609 B == Content-Length, PASS 218 s
fallback uci-comb (41202de9), github.com /robots.txt $1D 200 14,489 B == Content-Length, PASS 101 s

All 8 runs completed; no truncation was seen. The silent-truncation defect #211 was closed on 2026-09-24 (PR #238); these runs are evidence for that fix on the PQ images, not a statement that it is intermittent today. The article is 760,609 B now (759,171 B at v0.5.1). Elapsed is the whole rig run including boot (comb adds its ~45 s table build).

Local OpenSSL 3.6.5 listener (tools/uci/rig_https_local.py, EXTERNAL_LISTENER=1; it dials the listener's IP, so a release PRG cannot pass the name check, and the images were built with the release arguments plus HTTPS_SNI=www.foo.invalid: uci-onchip 46398f0b…, uci-comb fdb7386e…):

image server -groups tls_kex_group result oracle (C64 application traffic secrets vs the server's keylog)
uci-onchip X25519MLKEM768 $EC full HTTPS PASS MATCH
uci-onchip X25519 $1D full HTTPS PASS n/a (not hybrid)
uci-comb X25519MLKEM768 $EC full HTTPS PASS MATCH
uci-comb X25519 $1D full HTTPS PASS n/a (not hybrid)

boot_check.py passed on the release uci-onchip and uci-comb PRGs.

Trust store on the PQ images (the store stages in the TCP ring, which now also hosts the ML-KEM dk scratch). The rigs refuse any directory but /<volume>/c64https-test, so they ran on the release arguments plus TRUST_STORE_DIR=/USB1/c64https-test HTTPS_PORT=24520 (uci-onchip a0d26e3d…, uci-comb e8adb55d…); the real /USB1/TRUST.A|B (80 B / 144 B) were unchanged afterwards:

  • rig_trust_store.py (disk I/O: empty, save, load, torn write, corruption, 32 records, missing directory): exit 0 on both, 24 and 25 checks, 0 failures; phase 13 runs only at 1 MHz and was not run.
  • rig_trust_policy.py, local mode, through the menu against a Python TLS 1.3 listener (it can only choose X25519, so these fetches took 0x001D): exit 0 on both, 30 passed, 0 failed each: first use records the key (KEY RECORDED), a matching key is KNOWN KEY with the store untouched, a changed key shows KEY CHANGED and the full hash and sends no client Finished, accept-on-retry records ACCEPTED/K2, and Y at the prompt runs one UNPINNED fetch that records nothing. Live mode (RIG_MODE=live) needs the REU viewer demo, which does not link with PQ (below), so it was not run. Disclosure: the first-use and changed-key trust paths were therefore exercised on the PQ images with X25519 fetches only (KeyGen and the dk scratch in the ring still ran on every connect); with an 11EC server only the known-key path has been run, in the Wikipedia runs above.

Memory (tools/measure_margins.py --build, the exact release arguments, free bytes): uci-onchip LOADER 157, NET_CODE 105, CRYPTO_OVERLAY 372, CRYPTO_HOT 196; uci-comb LOADER 342, NET_CODE 134, CRYPTO_OVERLAY 55, CRYPTO_HOT 379, COLD_IMAGE 1,697, COLD_RUN_TRUST 75.

Suites, on this tree: BACKEND=uci python3 tools/run_all_tests.py 455/455 (the net and ip65_exports suites are accounted skips: they test the ip65 adapter). That aggregate runs the non-PQ REU profile; no VICE suite runs the release images, which are covered by make package-verify's VICE boot, the hardware runs above, and the PQ suites (test_pq_*, test_mlkem_*, 72 mutants, per the review of #315). Bare python3 -m pytest -q 510 passed.

What this release does NOT do

  • No certificate chain validation on the three 6510 images (unchanged). The released UCI images pin by trust-on-first-use (TRUST.A / TRUST.B on /USB1); a build-time pin or a signed bundle needs a custom build. If the store cannot be used, they offer to continue unpinned. The ip65 image accepts any certificate whose signature verifies. On the M3 demo, the ESP32 verifies certificates against its own Mozilla CA store.
  • Server name validation is UCI-only.
  • Entropy is weak (see above).
  • The Wikipedia REU viewer demo does not link with PQ_HYBRID=1. make BACKEND=uci USE_NISTCURVES_ONCHIP_COMB=1 PQ_HYBRID=1 HTTPS_HOST=en.wikipedia.org HTTPS_PATH=/wiki/Commodore_64 HTTPS_BODY_TO_REU=1 overflows CRYPTO_HOT by 507 B. Build the demo without PQ_HYBRID.
  • A hybrid ServerHello sent as one record cannot be read (the record buffer is 548 B); the client refuses it at once, leaving tls_sh_err=$0E (not shown on screen). Servers that honour max_fragment_length (OpenSSL, Wikipedia, browserleaks) split it. Front ends that ignore it, such as Cloudflare's and BoringSSL-based ones, are out of scope (Cloudflare also enforces a ~15 s deadline).
  • ip65-onchip was validated in VICE only (one e2e PASS at honest 1 MHz, see above); not on real RR-Net silicon at this release, and it has no server name validation.
  • The C64 Ultimate was not validated for this release. The last C64U run (v0.5.1, uci-onchip against Wikipedia at 64 MHz) did not reach a connection:
    • Its trust store could not be used, so the image printed TRUST FAIL 02 and asked CONTINUE UNPINNED? Y/N. The UCI images keep their trust store (TRUST.A / TRUST.B) on /USB1, and that C64 Ultimate has no /USB1 volume. That fits, but the cause was not investigated.
    • The path the rig typed arrived as /wiki/.ommodore_64.
    • No socket was opened ("NOT DIALLED").