c64-https v0.4.3
A security release. Two client-side TLS defects fixed since v0.4.2, both of
which affect every previously released image. If you are running v0.4.2 or
earlier, replace it.
Built from 48657f5. Submodule pins: libs/nistcurves v0.11.2 (unchanged
since v0.4.2), libs/x25519 v0.13.0 (not linked into any shipped image),
ip65 25a9c5a.
Security fixes
Both land in code that is compiled into all three images.
1. An all-zero X25519 shared secret is now rejected (#153, 47ab00c)
RFC 8446 §7.4.2 and RFC 7748 §6.1 both require the recipient to check whether
the computed shared secret is all zero and abort if it is. Before this fix
tls_ecdh_compute_shared copied the result out unconditionally and returned
with the carry undefined, and tls13.s followed the call with an
unconditional clc — so the check did not merely fail, it did not exist.
Impact. A server, or an attacker who rewrites one ServerHello in passing,
can send a low-order key_share — 32 zero bytes is the simplest — which
forces x25519(k, U) = 0 for every clamped scalar k. The key schedule then
collapses: handshake_secret = HKDF-Extract(constant, 0^32) is itself a
constant, and every secret below it is a function of the two plaintext hello
messages. Anyone who merely recorded the session can then derive the traffic
keys and decrypt it. The attacker who injected the key_share does not need
to stay on the path.
That is a passive decryption break. It is strictly worse than this client's
long-documented "an active attacker can impersonate any server" caveat,
because it defeats confidentiality against passive observers — the one
property the client still claims after conceding authentication.
The fix checks the output, which is what the RFC mandates and what catches
every low-order point rather than only the zero one. Covered by
tools/test_ecdh_zero_check.py (five small-order points must abort, plus an
honest basepoint case checked against an independent Python X25519).
2. The handshake message sequence is now enforced (#152, d0c9ed3)
tls_connect walks the encrypted half of the handshake with four
unconditional receives, and each returned as soon as one handshake message
of any type had dispatched. Nothing compared the received type against
tls_state.
Impact. A server — or an on-path attacker, since the ClientHello /
ServerHello exchange is unauthenticated — could satisfy all four receives with
four messages of one harmless type (four EncryptedExtensions, say). The client
would derive traffic keys, set TLS_STATE_CONNECTED and report success
having seen no Certificate, no CertificateVerify and no server Finished —
so no signature was ever verified. On the UCI images it also bypassed the
v0.4.2 server-name validation, which is a tail call off the Certificate
handler's success exit: omit the Certificate and the SAN check never runs.
The gate is one macro expanded at the single point on each backend that every
handshake message passes through — the .else arm of tls_recv_encrypted
under ip65, and @hdr_complete under UCI. The UCI placement matters: an
earlier cut gated df_dispatch only, and a Certificate whose bytes span
records takes a different route that never reaches it. Since the attacker
picks the record framing, a 2-byte first record forced that route for a
Certificate of any size, and four of them satisfied the whole flight.
Covered by tools/test_hs_sequence.py (62 cases driven through the real
tls_deframe_pump, including the four-spanning-Certificate flight, with both
range checks mutation-proven), and by CERT_MODE=omit in
tools/https_e2e/evil_listener.py.
Also fixed: the default host in released images
Every image is built for one host, baked in at build time; the packaging
scripts never override it, so an image carries whatever the Makefile default
was. v0.4.2's images carry www.foo.bar. .bar is a delegated gTLD, so
that is a name a third party can register. It is NXDOMAIN today — this is
latent, not live, and there is no evidence it was ever registered — but an
image with no certificate chain validation would dial whoever held it.
v0.4.3's images carry www.foo.invalid (cdf02b4, #164): RFC 2606 /
RFC 6761 reserved, never delegated, and so incapable of resolving at all. This
release fixes that item by construction rather than by policy.
Not a security fix, recorded separately so it is not miscounted
8c4b389 (#145) — CIA1's Time-of-Day clock is now started. Every UCI
spin-wait believed itself bounded to 5 s of wall-clock time via CIA1 TOD. The
TOD is halted out of reset and nothing in the KERNAL starts it, so on real
hardware every one of those waits was in fact unbounded. That is a
fail-unsafe hang, not a confidentiality or authentication break. UCI-only: it
does not affect ip65-onchip. VICE runs the TOD from reset, so no emulator
test can catch a regression here; the guard is tools/uci/boot_check.py and
it needs real hardware.
Correctness and robustness (UCI backend)
None of these are exploitable breaks; they are defensive or diagnostic.
net_poll's copy is bounded by the request (#140,50dc940). The
SOCKET_READ response header is not a delivered-byte count on TCP — the
firmware answers$FFFFrather than a count both on an idle poll and when
the queue exceeds the request. This is not remotely reachable: that header
is generated by the local Ultimate FPGA firmware, not by the peer, so an
attacker on the network cannot forge it — which is why this is hardening
rather than a fix. As a second line of defence the copy loop already
re-tested ring-full and DATA_AV per byte and always stored at
tcp_recv_buf+ masked tail, so no write could leave the 4 KB ring. The
count is now additionally capped at the length actually requested, so a
future refactor of that loop cannot turn a bad header into a runaway copy.
A non-sentinel header above the request leaves$8B UCI_ERR_BAD_READ_HDR
as a breadcrumb.- The 16-bit request clamp is now shape-independent (
5ae2695). The old
comparison tested the high byte and accepted the equal-high-byte case only
when the low byte was zero — correct for the shipped 512-byte cap, where
old and new agree on every possible input, but wrong for any
non-page-aligned cap, where it would have raised a sub-cap request up to
the cap. Behaviour in the shipped images is unchanged; this only unblocks
moving the constant. DATA_ACCis an accept, not a per-byte advance (#144,be698e7).
Pulsing it per byte ends the data phase and silently truncates.- The firmware's STATUS line is captured rather than discarded (#147,
c2e3bf1), so a failed command reports what the firmware said.
Build and packaging
- The flag set is a tracked build dependency (
build/flags.stamp, #159),
which retires most of the old "runmake cleanafter changing any flag"
rule and the silent mixed-link failure mode it existed to avoid. make -n/-q/-tno longer delete the tree (#174).EMBED_P256_OVERLAYis retired with a parse-time error (#118).src/net_abi.incis now the build-enforced backend boundary (#70): a
backend that drops a symbol fails the link by name on both backends.- The comb boot precompute is VIC-blanked (#151), so
uci-comb's ~45 s
startup table build now shows a dark-grey border as its "working" signal. libs/x25519pinned to v0.13.0.USE_X25519_SIBLINGremains off by
default and links into no shipped image; the in-tree X25519 is what every
release PRG is built from. The three PRG hashes are unchanged by this
bump.
Test integrity
An audit found suites that could exit 0 having verified nothing, and this
release is largely the result of fixing them:
- Six suites could exit 0 having tested nothing (#158); an involuntary skip is
now a failure, viatools/_skip_policy.py, in both the tool and rig lanes
(#165, #178a, #177). - Three VICE suites did not measure what they claimed (#161) — most sharply,
16 negative cases intest_finished_verify.pywere vacuous under the UCI
build, and Certificate cases in the deframer suites were being decided by an
empty hostname rather than by the code under test. run_all_tests.py's suite list is now pinned by AST, with four previously
omitted suites wired in (#169).test_x509_name.pymints its own certificate instead of dropping six
vectors (#167).
Documentation
CLAUDE.md split into a terse reference plus docs/engineering-notes.md
(#138); README refreshed against the shipped tree (#192); c64-lib-contract
§13 citations re-anchored to v0.17.1 (#185); assorted measured-drift
corrections (#150, #156, #168, #170, #182, #188).
What this release does NOT do
Stated plainly, because a security release should not imply coverage it does
not have.
-
There is still no certificate chain validation on any image. No trust
store, no root CAs, no issuer check. What the client proves is that the peer
holds the private key for the leaf certificate it presented; it does not
prove that anyone vouches for that certificate. An active attacker who can
redirect your connection can still self-sign and be accepted. -
Server name validation exists only on the two UCI images.
ip65-onchipdoes not have it — the routine is 491 B and that layout's
largest free block is 56 B (#135). Combined with the point above, name
validation narrows "any certificate is accepted" to "any certificate naming
the right host is accepted"; it is not authentication. -
The
ip65-onchipproduct has now run on real RR-Net hardware — once.
The ip65/RR-Net product has now run once on real CS8900a silicon — a full
TLS 1.3 handshake and GET at stock 1 MHz against a local self-signed
listener, verified on the wire and in the client's own memory. That is one
run, one clock, one cartridge, one device, and a local listener rather than a
real server; the image differed from the shipped one only in its build-time
target port. It says nothing about server-name validation, which the ip65
product still does not perform.Read the evidence as 15 independent hardware checks. The run recorded 24,
but 8 of those exercise neither the cartridge nor the 6510 — four checker
self-tests, a PRG file read, a labels read, a config write and a loopback
probe — leaving 16 genuine, and 15 independent once one provable redundancy
is discounted.Wall clock was 1,979 s from
Gto a closed connection, against 2,159.7 s
for the same profile and the same boundary under VICE. Treat that as a lower
bound — at least ~8%, not a measurement: the VICE figure was taken at the
libs/nistcurvesv0.6.0 pin and this release ships v0.11.2, which is the
slower of the two. The true margin on silicon is larger by an unmeasured
amount, and closing it means re-running VICE at the current pin. -
The UCI images were not hardware-validated as part of cutting this
release. The two security fixes above are covered by VICE suites only.
Any hardware validation of these exact images is a separate claim and should
be stated only by whoever ran it, against these exact PRG hashes. The one UCI
hardware run in this cycle does not count towards it: it used a plain
make BACKEND=ucibuild, which is the REU profile, and the REU profile ships
in no release image. -
The fixes above address specific defects. They are not the result of a
systematic cryptographic review, and this remains a demonstration project
rather than a hardened TLS implementation.
Open follow-ups
Open at this tag, and all four are consequences of the hardware run above
rather than of the security fixes:
- #199 — no hardware rig verifies the PRG image after loading it. Every rig
trusts the load; nothing reads the image back out of the machine and compares
it against the file. Filed as a blast-radius assessment of the harness defect
below. - #201 — 17 of 46 mutants survive
ip65_hw_checks. They mark untested
defence-in-depth branches. None of the survivors changes a verdict on the
run reported above, so this bounds how much the checker has been exercised,
not how much it got wrong. - #202 — five defences from the c64-wireguard reference checker were not
carried over, one of which the upstream source explicitly warns against
dropping. - c64-test-harness #231 — the bulk REST
write_memorypath corrupts exactly
one byte, sporadically, on payloads well past the 2 KB bound anyone had
verified (U64E, firmwarev3.15-78-g71480a9d). This release's own image
verify is what found it. It is worth knowing for anyone who loads images to
an Ultimate device over REST — the practical mitigation is to read the image
back and compare it, which is exactly what #199 is about.
Artifacts
| file | bytes | sha256 |
|---|---|---|
c64-https-ip65-onchip.prg |
47,105 | ba2be10824e376842690a3d07f61854898c22f4c937eca261212a47ff96ef947 |
c64-https-uci-onchip.prg |
62,977 | 5f9e9fb9e70edd2a15ab2249120f161d1826262140fa6ce6f3e01410a876b474 |
c64-https-uci-comb.prg |
62,977 | c423476011e1928f5955e4773c3cf28c4876dcf76e70be9de40c2f4522f8bf72 |
.d64 images and the dependency-free test listener ship alongside; see
MANIFEST.txt for the full checksum list and for which image to pick.