Repository navigation
PQC P0: pin libs/mlkem v0.6.0, place it behind PQ_HYBRID=1 (link-only) - #313
Merged
Merged
Conversation
Link-only: nothing calls ML-KEM. Code+rodata linked at $E000 (file="", not in the PRG: #288), LIB_MLKEM_POLY_BSS at $D000, LIB_MLKEM_BSS at $B100 over X25519_TABLES + hole + mul_dma, dk scratch in the TCP ring, ZP $40-$4F, shared sqtab at $BC00. Asserts in src/crypto/mlkem_link.s, scrub spans for all three secret homes, tools/test_mlkem_placement.py mutation-proves them. Unset, all seven profiles are byte-identical to master. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… entry Implements the 2026-10-13 decision: LIB_MLKEM code/rodata LOADS in the PRG at $A000 (the file bytes under CRYPTO_COLD_SHADOW's BSS, which boot zeroes anyway) and RUNS at $E000. No #288 relaxation: the PRG still ends at $BFFF (or $CFFF on comb). - cfg: CRYPTO_COLD_SHADOW becomes the RAM view (file=""), COLD_FILE the file view over the same $A000-$BFFF; unset, the PRG is byte-identical (all seven profiles). LIB_MLKEM_CODE/RODATA: load = COLD_FILE, run = MLKEM_CODE_RAM. - mlkem_link.s: load/run/size asserts (head of COLD_FILE, same layout as the run image, fits one 30-page block copy, ends inside the PRG's file content, below the $FFFA vectors); mlkem_boot_init (copy + RAM vectors -> RTI, skip unless fresh: flag AND nonzero stage); pq_mlkem_call, the only entry (banks $01=$34 under sei, runs mlkem_arith_init, jsrs the entry). - boot.s: jsr mlkem_boot_init right after sei, before the zeroing loop, with an assembler assert on the order. - tools/test_mlkem_linked.py: VICE boot of the PQ image, compares $E000.. to the PRG's $A000.. image, KeyGen and Decaps_seed against the reference across X25519 runs; three mutants go red. - measure_margins.py: RAM/file view aliasing, align_load, MLKEM_CODE_RAM row. - test_quit_basic_exit.py: the code and vectors survive 'Q'. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…on hardware; _prg_load allows the staged image span The $A000-$BFFF stage cannot be read back before the run (BASIC ROM) nor $E000 after it (KERNAL), so rig_mlkem_hw.py parks the CPU with all RAM banked in and compares. _prg_load counts and names the staged bytes instead of refusing them; $D000+ and other non-zero tails still refuse. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
JC-000
force-pushed
the
feat/pqc-p0-placement
branch
from
October 17, 2026 03:45
343a4f7 to
7281ac4
Compare
…ed wrappers + map-based entry ban, tail marker, I-flag test S1 _prg_load: non-zero bytes allowed only in [CODE load, tail marker + 1) from labels.txt and only on a build with pq_mlkem_keygen; $A000/$B000/$BFFF refuse on knob-off images again. S2 pq_mlkem_keygen/decaps_seed wrappers; any source outside mlkem_link.s naming a symbol the map puts in $E000-$FDFF fails. S3 docs (IRQ: sei is load-bearing). N1 I-flag restore test + mutant. N2 tail marker (LIB_MLKEM_MARK) replaces the $A000 gate; a cut-short LOAD is refused (red border, A=$FF/C=1). N3 comment. N5 rig_mlkem_hw takes scratch from labels and the arbiter; test_rig_scratch covers it with PQ fixtures. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…mbols, comb refusal border, C=0 and sqtab-collision checks SF1 loader keeps the stage span only if the PRG carries the tail marker at the labels' address; boot_check, refetch and rig_mlkem_hw pass their own labels. SF2 entry ban scans *.inc and exempts nothing by prefix. SF3 boot.s re-asserts the red refusal border after the comb precompute (VICE-tested, red without the fix). SF4 success C=0 asserted, mutant (e). N5 marker address vs sqtab bytes (link assert + collision check). N7 vectors written first. N6 docs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…try check, m3 labels, docstring Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PQC P0: pin libs/mlkem v0.6.0 and place it behind
PQ_HYBRID=1Placement only; nothing in the protocol calls ML-KEM. Rebased on master 7b1ed07 (#312).
Delivery (user decision 2026-10-13): loads at $A000, boot copies to $E000
ML-KEM code+rodata LOADS in the PRG at $A000 and RUNS at $E000. The file bytes under it are CRYPTO_COLD_SHADOW's BSS zero fill, which exist anyway, so the PRG is unchanged in size, ends at $BFFF (uci) / $CFFF (comb), and
test_prg_io_rangestays green. No #288 relaxation.CRYPTO_COLD_SHADOW(RAM view,file="", all BSS, unchanged names/addresses/symbols) andCOLD_FILE(file view,file=%Ozero fill, hosts the load image). Unset, the PRG is byte-identical on all seven profiles (hashes below).LIB_MLKEM_CODE/RODATA: load = COLD_FILE, run = MLKEM_CODE_RAM,align_load = $40.mlkem_link.s): image loads at the head of COLD_FILE ($A000), runs at $E000, load layout == run layout (one block copy), fits the 30-page copy, ends inside COLD_FILE (the PRG's file content), run image below the $FFFA vectors.mlkem_boot_init(NET_CODE) is called from boot.s right aftersei, before the $A000-$BFFF zeroing; an assembler assert pins that order (it compares labels; the linked test is the real guard). BASIC is already out, so $A000 reads RAM; writes to $E000 land in RAM under the KERNAL. It decides on the one-byte tail marker, not a nonzero-byte gate: flag already set -> nothing; marker at the load address -> copy; no marker but one at $E000 -> resident, flag re-armed; else REFUSE (red border,pq_mlkem_*return A=$FF C=1). 'Q' does not touch $E000 or the vectors (code is not secret; no scrub span there). The flag is not scrubbed.mlkem_boot_init, so every$01=$34window (the boot's, the scrub's, a call's) has a valid target. A RESTORE NMI returns. IRQ/BRK share the RTI, which is safe only because every call runs undersei: the RTI acknowledges nothing, so theseiis what prevents a level-triggered IRQ livelock.pq_mlkem_call(A/X = entry) is the only way in:php/sei,$01=$34,mlkem_arith_init, the entry, restore.test_mlkem_placement.pyfails on any other reference to a library entry. Themlkem_arg_*block lives in the same window X25519 rebuilds: fill it after the last X25519/fp_mul.Review round (adversary #313): what changed
_prg_load.pyallows non-zero bytes ONLY in[__LIB_MLKEM_CODE_LOAD__, tail marker + 1)frombuild/labels.txt, and only when the labels carrypq_mlkem_call. A knob-off image with a byte at$A000/$B000/$BFFFrefuses again; so does a PQ image with a byte just past its end; the PQ image itself is accepted.test_prg_load.py(13/13) restores the refusal list and pins all three cases;test_mlkem_placementalso checks the real PQ PRG against the loader.pq_mlkem_keygenandpq_mlkem_decaps_seed(a client needs KeyGen and Decaps-from-seed only; Encaps and Decaps-with-dk have no wrapper because nothing calls them). The entry rule now reads the map's export list: ANY source outsidemlkem_link.snaming a symbol the linker puts in$E000-$FDFFfails (27 symbols, e.g.keccak_f1600,mlkem_poly_ntt), and the wrappers are callable from anywhere. Red cases for both directions in the suite.seiis LOAD-BEARING (CIA1 IRQ is level-triggered and unacknowledgeable with I/O out, so an IRQ reaching the RTI would livelock). Byte figures removed from the docs (CLAUDE.md: margins are measured, not written down); they live here.cli) and set (sei); mutant (d),php/plpreplaced bynops, now goes RED (P=$34for theclicase).lda $A000 / beqgate is gone. A one-byte tail marker (LIB_MLKEM_MARK,$5C, last byte of the load image) is checked before the copy. No marker at the load address: if$E000's marker is there the image is resident (flag clobbered after 'Q') and the flag is re-armed; otherwise REFUSED: border red,mlkem_copiedstays 0,pq_mlkem_*returnA=$FF, C=1and run nothing. Tested both ways in VICE (a PRG with the marker zeroed; flag cleared with the stage gone).labels.txt/the memory policy on knob-off builds gain the__MLKEM_{CODE,BSS,POLY}_RAM_*,__COLD_FILE_*and__LOADADDR_*symbols, and__CRYPTO_COLD_SHADOW_FILEOFFS__moves, so_memory_policyreserves$B100-$BBFF,$D000-$DFFF,$E000-$FDFFand$07FF-$0800there. Harmless today (no rig scratch lives in those); not avoided because the areas must exist in the cfg.rig_mlkem_hw.pytakes its scratch fromlabels.txt(cert_buf,cert_buf_size,tcp_recv_buf) and the page-3 arbiter;test_rig_scratchnow covers it (PQ fixturesuci-onchip-pq,uci-comb-pqadded totools/rig_scratch_profiles.json, 18/18). Both rig phases run AFTER 'Q', so no in-session call (KeyGen between the menu and a connection) is proven yet: that is P4's job.src/constants.inc, next to the dk rule: the 64 B d||z seed needs a home and a scrub span (P5), and ML-KEM never interleaves with X25519/fp_mul.Review round 2: what changed
$A000/$B000/$BFFF: refused; labels for another image: refused; PQ image with no labels: refused, the false red that rigs avoid by passing their own labels).boot_check.py,rig_https_refetch.pyandrig_mlkem_hw.py(the PRG-override callers; the rest use the repo's own build/) pass the labels next to their PRG.test_prg_load.py13/13 carries the cases as red/green.*.incas well as*.sand exempts nothing by prefix (__LIB_MLKEM_CODE_RUN__and friends count; 33 symbols). Red cases: an.importin an.inc, a__LIB_MLKEM_*reference. A second check reads the ld65 map's Imports list: no module outsidemlkem_link.oand the library's own archive members may import a banned symbol; a real probe module using.import .ident(.concat("mlkem_","keygen"))links, is missed by the source scan and is caught by the Imports check (zz_probe.o). Known gaps, accepted: a literal address (jsr $E123) and arithmetic on a non-banned symbol (__LIB_MLKEM_CODE_LOAD__+$4000).mlkem_copiedis 0.test_mlkem_linked.py --combboots the comb PQ image with the marker zeroed in VICE with-reu(~140 s) and checks$D020. The comb refusal is guarded ONLY under--comb(the default run does not). The border mutant (boot.s without the re-assert) was a MANUAL run, not automated: RED ($D020=E; the adversary reproduced it, 28/29), green with the fix.clcdeleted, is RED.n*n/4) and fails on a collision; the known collisions$BC67/$BD99are red cases. The real marker load address is$BD41(run$FD41, stage[$A000,$BD42)) on all four PQ profiles; there the table holdssqtab_lo[321] = $A0, not the marker. The test reads the address fromlabels.txt. Known limit, not fixed: case 3 ("resident") cannot tell which build left the image.test_mlkem_linkeddocstring lists mutants a-e and the refusal phases.labels.txtalso gainsboot_zbss_first(all backends, ip65 too) andmlkem_copy_siteis PQ-only; the PRG bytes are unchanged.Earlier content of this PR (unchanged)
libs/mlkemat tag v0.6.0 (a00444a1) +build_mlkem.sh(discovered members, od65 checks, #124-style stale-checkout probe);PQ_HYBRID=1on uci onchip / comb, with or without TRUST_STORE,$(error)on ip65, uci-m3, uci REU (its fp_mul DMA lands in mul_dma). Polys $D000 (4,096 B), LIB_MLKEM_BSS $B100-$BAF0 (2,545 B) over X25519_TABLES + $B900 hole + mul_dma; dk scratchmlkem_dk_scratch = tcp_recv_bufwith ring >= 2,400 asserts (comb's COLD_IMAGE at $C000 is dead aftercold_bank_init; the trust store/bundle also stage in the ring, so KeyGen goes aftertrust_pre); ZP $40-$4F (only co-tenant X25519fe_wide); shared sqtab $BC00; scrub spans for window / polys (undersei,$01=$34) / dk.Known gaps (not fixed here)
LIB_SHARED_SQTAB_BASEbaked into a sibling archive cannot be checked (never exported, immediates): editing the wrapper's0xBC00links clean. This is a known gap across all three siblings (nistcurves, x25519, mlkem).mlkem_link.sasserts the equivalent and a test fails on any area with neither.trust_pre, zero dk before the firstnet_poll" is a comment at the equate._prg_load.pycan no longer prove the $A000 stage before the run (BASIC ROM view) nor $E000 after it (KERNAL view); it now counts and names the staged bytes andrig_mlkem_hw.pyproves the copy.Byte identity, knob unset. Compared against NEW master 7b1ed07 (all seven identical)
PQ_HYBRID=1 PRGs: uci-onchip
6733fe2577358fce6e04d28fb6acd25d5147a89e06e0c464e49f781df478aa6f, uci-comb667befa60939ac1cda2411d2696fa3738d4125f9a90d9b881e45c7d2f284591d, onchip-store5dc459c2bc038399c36b821b00a28b5f3e7fb75d86c4e85b692c70fc69e4c418, comb-storee83f2c16cf48ab55eaea7f000c3ddf9290e8768eb7b948ae283b42b8f89b62b8. Knob-off hashes re-proved against master 7b1ed07 after this round: all seven identical (table above); the only knob-off change is the extraboot_zbss_firstlabel.Margins (
measure_margins.py --build, tail free bytes; each table's PRG hash above)ML-KEM's measured cost against the same master: LOADER 24 B (uci) / 34 B (comb) (616->592, 199->165, 594->570, 84->50 on comb: the boot.s call, the scrub spans and, on comb, the border re-assert), NET_CODE 149 B (538->389, 392->243; boot copy, marker logic, vectors, wrappers,
pq_mlkem_call). (#312 itself cost ~197 B of NET_CODE.)measure_margins.pynow reports the RAM/file views as one region and theMLKEM_CODE_RAMrun area.Red-green evidence
tools/test_mlkem_placement.py194/194 (cfg shape, knob matrix, 5 green links each also running the map-based entry rule (*.sand*.inc, no prefix exemption), the loader check and the marker/sqtab collision check, 18 mutants each failing with its own assert text, stale-checkout probe, restored relink).tools/test_mlkem_linked.py38/38 with--comb(34/34 without) (VICE, onchip withC64_VICE_NO_REU=1; comb with-reu): image == the PRG's $A000 bytes, vectors -> RTI, re-run harmless, flag re-armed from the $E000 marker, the I flag survives a call (clear and set) and success returns C=0, X25519 -> KeyGen (ek, dk exact) -> X25519 -> Decaps_seed (K exact; flipped ct differs),$01restored, a marker-less PRG is refused (flag 0, red border, nothing copied,A=$FF C=1) on onchip AND on comb (border survives the precompute). Mutants, all RED: (a) no copy call, (b) copy after the zeroing, (c) nomlkem_arith_init, (d) nophp/plp, (e) noclc; plus the comb border mutant (boot.s without the re-assert:$D020=E).Other suites
BACKEND=uci USE_NISTCURVES_ONCHIP=1 PQ_HYBRID=1 tools/run_all_tests.py: 448/448, 3 suites skipped (net, reu_row_abi, ip65_exports; by design).test_quit_basic_exit.pyPQ: 74/74.test_prg_load13/13,test_rig_scratch18/18,test_measure_margins19/19,test_device_prep35/35,test_build_flags_stamp21/21,test_pins,test_prg_io_range,test_runner_coverage,test_pytest_boundary0 failures.Hardware (U64E 10.43.23.81, DeviceLock, 48 MHz)
rig_mlkem_hw.py, PQ uci-onchip6733fe25...78aa6f: 7/7. PQ uci-comb667befa6...84591d: 7/7 (REU enabled by the prep). Both: image == the PRG's $A000 bytes, vectors, KeyGen ek/dk and Decaps_seed K match the reference. Both phases run after 'Q', so no in-session call is proven yet (P4).boot_check.pyandrig_https_local.py(earlier rounds passed; the boot path changed by a handful of bytes, covered by the rig's boot).🤖 Generated with Claude Code