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
PIN marker and abort code, following the tls_last_state=6 oracle 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
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-SHA384intermediate←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
SubjectPublicKeyInfoDER — 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), savezp_ptrbeforeder_read_tagreads the SPKI SEQUENCE tag; end is (post-lengthzp_ptr+der_len). That is the full SPKI TLV including its own tag and length — byte-identical toopenssl x509 -pubkey | openssl pkey -pubin -outform DER, so a generator and the 6502 agree by construction. ~20 B, thensha256_init/update/finalover ~91 B (two blocks, immeasurable).Where it is checked. At
x509_extract_pubkey's success exit (src/tls_cert.s) — the same sitesrc/x509_name.salready 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_okonly on a successful compare, and havetls13.srefuse to derive traffic keys unless it is set. This is the #152 interlock: if an attacker omits the Certificate message entirely,x509_extract_pubkeynever 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 generatedbuild/https_host.incbesideHTTPS_HOST/PATH/SNI, 32 B intoHTTPS_TARGET_RODATA. That mechanism is content-compared at Makefile parse time, so changing a pin needs nomake clean— which matters given rotation. Works today with no new infrastructure, and fits the shipping model whereHTTPS_HOSTis compiled in.⚠ Note
HTTPS_SNIis 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 exposespubkey_sha256directly; crt.sh was 502):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 RXstall that CLAUDE.md already says is not diagnostic — two unrelated causes present identically there. A user would power-cycle a healthy device. So:PINmarker and abort code, following thetls_last_state=6oracle pattern;~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-verifykeep 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.d64that 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_NAMEis 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