Skip to content

UCI trust policy: build pin > signed bundle > TOFU (#155 phase 2, L3) - #268

Merged
JC-000 merged 7 commits into
masterfrom
feat/p2-l3-policy
Oct 6, 2026
Merged

JC-000 merged 7 commits into
masterfrom
feat/p2-l3-policy

Conversation

@JC-000

@JC-000 JC-000 commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

#155 phase 2, lane L3: the trust policy on UCI builds (TRUST_STORE=1). Binding inputs: phase2-spikes/DECISIONS.md (all items; item 11 governs) and l3/L3-design.md.

Merge order: #268 → #269. Rebased onto master c26e268 (#273 and #279-#286 merged).

Behaviour

Precedence. The build pin applies only when the typed host equals HTTPS_HOST (Q2). Then comes the signed bundle (advisory), then TOFU.

One resident hook. src/cert_pin.s serves as the trust hook under TRUST_STORE:

trust_pre runs before the dial:

  • TRUST STORE EMPTY is printed on every GET while the store is empty.
  • A store FAIL prints TRUST FAIL nn, then CONTINUE UNPINNED? Y/N (Q5).
    • Y gets one fetch, with the UNPINNED banner before and after. It is never recorded.
    • N gets NOT DIALLED.
  • An armed accept survives only for the same host key.

trust_post runs after the close, on every exit after the dial:

  • It records a first use as TOFU and an accept as ACCEPTED, only after a completed handshake.
  • KEY CHANGED shows the full hash, then ACCEPT ON RETRY? A=YES. Otherwise it asks CONTINUE UNPINNED? Y/N.
  • BUNDLE MISMATCH at first use: the fetch finishes, then RECORD KEY? A=YES (Q3).
  • No key is read mid-handshake. Every question flushes $C6 first.

Bundle (TRUST_BUNDLE=1, off in packages).

  • File: TRUST_STORE_DIR/TRUST.P.
  • Checks run in order: structure, then ECDSA, then the generation floor.
  • It is verified lazily once per boot and cached by digest (Q4).
  • Its SHA-256 borrows tls_transcript_* before the dial.

adv-268 fixes (4414bae).

  • tp_bwarn is now set explicitly (MEDIUM-HIGH: a mismatched pin byte of $00 recorded silently).
  • trust_state_init runs at start.
  • The stubs clear the armed accept and the mode when the cold bank refuses.
  • The hook has its own segment, TRUST_HOOK_CODE.

Placement (D2 3ceabfc + adv-d2 F1 affb456)

On master plus #279-#282, the comb products overflowed LOADER (the prompts) and NET_CODE (the hook). The fix is placement only; no code changes.

  • comb (cold-bank cfg):
    • TRUST_PROMPT_CODE rides the cold TRUST group: only TRUST_POLICY_CODE, which runs there, calls it.
    • The hook stays in NET_CODE.
    • The policy state (TRUST_POLICY_BSS) moves to NET_CODE.
    • The LOADER_OVERFLOW tenants (boot/vic/crypto_swap) move to CRYPTO_HOT, under TRUST_STORE only. They go through src/loader_overflow.inc, which names the segment LOADER_OVERFLOW_TS.
  • TRUST_BUNDLE=1 fills the TRUST group and grows the state, so two .ifdef TRUST_BUNDLE directives switch them to TRUST_PROMPT_RES / TRUST_POLICY_BSS_RES, placed in CRYPTO_HOT.
  • uci (onchip / REU): TRUST_PROMPT_CODE and _RES move from LOADER to CRYPTO_OVERLAY. LOADER_OVERFLOW_TS sits beside LOADER_OVERFLOW in NET_CODE.
  • COLD_BANK=0 + TRUST_STORE=1 is still an $(error).

Byte identity (master c26e268 + this + #269 8fdebe7; every hash below re-measured there, identical to the pre-rebase trial 113415a)

  • Every image without the trust store is byte-identical to the same stack without this PR:

    image PRG
    uci 0046561b
    uci-onchip 1061e8ff
    uci-comb 26e032dd
    comb demo 0f949c33
    comb demo + HTTPS_PIN_SPKI 5835f7c2
    ip65 0a0b7a78
    ip65-onchip 78a10748
    uci-m3 links
  • adv-d2 F1: D2 alone had moved LOADER_OVERFLOW for every comb image and broke the demo + pin build (HOT -56). affb456 restores it.

Margins (tools/measure_margins.py, free bytes, on c26e268 + #268 + #269)

image PRG LOADER NET_CODE OVERLAY HOT
ship uci-onchip TS (TRUST_RELEASE=1) da4d607e 588 184 333 166
ship uci-comb TS (TRUST_RELEASE=1) e6750100 78 38 47 1,237
comb Wikipedia demo TS 59bbc7bd 21 38 29 48
onchip + bundle 0c76f1d1 31 184 210 32
comb + bundle 9e46fd9a 67 53 47 990
onchip + bundle + pin 8cafb05d 31 184 14 32
uci TS 58f2214b 588 176 430 122
  • Rig builds (rig_trust_policy local/live, test_rig_scratch demo-trust, rig_trust_store) all link.
  • Known and not shipped: comb + TS + pin does not link (it did not before D2 either), and a 63-char host on comb + TS is 1 B over.

Red-green

  • tools/test_trust_policy_6502.py runs the shipped do_https_get on the 6502 interpreter, with a real Python ECDSA check of the struct the C64 built. Default five images: 432/432.
    • Since adv-d2 F2 (a0f53fa), a simulated handshake fills all 2048 B of cert_buf, as a real flight does. A resident call into the cold prompt now runs $02.
  • tools/mutate_trust_policy.py: 22/22 killed. That includes the new "resident stub calls into the cold TRUST group" mutant, which survived before a0f53fa.
  • tools/test_typed_target.py on TRUST_STORE images (adv-d2 F3, d1bb592). The suite answers CONTINUE UNPINNED? when it appears: Y for accepted targets, and refused targets must never be asked. One case answers N and expects NOT DIALLED.
    • Before: the suite hung on every TRUST_STORE image.
    • Now: onchip + TS 163/163, comb + TS 164/164; non-TS 138/138 and 139/139.
    • adv-d2's three mutants of the new path all go red.
  • tools/test_trust_bundle_policy_c64.py (VICE, real 6502 SHA/ECDSA): good, cached, flipped record, wrong key and replay, 5/5.
  • Other suites on the trial: test_cold_bank 118/118 (its cfg mirror includes the D2 edits), test_trust_store_6502 128/128 on onchip and comb, test_reu_body_sink 76/76, test_quit_basic_exit comb + TS 68/68.
  • Comb with LOADER_OVERFLOW_TS in HOT: test_ecdsa_kat_oracle comb + TS 6/6.
  • Root pytest: 433 passed and 1 failed. The failure is test_ip65_hw_checks_unit, which needs an ip65 build: build state only.

Hardware

U64E, fw 3.15 3a1ff9ff, FPGA 126, core 1.50, 48 MHz. Each run went through DeviceLock, then prepare_device and preflight_reu, using tools/uci/rig_trust_policy.py in /USB1/c64https-test. The PRGs were built on 8fdebe7, whose image bytes equal this branch's.

run PRG sha256 result
local, comb TS (TRUST_STORE_DIR=/USB1/c64https-test HTTPS_PORT=4433) d825488203f54d79… PASS 28/28
local, onchip TS (same flags) 356f2b13589bd829… PASS 28/28
live, comb Wikipedia demo TS: en.wikipedia.org first use 1baa43a96bd5f681… PASS 4/4: TOFU C0A0573A… recorded

The live run's first attempt (same PRG) was 1/4. The rig's 240 s fetch budget expired mid-body. 011209b makes live mode wait 900 s, and the 4/4 run above used that.

Deviations

  • COLD_BANK=0 + TRUST_STORE=1 is refused at build time.
  • The bundle-corroborated record flag is not set.
  • A store FAIL skips the bundle for that fetch.

🤖 Generated with Claude Code

@JC-000
JC-000 force-pushed the feat/p2-l3-policy branch from f29f264 to 04af8b9 Compare October 1, 2026 19:43
@JC-000
JC-000 force-pushed the feat/p2-l3-policy branch from 04af8b9 to d1bb592 Compare October 6, 2026 02:35
@JC-000
JC-000 changed the base branch from master to fix/reu-body-sink-bound October 6, 2026 02:36
@JC-000
JC-000 changed the base branch from fix/reu-body-sink-bound to master October 6, 2026 16:53
JC-000 and others added 7 commits October 6, 2026 11:53
The trust store (L2), the cold bank (L5) and the bundle format (L4) get
their policy, per DECISIONS 3, 4, 7, 10 and 11:

* src/cert_pin.s becomes the one resident hook under TRUST_STORE=1: the
  same SPKI-window hash and #152 interlock, compared per tp_mode with the
  store's record (KEY CHANGED refuses unless the operator armed exactly
  this 32 B hash), nothing (first use, UNPINNED), or the build pin. A
  connection trust_pre never prepared is refused. Without TRUST_STORE
  every byte is unchanged (ip65 and every non-trust UCI build: identical
  PRG hashes to master).
* src/net/uci/trust_policy.s: trust_pre before the dial (build pin only
  for HTTPS_HOST; TRUST STORE EMPTY on every GET; store FAIL asks
  CONTINUE UNPINNED? Y/N) and trust_post after the close (records first
  use and accepted keys; KEY CHANGED shows the full hash and offers
  ACCEPT ON RETRY / CONTINUE UNPINNED; BUNDLE MISMATCH asks RECORD KEY?).
  Every keyboard read is there, so none can happen with a socket open.
* src/net/uci/trust_bundle.s (TRUST_BUNDLE=1): TRUST.P from the store's
  directory, structure -> signature -> generation floor, verified once
  per boot with a cached digest; advisory leaf pins.
* comb: the policy rides the cold bank's TRUST group, the bundle check
  its UI group; COLD_BANK=0 with TRUST_STORE=1 is now a build error.

Accept-and-retry is a second handshake in one boot: it needs
fix/tls-reconnect-state (per-connection TLS reset), which merges first.

Tests: tools/test_trust_policy_6502.py (the shipped do_https_get on the
6502 interpreter, both profiles), tools/mutate_trust_policy.py (18
mutants), tools/test_trust_bundle_policy_c64.py (real ECDSA in VICE),
tools/uci/rig_trust_policy.py (U64E).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…K_CODE)

1. MEDIUM-HIGH. The hook stored tp_bwarn from A after a failed
   tp_cmp_got, and A is then the bundle pin's byte where the hashes first
   differ (from byte 31 down). A $00 there left tp_bwarn clear: BUNDLE
   MISMATCH printed, RECORD KEY? was never asked, and trust_post recorded
   the server's key as TOFU -- free when the pin's byte 31 is $00, ~2^8 /
   2^16 grinds for byte 30 / 29. Now `lda #1`. Regression: pins with byte
   31 = $00, and byte 30 = $00 with 31 equal (red on 04af8b9: recorded
   without A); mutant. Audit: tp_bwarn was the only flag stored from an
   incidental A; the other stores are explicit, or clear (the safe way).
2. LOW. TRUST_POLICY_BSS is not boot-zeroed, so an accept armed before 'Q'
   survived RUN (or a reset and SYS). start now calls trust_state_init,
   which clears it and the bundle cache. Test + mutant (drop the call).
3. LOW, comb. A cold-bank refusal of trust_post (socket ERROR, latched DMA
   timeout) skipped its clears, keeping tp_ovr_armed and tp_mode. The
   resident stubs now clear both whenever the bank itself refuses. Red on
   04af8b9 (armed=1 mode=1 survived); mutant.

Under TRUST_STORE the hook's code is TRUST_HOOK_CODE, not CERT_PIN_CODE:
the comb cfgs place it in NET_CODE (CRYPTO_HOT has no room for it beside
#273's body-sink bound), the uci cfg where CERT_PIN_CODE already was. The
plain pin keeps CERT_PIN_CODE, so non-trust pinned builds do not move.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On master + the queued PRs, the comb products overflowed LOADER (the
prompts) and NET_CODE (the hook). Placement only, no code change:

- comb: TRUST_PROMPT_CODE rides the cold TRUST group (only the policy,
  which runs there, calls it); LOADER_OVERFLOW moves to CRYPTO_HOT and
  TRUST_POLICY_BSS to NET_CODE, so the hook keeps NET_CODE on the
  Wikipedia demo, whose CRYPTO_HOT is nearly full.
- TRUST_BUNDLE=1 fills the TRUST group and grows the state: the source
  switches both to TRUST_PROMPT_RES / TRUST_POLICY_BSS_RES (comb:
  CRYPTO_HOT).
- uci: TRUST_PROMPT_CODE (and _RES) LOADER -> CRYPTO_OVERLAY, which the
  onchip bundle images need.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…RE (adv-d2 F1)

D2 moved LOADER_OVERFLOW to CRYPTO_HOT on every cold-bank comb build, so
images without the trust store changed for no gain, and the comb demo
with HTTPS_PIN_SPKI (no store) stopped linking (CRYPTO_HOT -56). Under
TRUST_STORE the three tenants now name it LOADER_OVERFLOW_TS
(src/loader_overflow.inc, the _RES pattern): the comb cold cfg puts that
in CRYPTO_HOT, the uci cfg beside LOADER_OVERFLOW in NET_CODE, and every
image without the store keeps its pre-D2 placement and bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nto the cold prompt goes red (adv-d2 F2)

_tls_connect wrote only the 91 B leaf window, so the TRUST group's copy
in cert_buf survived a "handshake" and a resident JSR into the cold
prompt still ran. A real flight fills cert_buf: the model now fills all
2048 B ($02, a JAM) first. New mutant: the resident trust_post stub calls
tp_unpinned_banner (comb), killed by sc_bank_refuses_post.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…dv-d2 F3)

In VICE the trust store read fails (no UCI), so trust_pre asks CONTINUE
UNPINNED? Y/N before the dial, after flushing the key buffer; the suite
never answered and hung on every shipped TRUST_STORE image. On those
images do_https_get now runs until it returns or parks on the question,
types Y (so every accepted target still reaches the dial stub), and
checks that it was asked; a refused target must never be asked. One more
case answers N: NOT DIALLED, nothing dialled. call() is untouched (#283).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
RIG_MODE=live streams the ~750 KB Wikipedia demo body; the shared 240 s
budget expired mid-body at 48 MHz (U64E fw 3.15 3a1ff9ff, PRG 1baa43a9:
KEY RECORDED never shown, 1/4). With FETCH_TIMEOUT=900 the same PRG
passed 4/4. Local mode keeps 240 s; FETCH_TIMEOUT still overrides both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@JC-000
JC-000 force-pushed the feat/p2-l3-policy branch from d1bb592 to 011209b Compare October 6, 2026 17:39
@JC-000
JC-000 merged commit 3ef5444 into master Oct 6, 2026
JC-000 added a commit that referenced this pull request Oct 6, 2026
#211 is closed: the stretch-goal bullet, the wikipedia-stall bullet and
the rig list no longer describe it as open (no replacement claim). "There
is no trust store, no root CAs" is false for the UCI images (#268/#269)
and the M3 demo; the chain-validation bullet keeps only the issuer skip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@JC-000
JC-000 deleted the feat/p2-l3-policy branch October 14, 2026 15:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant