Files
Robert Allan JamesandClaude Sonnet 5 6302dcb50e
Build / build-amd64-iso (push) Canceled after 0s
Build / build-aarch64-iso (push) Canceled after 0s
Build / build-riscv64-img (push) Canceled after 0s
FABRIC-3.7.md: record Phase 8 v3 closure (in-system block-copy defense)
Distinguishes it clearly from the still-accepted "cloned outside
StarshipOS entirely" limitation this document already settled -- Phase
8 v3 closes a narrower, different threat: cloning block content using
StarshipOS's own console primitives, now refused by MOVE/CMOVE/CMOVE>/
RELOCATE-BLOCK for cross-device copies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 04:45:06 -04:00

14 KiB
Raw Permalink Blame History

FABRIC-3.7.md — Phase 8 PKI: the elevation entrypoint

Status: OPEN — design only, no code written or authorized.

CORRECTION (2026-09-22, before any code was written against this document): §2's central claim — that the old SEND-ELEVATE-REQUEST passed a raw cross-VM address into ELEVATE-GRANT's waddr — is wrong. Found while starting Part A's implementation: reading the actual deleted source (git show 3e201c8^:capsules/common/messaging.4th, block 5040) shows SEND-ELEVATE-REQUEST copied the target word's name as literal character bytes (via ELEVATE-REQ-APPEND's CMOVE) into a scratch buffer, building the text S" <name-text>" <pk0> <pk1> <pk2> <pk3> ELEVATE-GRANT, and sent that whole string to Hera. When Hera's own interpreter runs S" <name-text>", it allocates a fresh string in Hera's own memory and pushes Hera's own valid address — no numeric cross-VM address ever appears anywhere in this flow. §2 was written from ELEVATE-GRANT's signature alone, assuming the caller forwarded a raw address, without first reading how the caller actually built its message. It didn't.

What is still real, much narrower than originally claimed: if a future caller ever spliced attacker-influenced text into the name field without checking for an embedded " character, that could break out of the S" ... " literal early and inject arbitrary FORTH source, executed with Hera's privilege. That's an input-validation discipline question for whoever writes the new caller (validate: no embedded ", or just always use a compile-time-fixed literal name, never a runtime-supplied one) — not an architectural cross-VM-memory defect requiring the buffer/message redesign §3 originally called for. §3's proposed mechanism (Hera-side fixed receive buffer, kernel-constructed integer-literal-only command) is not needed — the original text-copy design was already safe against the bug as actually diagnosed. Kept below, struck through, for traceability, per this series' own rule against silently rewriting a prior state.

Successor to FABRIC-3.5.md/FABRIC-3.6.md (both CLOSED/ARCHIVAL at v2.1.0) for exactly one topic: the Ed25519-challenge-response elevation entrypoint that .claude/CLAUDE.md's ACL section names as Phase 8, the last open item in the word-level ACL system. This is a new document, not a reopening of FABRIC-3.6.md — that document's own close header says future fleet work gets its own document, and this is that.

Provenance. Written 2026-09-22, immediately after FABRIC-3.6.md's close, from a design conversation with Captain Bob about a security concern he raised directly: how to rebuild the elevation entrypoint that Phase 4's Category B strip left dangling, without reopening a hole. The design below was proposed, and Captain Bob asked for it in writing here rather than left only in session memory.


1. What's dangling, and why

FABRIC-3.6.md Phase 4 (Category B strip, 2026-09-22) deleted capsules/common/messaging.4th after every FORTH-owned message type had been cut over to kernel-Hermes. One casualty was collateral, not intended: SEND-ELEVATE-REQUEST (messaging.4th block 5040) was the only caller of both KH-ELEVATE-SEND (src/starkernel/repl.c) and, transitively, ELEVATE-GRANT (capsules/zuse-eligibility.4th, blocks 4021–4022). All three still exist in the tree. ELEVATE-GRANT is still loaded at boot (capsules/init.4th:18). Nothing can call it any more.

Captain Bob's decision at the time (FABRIC-3.6.md's own Phase 4 entry): leave it unreachable, don't patch a caller back in as part of that strip. Phase 8 builds its own entrypoint instead of resuming this one. This document is that entrypoint's design.

Original §2/§3 (WRONG — see the correction at the top of this document; kept for traceability, not current design)

2. The security hole in the old mechanism — found before any code was written

ELEVATE-GRANT's signature, unchanged since it was written:

ELEVATE-GRANT ( waddr wu pk0 pk1 pk2 pk3 -- )

waddr/wu are an address/length pair meant to point at the string naming the word to elevate. pk0–pk3 are the caller's Ed25519 pubkey, packed 8 bytes per cell (ELEVATE-PUBKEY-UNPACK, mama_forth_words.c).

The old SEND-ELEVATE-REQUEST computed waddr in the sending VM's own address space, but ELEVATE-GRANT always executes on Hera (ELEVATE-GRANT always runs on Hera — repl.c's own comment on KH-ELEVATE-SEND, still there). A word's name string lives in the sending VM's memory. ELEVATE-GRANT dereferences waddr in Hera's memory. Those are not the same address space by construction — vaddr_t is per-VM.

Consequence: whoever controls waddr controls what bytes NAME>XT reads and resolves as a word name, in Hera's dictionary, not the caller's. This is not "the string might be malformed" — it is a primitive for making Hera's own ELEVATE-GRANT grant ACL-ALLOW!/ACL-TTL! on whatever dictionary entry the attacker's chosen waddr happens to land on, regardless of what word name the caller claims to be requesting elevation for. A caller who can influence waddr at all — not forge a signature, not defeat zuse_eligibility_is_member(), just choose a number — has a privilege-escalation primitive against the fleet governor.

This was never exploited (the entrypoint has had zero live callers since the file that called it was deleted), and is reported here as a design defect found by inspection, not a live incident.

3. The fix: never cross an address, only ever cross bytes

This project already solved the general version of this problem once, this same session (FABRIC-3.6.md tasks 3.8/3.9, the payload-aliasing fix): a kernel-Hermes message's payload must be copied into the message's own storage, never a pointer into the sender's memory that might be reused or freed before the receiver drains it. SkHermesMessage.payload_buf (include/starkernel/vm/kernel_hermes.h:160, SK_HERMES_CHUNK_MAX_PAYLOAD = 1024 bytes) is exactly that fix, already built, already proven on all three architectures.

The elevation entrypoint's hole is the same defect one level up: an address crossing a boundary it isn't valid on the other side of. The fix generalizes directly:

  1. Never send waddr/wu across the kernel-Hermes boundary. Send the pubkey (32 bytes, already the right shape for payload_buf) and the target word's name, as literal bytes, copied inline into the message payload — not an address, the actual characters. This is already how CONSOLE-CMD-EVENT's payload works (a command string's bytes, not a pointer to one), so this isn't a new pattern, it's applying the existing one to the one caller that still passed a raw address.

  2. On receipt, kernel-Hermes's C drain-checkpoint copies those name bytes into a small, fixed, kernel-owned buffer that already lives in Hera's own VM memory — a receive-side mirror of the existing send-side pattern (g_kh_elevate_buf, repl.c:445, is the already-built precedent for "a static buffer this mechanism owns"; this needs its Hera-side counterpart). The buffer's address is a compile-time constant, known to the kernel, never computed from anything the caller supplied.

  3. The FORTH command handed to vm_interpret() on Hera references only that fixed buffer's address and length as plain integer literals. Both are always kernel-controlled. Neither is ever derived from caller input. ELEVATE-GRANT itself does not change — same signature, same zuse_eligibility_is_member() check, same ACL-ALLOW!/ACL-TTL! grant. Policy logic stays in FORTH, per ACL.4th's own rule (no new C primitive for policy) — this fix is entirely about how bytes get from one VM to another, not about who is allowed to grant what.

Why this closes the hole structurally, not by validation: there is no string to sanitize and no address to bounds-check, because the interpreted command never contains anything an attacker touched except opaque data bytes that get copied, never dereferenced as a pointer, by the receiving side. The class of bug (cross-address-space pointer confusion) becomes impossible by construction, the same way payload_buf made use-after-free impossible by construction rather than by careful lifetime tracking.

2 (corrected). What the old mechanism actually did, and the one real gap in it

Re-read from the actual deleted source (git show 3e201c8^:capsules/common/messaging.4th, blocks 5039–5040): SEND-ELEVATE-REQUEST ( pk3 pk2 pk1 pk0 waddr wu -- ) used waddr/wu only to CMOVE the target word's name bytes, as text, into a scratch buffer (ELEVATE-REQ-BUF/ELEVATE-REQ-APPEND) it owned — building the literal string S" <name-text>" <pk0> <pk1> <pk2> <pk3> ELEVATE-GRANT entirely in the sending VM's own memory. Only that finished string — not waddr itself — went to KH-ELEVATE-SEND and across to Hera. When Hera's interpreter runs S" <name-text>", Hera's own S" allocates a fresh string in Hera's own memory and pushes Hera's own valid address. waddr/wu never cross the VM boundary as numbers at any point — only as copied character content. There is no cross-VM pointer dereference anywhere in this flow.

The one real, much narrower gap: the name text is spliced into S" ... " with no check for an embedded " character. If a future caller ever passed attacker-influenced text as the name (none ever did — the word had zero live callers), a " in the name would close the string literal early and let the rest of the name execute as raw FORTH source, with Hera's privilege. This is a caller-discipline / input-validation question, not an architectural defect: either always use a compile-time-fixed name literal at the call site (no runtime input, no risk at all), or validate for an embedded " before building the command if a name ever does need to come from something less trusted than the call site's own source code.

Net effect on Phase 8 v1's scope: Part A, as originally conceived in §3 above, is not needed. If a SEND-ELEVATE-REQUEST replacement is ever built, it can follow the original text-copy design as-is, with the one-line "-check added if and only if the name is ever runtime-supplied rather than a fixed literal. No kernel-Hermes/repl.c changes required. Part B (capsules/zuse.4th, gating ZUSE-ELIGIBILITY-ADD) stands on its own, independently verified, unaffected by this correction.

4. What Phase 8 actually needs to build

Corrected per §2's re-read above. Concretely, when Phase 8 next picks this up:

  • If a caller into ELEVATE-GRANT is ever needed again, rebuild it close to the original SEND-ELEVATE-REQUEST shape (ELEVATE-REQ-BUF/ELEVATE-REQ-APPEND/text-copy into S" ... ") — it was already safe. Add the one-line embedded-" check only if the name is ever runtime-supplied rather than a call-site literal. No kernel_hermes.c/kernel_hermes.h/ repl.c changes needed for this.
  • ELEVATE-GRANT unchanged either way.
  • Part B is done (capsules/zuse.4th, committed and three-arch verified this session, 2026-09-22) — ZUSE-ELIGIBILITY-ADD denied by default, granted only inside ACL-ZUSE-BOOT's authenticated branch. This closes the actual "grant yourself eligibility with no real drive at all" path — a real, independently-confirmed gap, unaffected by this correction.
  • Defending against a cloned drive — settled, 2026-09-23: not going to happen, by design. Today, WIREBIND/MINT trust whatever identity is stored on an attached thumbdrive with no challenge at all (confirmed by grep: no ed25519_sign/ed25519_verify call anywhere in capsule_wirebind.c or capsule_mint.c), and the private key seed itself is stored in plaintext on the drive, read in the same devblock as the pubkey/cert. This is accepted, not a gap. Captain Bob, directly: "nothing like a pin or a password or secret code or any bullshit... Everybody has secrets. There's only the drive." Physical possession of the drive is the entire, deliberate credential model — a byte-for-byte clone being equivalent to the real drive is the accepted design, not a defect to close. A PIN/passphrase second factor was built, live-tested on all three architectures, and fully reverted before commit (/home/rajames/.claude/plans/jiggly-cuddling-stallman.md, now marked rejected; memory feedback_no_knowledge_factor_identity) — do not revisit a knowledge-factor approach here. Any future work in this space needs a fundamentally different mechanism (not something typed and known) or stays an accepted limitation.
  • Phase 8 v3, 2026-09-23 — done, a distinct and narrower concern from the item above. The "accepted limitation" above is about a drive image copied outside StarshipOS entirely (e.g. imaged on an external computer) — that's still accepted, unchanged by this item. Captain Bob separately asked to close a narrower, different threat: cloning a device's block content from within StarshipOS's own console, using its own stock, unpinned words (<src> BLOCK <dst> BUFFER 1024 MOVE/RELOCATE-BLOCK). That's now closed — MOVE/CMOVE/ CMOVE>/RELOCATE-BLOCK all refuse a same-VM, cross-device copy, verified live on all three architectures. See /home/rajames/.claude/plans/jiggly-cuddling-stallman.md's "Phase 8 v3" section for the full design and verification record. The identity record itself (seed/pubkey/cert) was already unreachable from FORTH before this — this closes the one real gap the research found: ordinary block content, not the identity record.

5. What this document is not

Not a reopening of FABRIC-3.6.md, not a change to anything currently built, not an authorization to write code. Per this series' own convention: design here, execution gets its own document when the work actually starts, the same relationship FABRIC-3.5.md had to FABRIC-3.6.md.