Skip to content

c64-https v0.4.3 — security release

Latest

Choose a tag to compare

@JC-000 JC-000 released this 06 Sep 11:02
· 4 commits to master since this release
48657f5

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 $FFFF rather 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_ACC is 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 "run make clean after changing any flag"
    rule and the silent mixed-link failure mode it existed to avoid.
  • make -n / -q / -t no longer delete the tree (#174).
  • EMBED_P256_OVERLAY is retired with a parse-time error (#118).
  • src/net_abi.inc is 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/x25519 pinned to v0.13.0. USE_X25519_SIBLING remains 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, via tools/_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 in test_finished_verify.py were 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.py mints 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-onchip does 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-onchip product 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 G to 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/nistcurves v0.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=uci build, 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_memory path corrupts exactly
    one byte
    , sporadically, on payloads well past the 2 KB bound anyone had
    verified (U64E, firmware v3.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.