Skip to content

Certificate pinning: pin the leaf SPKI hash (build-time now, trust-on-first-use as the direction) #155

Description

@JC-000

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions