Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,8 @@ already refused a step later, as `DF_ERR_TYPE = $04`). Test:
profiles, each table stamped with its PRG sha256; it exits 1 on a failed
link (ld65 still writes the map). Margins are per
profile — never carry a figure from one to another.
- 'Q' zeroes only the spans in `src/session_scrub.s`: a new secret buffer outside every span is silently left in RAM (add a SPAN).
After 'Q' BASIC owns $0801-$9FFF (the image): SYS re-entry is valid only before BASIC allocates anything (variables, strings, LOAD), and BASIC is unusable after such a SYS returns; rigs park the CPU (JMP *) or type only further SYS lines.
- `net_tcp_set_recv_cb` is an RTS stub. Boot banner: `rr-net` under ip65,
`UCI NETWORKING` under UCI — `boot_check.py` asserts both.

Expand Down
9 changes: 9 additions & 0 deletions docs/engineering-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -1863,6 +1863,15 @@ Five latent bugs and three new ones were cleared to get here:
runnable test — see the packaging validation record for the exact
invocation. Never set it for a REU-profile build: that is precisely
the silent-garbage case above.
- **Do not cap BASIC's MEMSIZ on 'Q' to protect the image.** After
'Q' BASIC owns $0801-$9FFF, and a cap (MEMSIZ = FRETOP = VARTAB, so
any allocation is ?OUT OF MEMORY) was tried and dropped in review:
every quoted string in direct mode, `LOAD"name",8` and `OPEN` with a
filename included, became ?OUT OF MEMORY, so nothing could be loaded
without a reset; and it did not hold anyway -- `OPEN 2,2` (RS-232)
copies KERNAL MEMTOP to MEMSIZ via $E0F9, after which `DIM` overwrote
2,819 B of the image. The rule is instead: SYS re-entry is valid only
before BASIC allocates anything.

### VIC-II blanking — worth 6.3%, not the fleet's "20-25%"

Expand Down
74 changes: 71 additions & 3 deletions src/boot.s
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@
; ---- exports: local BSS ----
.export net_initialized

; ---- imports: 'Q' secret scrub (src/session_scrub.s) ----
.ifndef BACKEND_UCI_M3
.import session_scrub
.endif

; ---- imports: entropy / DRBG / sqtab / crypto init ----
.import entropy_init
.import drbg_init_entropy
Expand Down Expand Up @@ -471,12 +476,75 @@ main_loop:
; 'Q' = quit
cmp #$51
bne main_loop
; falls through

; re-enable BASIC ROM
; =============================================================================
; quit_to_basic - 'Q': scrub the session, then rebuild BASIC and enter READY.
;
; Not an RTS into the SYS that started us. BASIC resumes by running CHRGET
; ($73-$8A) off TXTPTR ($7A/$7B), and trusts CURLIN ($39/$3A) and its
; pointers ($2B-$38) -- ZP the crypto uses as scratch: X25519's fe_wide is
; $40-$7F, nistcurves' fp_mul_i/j are $39/$3A, the TLS/ECDSA slots $02-$3F.
; After a handshake that RTS executed a field element as CHRGET and printed
; "?SYNTAX ERROR IN <garbage>".
;
; Instead: zero the secrets while $A000-$BFFF is still RAM, zero every ZP
; byte we use and the stack page (pushed operands), then run the cold
; start's own BASIC init minus its banner -- INITCZ (CHRGET from ROM,
; TXTTAB/MEMSIZ/FRETOP) and NEW -- and enter READY via the BASIC warm-start
; vector. The screen is kept, so a fetched body stays readable. INITV is
; left out: the $0300 vectors are not ours, and resetting them would unhook
; a cartridge or DOS wedge.
;
; NEW is deliberate: the image is not re-entrant from RUN (start does not
; reset the BSS outside $A000-$BFFF), so it is not left looking runnable.
; After 'Q' BASIC owns $0801-$9FFF, which is the image: variables grow up
; from $0803, strings down from $A000, and LOAD writes over it. SYS
; re-entry into the image is valid only before BASIC allocates anything,
; and a re-entry that runs crypto clobbers BASIC's ZP again exactly as
; before 'Q': BASIC is unusable after such a SYS returns. The tools/uci rigs
; park the CPU (JMP *) or type only further SYS lines, so they are fine, and
; session_scrub re-seeds the DRBG for exactly them. Do not cap MEMSIZ to
; protect the image -- see engineering-notes, Known issues.
;
; The BASIC init routines sit in the KERNAL ROM's BASIC tail and are not
; vectored; $E37B-$E45E is byte-identical in every C64 KERNAL VICE ships
; (901227-01/-02/-03, SX-64, 4064).
; =============================================================================
BASIC_INITCZ = $E3BF ; CHRGET/RND seed, TXTTAB, MEMSIZ, FRETOP
BASIC_SCRTCH = $A644 ; NEW: empty program, then CLR
BASIC_WARM_VEC = $A002 ; -> $E37B: CLRCHN, STKINI, CLI, READY.

quit_to_basic:
sei ; the stack page is about to be zeroed
.ifndef BACKEND_UCI_M3 ; (uci-m3: the keys live on the ESP32)
jsr session_scrub
.endif
lda #0
ldx #$7F - $02
@zp:
sta $02,x ; $02-$7F: every TLS/crypto/X25519 slot
dex
bpl @zp
ldx #$FF - zp_ptr
@zp_hi:
sta zp_ptr,x ; $FB-$FF: zp_ptr, zp_temp, zp_count
dex
bpl @zp_hi
inx ; X = 0
@stack:
sta $0100,x ; nothing below here is ever returned to
inx
bne @stack
quit_scrubbed: ; tools/test_quit_basic_exit.py stops here
ldx #$FB ; the cold start's stack pointer
txs
lda $01
ora #%00000001
ora #%00000001 ; BASIC ROM back in
sta $01
rts
jsr BASIC_INITCZ
jsr BASIC_SCRTCH
jmp (BASIC_WARM_VEC)

; =============================================================================
; do_net_init - initialize network (menu-driven)
Expand Down
146 changes: 146 additions & 0 deletions src/session_scrub.s
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
; =============================================================================
; src/session_scrub.s — zero the TLS session's secrets before 'Q' quits.
;
; boot.s's quit path calls this before handing the machine back to BASIC.
; Everything below outlives the connection that wrote it, and after 'Q'
; anything may read it: the next program, a freezer cartridge, or anyone on
; the LAN with the Ultimate's REST readmem. Each span is a session secret or
; scratch that held one:
;
; tls_ecdhe_privkey..tls_shared_secret ECDHE private key + shared secret
; (the two public keys between them)
; tls_hs_write_key..tls_app_read_iv handshake + application keys/IVs
; tls_nonce last per-record nonce (IV ^ seq)
; hkdf_prk..hkdf_okm HKDF PRK / output
; tls_early_secret..tls_master_secret key-schedule secrets
; tls_c_hs_secret..tls_finished_key traffic secrets, finished key
; sha256_h0..mul_src2_buf SHA-256 state and schedule (keyed
; by HMAC), the HMAC key input (HKDF
; and the Finished MAC key it), DRBG
; V and output, ChaCha20
; key + keystream, Poly1305 r/s,
; AEAD key, last multiply operand
; tls_rec_buf last decrypted record, and the
; X25519 sibling's whole scratch
; (x25_scalar = clamped private key,
; x25_result = shared secret): the
; cfg overlays X25519_SCRATCH on it,
; asserted in src/crypto/x25519_tables.s
; zp_save_buf ip65's copy of crypto ZP $02-$1B
; poly_prod_lo/hi last 8x8 product (Poly1305: key r)
; mul_dma_lo/hi last multiply row: a*b for every b,
; i.e. one operand byte (X25519's on
; a handshake that stopped before
; CertificateVerify)
;
; The multi-buffer spans zero whatever their module declares between the
; two ends, so each length is asserted exactly: a field added inside a span
; fails the link here, and whoever added it decides whether it is session
; state or must survive 'Q'.
;
; The DRBG's K is not in any span: it is private to src/crypto/hmac_drbg.s
; (tools/test_drbg_state_owner.py forbids naming it elsewhere). The tail
; call to drbg_init_entropy is what scrubs it -- instantiate sets K = 0,
; V = 1, then updates both from fresh SID/CIA entropy, exactly as boot does
; -- so it is not redundant with the spans. It is also what keeps a caller
; that SYSes back in after 'Q' (every tools/uci rig that drives http_get)
; from drawing all-zero "random" bytes out of the zeroed V and drbg_output:
; it sets drbg_buf_idx = 32. The state left behind is fresh, and no longer
; a function of the session.
;
; Not scrubbed, because none of it is key material: certificates
; (cert_buf), signature-verify scratch (LIB_NISTCURVES_P256_BSS: every input
; is public), the transcript hash, randoms, public keys, generated tables,
; the TCP ring (ciphertext), and the application data -- http_req_buf (the
; request, with the typed path), http_resp_buf (the response the screen
; shows, and possibly the typed path, which the prompt stages there) and, under
; HTTPS_BODY_TO_REU, the body in REU bank $10.
;
; A secret buffer added OUTSIDE every span below is left unscrubbed, and
; nothing fails: add a SPAN for it (tools/test_quit_basic_exit.py's ZEROED
; list is the test side).
;
; In: BASIC ROM banked OUT -- the zeroing would reach RAM either way, but
; the re-seed reads its state back through $A000-$BFFF.
; Out: Clobbers A, X, Y and crypto ZP (zp_ptr, zp_count, SHA temps); the
; caller zeroes ZP afterwards.
; Segment: CODE (LOADER) -- never under the ROM the caller is about to map in.
; =============================================================================

.include "constants.inc"

.export session_scrub

.import tls_ecdhe_privkey, tls_shared_secret
.import tls_hs_write_key, tls_app_read_iv
.import tls_nonce
.import hkdf_prk, hkdf_okm
.import tls_early_secret, tls_master_secret
.import tls_c_hs_secret, tls_finished_key
.import sha256_h0, mul_src2_buf
.import tls_rec_buf
.import zp_save_buf
.import poly_prod_lo, poly_prod_hi
.import mul_dma_lo, mul_dma_hi
.import drbg_init_entropy

; SPAN first, end, len: zero [first, end), which must be len bytes long.
.macro SPAN first, end, len
.word first
.word len
.assert (end) - (first) = len, lderror, .sprintf("session_scrub: span at %s is not %d bytes; review what was added inside it", .string(first), len)
.endmacro

.segment "CODE"

session_scrub:
ldx #0
@span:
lda scrub_spans,x
sta zp_ptr
lda scrub_spans+1,x
sta zp_ptr+1
lda scrub_spans+2,x
sta zp_count
lda scrub_spans+3,x
sta zp_count+1
ldy #0
@byte:
lda #0
sta (zp_ptr),y
iny
bne @count
inc zp_ptr+1
@count:
lda zp_count ; 16-bit decrement; every length is > 0
bne @lo
dec zp_count+1
@lo:
dec zp_count
lda zp_count
ora zp_count+1
bne @byte
inx
inx
inx
inx
cpx #scrub_spans_end - scrub_spans
bne @span
; NOT redundant: this is the ONLY scrub of the DRBG's K (drbg_k,
; private to hmac_drbg.s, in no span). Re-instantiates K/V from
; fresh entropy and sets drbg_buf_idx = 32. See the header.
jmp drbg_init_entropy

scrub_spans:
SPAN tls_ecdhe_privkey, tls_shared_secret + 32, 128
SPAN tls_hs_write_key, tls_app_read_iv + 12, 176
SPAN tls_nonce, tls_nonce + 12, 12
SPAN hkdf_prk, hkdf_okm + 32, 64
SPAN tls_early_secret, tls_master_secret + 32, 96
SPAN tls_c_hs_secret, tls_finished_key + 32, 160
SPAN sha256_h0, mul_src2_buf + 35, 1243
SPAN tls_rec_buf, tls_rec_buf + 548, 548
SPAN zp_save_buf, zp_save_buf + 26, 26
SPAN poly_prod_lo, poly_prod_hi + 1, 2
SPAN mul_dma_lo, mul_dma_hi + 256, 512
scrub_spans_end:
1 change: 1 addition & 0 deletions src/tls_keyschedule.s
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
.export tls_verify_finished
.export tls_verify_data
.export tls_c_hs_secret
.export tls_finished_key ; end of the session_scrub span

; HKDF primitives (hkdf.s)
.import hkdf_extract
Expand Down
8 changes: 8 additions & 0 deletions tools/run_all_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@
"python3 tools/test_ecdsa_kat_oracle.py (see C64_MAKE_ARGS in "
"its docstring to point it at a shipped profile)"
),
"test_quit_basic_exit": (
"ends the program: it presses 'Q', which zeroes the session "
"secrets and ZP, NEWs the BASIC program and banks BASIC ROM back "
"over $A000-$BFFF. Every suite after it in this runner's shared "
"VICE instance would read ROM where its buffers are. It also runs "
"two X25519 and two P-256 verifies (~3-4 min). Run it directly: "
"python3 tools/test_quit_basic_exit.py"
),
}


Expand Down
Loading