Repository navigation
Description
Why
This client does no chain validation (README.md:149-150). It proves the peer holds the private key for whatever leaf it presented, and on UCI that the leaf's SAN matches the hostname (#135) — but nothing ties that leaf to anything trusted. A self-signed certificate naming the target is accepted.
A conventional CA trust store was assessed on 2026-08-30 and is not achievable, for a reason worth recording because it is not the obvious one: there is no RSA anywhere on the chains this client can reach. The web PKI standardised its ECC hierarchy on P-384 above the leaf — 14/14 measured chains have ecdsa-with-SHA384 intermediate←root signatures under P-384 roots, and Let's Encrypt's issuing intermediates are themselves P-384-keyed. Root anchoring needs P-384 + SHA-384, which cannot be built here, and even with it would add 121-300 s against 45-100 s of measured server patience.
Pinning sidesteps all of it. Instead of "did a CA vouch for this key?", ask "is this the key I expected?" — a SHA-256 over ~91 bytes, no extra signature verification, no P-384, no clock. It is also a stronger claim: a CA store trusts ~150 authorities, any of which can mis-issue (DigiNotar, Symantec, TrustCor); a pin trusts none of them.
What is pinned
SHA-256 of the leaf's SubjectPublicKeyInfo DER — 32 bytes; the SPKI is 91 B for every P-256 leaf sampled.
SPKI rather than the whole certificate because a certificate changes on every reissue (serial, validity dates, SCT extensions) even when the key is identical — a cert pin would break every 60-90 days unconditionally. An SPKI pin breaks only on actual key rotation. This is why RFC 7469 pinned SPKI. It is also cheaper: the SPKI DER is a contiguous span already sitting in cert_buf.
Design
Where it is computed. In x509_parse_cert (src/der_decode.s, step 4g), save zp_ptr before der_read_tag reads the SPKI SEQUENCE tag; end is (post-length zp_ptr + der_len). That is the full SPKI TLV including its own tag and length — byte-identical to openssl x509 -pubkey | openssl pkey -pubin -outform DER, so a generator and the 6502 agree by construction. ~20 B, then sha256_init/update/final over ~91 B (two blocks, immeasurable).
Where it is checked. At x509_extract_pubkey's success exit (src/tls_cert.s) — the same site src/x509_name.s already tail-calls from. Chain the pin check ahead of the name check so a pin failure reports as a pin failure.
It MUST be a positive flag, not an abort-on-mismatch. Set cert_pin_ok only on a successful compare, and have tls13.s refuse to derive traffic keys unless it is set. This is the #152 interlock: if an attacker omits the Certificate message entirely, x509_extract_pubkey never runs, the flag stays clear, and the handshake fails closed. A pin written as "abort if the compare fails" is bypassed by exactly the attack #152 describes. Sequenced strictly after #152.
Two phases — the machinery is shared
The hash-and-compare is identical in both; only the source of the expected value differs, so phase 1 is not throwaway work.
Phase 1 — build-time pin. HTTPS_PIN_SPKI_SHA256=<64 hex> emitted into the generated build/https_host.inc beside HTTPS_HOST/PATH/SNI, 32 B into HTTPS_TARGET_RODATA. That mechanism is content-compared at Makefile parse time, so changing a pin needs no make clean — which matters given rotation. Works today with no new infrastructure, and fits the shipping model where HTTPS_HOST is compiled in.
⚠ Note HTTPS_SNI is not in the no-clean set (#150) — if the pin ever gains a code-affecting -D, it inherits that trap.
Phase 2 — trust on first use (the preferred direction). Store the SPKI hash on first contact; warn loudly if it ever changes. This is the SSH model, it needs no CA store and no P-384, and it generalises to user-specified hosts, which a build-time pin cannot.
Prerequisite, stated plainly: TOFU needs persistent writable storage, which this project does not have. There is no disk-write path in the tree; the REU is volatile, so a REU-backed store lasts one power cycle. Options to assess: writing to the .d64 via KERNAL (no precedent here), or the UCI DOS targets (UCI-only, so ip65 would be left without). That assessment is the real work in phase 2 — the crypto is already done by phase 1.
Rotation — the honest cost, and it differs per host
A pinned build stops working when the server rotates its key. Measured 2026-08-30 via CT logs (api.certspotter.com, which exposes pubkey_sha256 directly; crt.sh was 502):
| host | distinct SPKI keys | verdict |
|---|---|---|
| lwn.net | 3 of 3 issuances | no reuse — fresh key every renewal |
| browserleaks.com | 2, each seen twice | reuses across the Jun→Aug renewal |
| github.com | 12 of 13 | high CT churn; six consecutive live connections all returned the same SPKI, so no observable concurrent multiplicity |
| en.wikipedia.org | — | certspotter returned nothing; unresolved |
So enforce-vs-warn is a per-host decision, not a global one.
The diagnostic is part of the deliverable, not polish
Today a pin failure would land in the same undifferentiated …KEYS ENC1 RX stall that CLAUDE.md already says is not diagnostic — two unrelated causes present identically there. A user would power-cycle a healthy device. So:
- a distinct
PINmarker and abort code, following thetls_last_state=6oracle pattern; - print the first 4 bytes of expected and received hash on failure, so the user reads the new fingerprint off screen and rebuilds;
- print the pin in the boot banner so a build is identifiable before it fails.
~30-40 B. If this is not funded, ship nothing here — a security check whose failure is indistinguishable from a broken network is a net negative.
Three modes, one variable: unset = disabled (default, so every existing rig and make package-verify keep working); set = enforce; HTTPS_PIN_WARN=1 = detect and report, do not abort (~10 B). Recommend warn-by-default on packaged artifacts, enforce for self-built targets — a released .d64 that says "SERVER KEY CHANGED" beats both today's silence and a binary that bricks itself in eight weeks.
Why this is worth it for ip65 especially
X509_VERIFY_NAME is UCI-only — 491 B does not fit on ip65 (#135). At ~112-152 B the pin would be the first certificate authentication the ip65 product has ever had. Retiring P-384 (~332 B recovered on both backends) makes that comfortable rather than marginal.
Related
- TLS 1.3 handshake message sequence is unenforced: a MITM completes the handshake with no Certificate at all #152 — handshake sequence unenforced. Blocking: the pin's interlock depends on it.
- No all-zero check on the X25519 shared secret: a low-order key_share makes the session passively decryptable #153 — no all-zero X25519 check.
- No server name validation: the certificate's SAN/CN is never compared against the host we asked for #135 — SAN name validation. The pin logically subsumes it, but keep both: they fail independently, and in warn mode the SAN check is the only enforcing check left.
Activity
Build-time leaf SPKI pin (#155 phase 1)
Merge pull request #255 from JC-000/feat/155-spki-pin
JC-000 commentedon Sep 29, 2026
Phase 1 is shipped (#255, merge 8aa1a49). This issue stays open for phase 2 (TOFU).
Phase 1: what exists
make … HTTPS_PIN_SPKI_SHA256=<64 hex>.python3 tools/spki_pin.py <host>prints the value, and it matches the openssl pipeline in the issue.cert_pin_statusbecomes$80, and the screen showsPIN FAIL EXP xxxxxxxx GOT yyyyyyyy.HTTPS_PIN_WARN=1printsPIN WARN …and continues. The boot banner showsSPKI PIN xxxxxxxx.tls_connectcallscert_pin_hs_keys(clears the status every connection) andcert_pin_send_finished(no client Finished unless a check ran and passed). A flight that omits the Certificate therefore fails closed even with the TLS 1.3 handshake message sequence is unenforced: a MITM completes the handshake with no Certificate at all #152 sequence gate removed.x509_parse_cert, which is not on the live path. It is the 91 B window that ends at the Qy the key scanner actually copied, so the pinned key is the key that CertificateVerify checks. The header ofsrc/cert_pin.sexplains why.ecdsa_curve_id != 0.Phase 2: what can be reused
cert_pin_expected, is 32 B in segmentCERT_PIN_UI, which istype = rwin every cfg. A TOFU loader can write the stored hash there at boot, so the stored pin needs no new RAM.PIN WARN/FAILcase, such as accepting the new key.Phase 2: constraints, measured at 8aa1a49
tools/measure_margins.py, bytes free in each region's tail. Pinned figures are for enforce mode; warn mode costs 6 B more (1 in the hash code, 5 in the rest).CERT_PIN_CODE= 103-104 B andCERT_PIN_UI= 191 B (enforce) / 105 and 196 B (warn).HTTPS_HOST/HTTPS_PATHstrings and falls to 14 B with the wikipedia target. CRYPTO_OVERLAY and CRYPTO_RESIDENT are adjacent, so their free bytes are one pool; moving segments between them creates no space. A load/save path on ip65 therefore needs new space first.Phase 2: storage
SETLFS/SETNAM/OPEN/SAVEcall sites), anduci_regs.incdefines onlyUCI_TARGET_NETWORK.$80-$BFgo through the registry in c64-wireguard'snet_abi.incfirst, persrc/net_err_registry_asserts.s.trust store: on-disk TOFU store I/O over the UCI DOS target (#155 pha…
UCI: type the HTTPS host and path at the 'G' prompt (#155 phase 2)
trust store: on-disk TOFU store I/O over the UCI DOS target (#155 pha…
Merge pull request #258 from JC-000/feat/p2-l2-store
Reclaim unreachable code on every profile, knob-gated (#155 phase 2)
UCI: type the HTTPS host and path at the 'G' prompt (#155 phase 2)
Reclaim unreachable code on every profile, knob-gated (#155 phase 2)
tools: signed trust-bundle format + host tooling (#155 phase 2, L4)
Merge pull request #260 from JC-000/feat/p2-l0-reclaim
Merge pull request #262 from JC-000/feat/p2-l4-bundle
UCI: type the HTTPS host and path at the 'G' prompt (#155 phase 2)
Merge pull request #261 from JC-000/feat/p2-l1-hostname
Merge pull request #266 from JC-000/feat/p2-l5-coldbank
UCI: trust policy -- build pin > signed bundle > TOFU (#155 phase 2, L3)
UCI: trust policy -- build pin > signed bundle > TOFU (#155 phase 2, L3)
UCI: trust policy -- build pin > signed bundle > TOFU (#155 phase 2, L3)
Merge pull request #268 from JC-000/feat/p2-l3-policy
Merge pull request #269 from JC-000/feat/p2-l3-package
Merge pull request #318 from JC-000/docs/155-phase2-design-record