Skip to content

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

Open

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

  1. added 2 commits that reference this issue on Sep 28, 2026
  2. JC-000 commented on Sep 29, 2026

    @JC-000
    OwnerAuthor

    Phase 1 is shipped (#255, merge 8aa1a49). This issue stays open for phase 2 (TOFU).

    Phase 1: what exists

    • Build-time pin: 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.
    • Enforce and warn modes: in enforce mode (the default) a mismatch aborts, cert_pin_status becomes $80, and the screen shows PIN FAIL EXP xxxxxxxx GOT yyyyyyyy. HTTPS_PIN_WARN=1 prints PIN WARN … and continues. The boot banner shows SPKI PIN xxxxxxxx.
    • Both backends. On ip65 the pin is the only certificate check.
    • Positive-flag interlock: tls_connect calls cert_pin_hs_keys (clears the status every connection) and cert_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.
    • Unpinned builds are byte-identical to one built without the feature.
    • The hashed span differs from the issue's design. It is not the DER-parsed SPKI from 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 of src/cert_pin.s explains why.
    • P-256 leaves only. The check refuses any ecdsa_curve_id != 0.

    Phase 2: what can be reused

    • The hash-and-compare needs no change. The expected value, cert_pin_expected, is 32 B in segment CERT_PIN_UI, which is type = rw in every cfg. A TOFU loader can write the stored hash there at boot, so the stored pin needs no new RAM.
    • What TOFU still needs:
      • a load path;
      • a "no pin stored yet" state, where the first contact records the key instead of rejecting it;
      • a save path;
      • a UI decision for the PIN WARN/FAIL case, 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).

    profile LOADER NET_CODE CRYPTO_OVERLAY CRYPTO_RESIDENT / HOT where the pin lives
    ip65 14 56 41 → 40 RESIDENT 355 → 61 both segments in RESIDENT
    ip65-onchip (shipped) 14 56 7 → 6 RESIDENT 318 → 24 both in RESIDENT
    uci 125 49 1,874 → 1,579 HOT 406 both in OVERLAY
    uci-onchip (shipped) 125 57 1,777 → 1,482 HOT 450 both in OVERLAY
    uci-comb (shipped) 99 49 283 → 92 HOT 183 → 79 hash in HOT, the rest in OVERLAY
    • Phase 1 costs 0 B of LOADER and 0 B of NET_CODE on every profile. Its segments are CERT_PIN_CODE = 103-104 B and CERT_PIN_UI = 191 B (enforce) / 105 and 196 B (warn).
    • ip65-onchip has almost no room for phase 2: 24 B in CRYPTO_RESIDENT, 6 B in CRYPTO_OVERLAY, 14 B in LOADER, and 56 B in NET_CODE. The 56 B is shared with the HTTPS_HOST/HTTPS_PATH strings 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.
    • uci and uci-onchip have about 1.5 KB of CRYPTO_OVERLAY free with a pin set.
    • uci-comb has 92 B of CRYPTO_OVERLAY and 79 B of CRYPTO_HOT. The overlay tail is also the target strings' budget and the scratch area the rigs allocate from.

    Phase 2: storage

    • No persistence code exists in the tree: no KERNAL file I/O (no SETLFS/SETNAM/OPEN/SAVE call sites), and uci_regs.inc defines only UCI_TARGET_NETWORK.
    • REU: volatile, so a store there lasts one power cycle.
    • KERNAL disk write (drive 8, .d64): works for both backends. It is new code in the ip65 regions above, which have no room.
    • UCI DOS target (a file on the Ultimate's storage): UCI only, so ip65 would get nothing. It would be a second UCI target beside NETWORK, with its own command set and error codes. Codes in $80-$BF go through the registry in c64-wireguard's net_abi.inc first, per src/net_err_registry_asserts.s.
  3. added a commit that references this issue on Oct 18, 2026
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