Skip to content

#74 ABI half: HostCtx is a kernel-tagged handle verified before use (no host pointer crosses) - #1007

Open
MattJackson wants to merge 6 commits into
predevfrom
lane-proof-abi-mac
Open

MattJackson wants to merge 6 commits into
predevfrom
lane-proof-abi-mac

Conversation

@MattJackson

@MattJackson MattJackson commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

BUSBAR-1.6.0.md Part 2 #74 (l.2274): the half that covers the mechanism door's untrusted ABI. DRAFT: security-critical; a fresh reviewer re-checks before undraft.

#74 is NOT closed by this PR. The HOT lane's HostCtx (crates/busbar-contract/src/abi/hot/host.rs:61-71, kernel plane_host) still sends a raw pointer. #74 closes only when abi/hot is deleted (row 84: #898, #899, #904 and #960's chain).

Defect. The mechanism HostCtx was the address of the instance's leaked InstanceWake. wake, every host service, every connector slot and every I/O slot dereferenced whatever value the plugin handed back.

Change.

  • HostCtx { handle: u64 }, where handle = tag<<24 | id. The tag is 40 bits of SipHash-2-4 under a 128-bit key from the OS random source.
  • The key is drawn at loader boot: Dispatcher::try_new/try_with_services return LoadError::Entropy on failure, and busbar's main exits 1. There is no fallback key.
  • handle::resolve runs these checks in order:
    1. The constant-time tag check, before the table is read.
    2. The table entry exists.
    3. Inside a crossing, the handle must name the crossing's own instance. This runs before the revoked check, so another instance's handle is a forgery even if revoked.
    4. The entry is not revoked. Revocation happens on quarantine, watchdog fault (ticketed and ticket-less), close, drop, or a bind refused after mint.
    5. Outside a crossing, the latch is re-checked.
  • A forgery inside a crossing quarantines that instance (N=1), and the forging op itself answers FAULT: its answer is not judged and its envelope is not ingested.
  • A forgery outside any crossing counts against a process-wide latch. At K=16, every off-crossing resolve is refused, with one ERROR line.
  • DispatchStats gains forged_handles and handles_latched.
  • The crossing mark is set only through with_crossing(inst, || ..). The inline HOT-lane plane and transport slots run without_crossing.

Layout. HostCtx stays 8 bytes, align 8. The golden changes one field name and the header renders uint64_t handle. No R9 re-seed.

Operator-visible effects and trade-offs

  • The latch is a deliberate denial-of-service trade-off. One hostile plugin that forges 16 contexts from outside any crossing makes the host refuse every off-crossing call for the rest of the process. That includes honest plugins' background wakes. While the latch is tripped, /healthz answers 503 with the neutral line "plugin dispatch halted; restart required". The detail is in the latch's ERROR log line only. No new /metrics family is added; the count will ride /metrics: the dispatcher's bb_* counters, one HELP/TYPE family each (stacked on #952) #971's quarantine family once it exists.
  • Effects already committed stay committed. A host service that succeeded earlier in the same crossing as a forgery keeps its effect: a written record, a sent wake. The quarantine stops everything after the forgery.
  • The revoke/use race is memory-safe. An entry can be revoked by another thread after resolve has loaded it. That entry is still a leaked, valid InstanceWake, so the call in flight finishes against an instance whose next op is refused.

Follow-up (stacked draft #1018): the ticket half.

RED tests (each judged on a latch of its own; none reads the process-wide counter):

  • handle_tests:
    • SipHash vectors
    • six forgeries, including the leaked entry's own address
    • a revoked handle is refused and not counted
    • the latch trips at K and refuses off-crossing resolves while a crossing's own still resolves
    • failing entropy is refused
  • dispatch_tests:
    • the forging tick (FFI-worker lifecycle path) answers FAULT, the instance is quarantined and its handle revoked
    • in a real crossing, another instance's (revoked) handle is a forgery, quarantines the crossing, and leaves the latch unmoved
    • an inline slot's forgery blames no one and restores the outer mark
    • close, drop, ticketed watchdog fault and ticket-less watchdog fault all revoke, with no forgery counted
    • a boot (try_new) and a bind with failing entropy are refused
  • crates/busbar/tests/plane_forged_context.rs: FORGE through a non-lifecycle op (a plane's arrive) answers FAULT and quarantines.
  • host_services_tests: a forged ctx (tag bit, moved id, the entry's raw address) is refused by clock.now and wake.
  • endpoints: /healthz answers 503 while the latch is tripped.

…ied before it names anything

BUSBAR-1.6.0.md Part 2 #74 (l.2274): "the handle a plugin holds is kernel-MAC'd ... the kernel
verifies it on every ABI call". The mechanism's HostCtx was the address of the instance's leaked
InstanceWake, and host_wake, every host service (services.rs served/caller/credential_kinds/
destination), every connector slot (conn_services.rs armed/warn_unverified/ticket_waker) and every
I/O slot (io_slots.rs caller) dereferenced whatever pointer the plugin handed back: a forged context
was undefined behaviour inside the host, not a refused handle.

busbar-contract abi/mechanism/ticket.rs: HostCtx { handle: u64 } (NONE = 0). The same 8 bytes and
alignment as the pointer, so no struct that carries it moves: the layout golden changes one field
name (MechHostCtx.ptr -> .handle) and the C header renders uint64_t handle.

plugin-loader dispatch/handle.rs: handle = tag << 24 | id. id indexes the host's instance table;
tag = the high 40 bits of SipHash-2-4(key, [domain, id]) with a 128-bit key from getrandom at the
first bind (host memory only, never logged; the #75 channel-2 residual stands). resolve() recomputes
the tag and compares it in constant time before the table is read; a handle the host did not mint
names nothing, is counted (handle::forged), and the entry answers as it answers no context. Every
former pointer dereference goes through resolve(). A bind past 2^24 instances is refused
(LoadError::HandlesSpent) rather than a handle reused.

RED: host_services_tests::a_forged_context_is_refused_before_it_names_an_instance (tag bit off, id
moved, a raw host address: clock.now REFUSED, wake wakes nothing, both counted; control: the minted
context serves and wakes). handle_tests: SipHash-2-4 reference vectors; only a minted handle
resolves (six forgeries, each counted; NONE is not a forgery).

Test doubles hand HostCtx::NONE where they handed a null pointer.
… = 1, one guess)

The coordinator's bar for the 40-bit tag: a failed verify quarantines the calling instance, so no
plugin gets a second guess. Every crossing now states its instance on its thread for the span of
the op (plugin.rs CROSSING, set on the caller's thread and on the FFI worker for lifecycle ops,
restored for nested crossings). handle::resolve's refusal counts the miss and calls
plugin::quarantine_crossing: the crossing instance is faulted exactly as the watchdog faults a hung
one (never called again on any worker, every later op FAULT, its connector streams purged) and a
WARN names the plugin. A call from a plugin's own thread outside any crossing names no caller and is
refused and counted.

RED: dispatch_tests::a_forged_context_quarantines_the_instance_that_forged_it: the test plugin's new
FORGE tick hands wake a context one tag bit off; the instance is faulted and its next op is FAULT;
control: an honest instance on the same dispatcher is never faulted.
…ng op, key at boot, own-instance check, closure-only crossing mark

Rulings on the review of e84c5dc, in one change:
1. LATCH: a forgery outside any crossing names no instance to quarantine, so it counts against a
   process-wide latch (handle::Latch); at LATCH_AT = 16 every resolve made outside a crossing is
   refused for the rest of the process, with one ERROR line; DispatchStats carries forged_handles and
   handles_latched. A revoked handle is no forgery (its tag verifies).
2. REVOCATION: InstanceWake.revoked, set by Instance::revoke (quarantine, watchdog fault), on close,
   and on drop; resolve refuses a revoked entry after the tag check.
3. The forging op itself answers FAULT: after the crossing returns, a faulted instance's answer is
   neither judged nor ingested.
4. KEY AT BOOT: drawn when the loader boots (Dispatcher::build -> handle::arm) and at the latest by
   the first bind; a failing OS random source refuses the bind with LoadError::Entropy. The
   RandomState fallback is deleted.
6. Inside a crossing a context must name the crossing's own instance; another instance's handle is a
   forgery (the interim bound before the CallId ticket).
7. The crossing mark is set only through with_crossing(inst, || ..) (and without_crossing for the
   inline HOT-lane plane and transport slots, plane.rs and transport.rs, which null it).

REDs: handle_tests -- the raw address of the leaked entry itself, a revoked handle refused and not
counted, another instance's handle refused inside a crossing, the latch tripping at K and refusing
every off-crossing resolve (a crossing's own still resolves), a failing entropy source refused;
dispatch_tests -- the forging tick (the FFI-worker lifecycle path) answers FAULT, the instance is
quarantined and its own handle revoked, and a boot whose entropy fails is refused through the test
seam; host_services_tests -- the raw-address case is the entry's own address.

The HOT lane's HostCtx (abi/hot/host.rs, kernel plane_host) still carries a raw pointer; it goes
with abi/hot (row 84).
…oot refusal, readiness on the latch

1. REDs added: a closed, a dropped and a watchdog-faulted instance's handle names nothing and is no
   forgery (dispatch_tests); a forgery inside an inline HOT-lane slot nested in a crossing blames no
   one and the outer mark is restored (without_crossing under with_crossing); FORGE through a
   NON-lifecycle op (a plane's arrive, crossed on the caller's thread, crates/busbar/tests/
   plane_forged_context.rs) answers FAULT and quarantines.
2. No test asserts on the process-wide counter any more: Latch carries its own forged/unattributed
   counts and the tests judge a latch of their own; the in-crossing case runs under a real crossing
   (with_crossing on a loaded instance) and asserts the quarantine and an unmoved latch.
3. The boot refusal is real: Dispatcher::try_new / try_with_services draw the key first and answer
   LoadError::Entropy; new / with_services panic on it rather than run keyless; busbar's main draws
   the key before the dispatcher and exits 1 on a failing source. RED: a boot (try_new) and a bind
   through the seam, both refused.
4. Readiness: /healthz answers 503 with a reason line while the handle latch is tripped (no new
   metrics family); the ERROR line stays.
5. Inside a crossing another instance's handle is a forgery even if revoked (own-instance check
   before the revocation check).
7. A bind refused after mint revokes the minted entry (Unbound guard).
8. The latch is read again after counting, so no off-crossing resolve passes once it trips.
9. sdk/services.rs SAFETY comments describe the u64 handle.
MattJackson added a commit that referenced this pull request Oct 14, 2026
…e counter

Per the #1007 re-review (no test reads the global forgery counter): the stale-wake RED wakes from
inside a crossing of its own instance, so a misjudged forgery would quarantine it, and asserts it is
not; the forged-generation RED asserts the quarantine and a revoked handle on a latch of its own.
…cket-less REDs, a neutral readiness line

1. handle_tests judges its own latch (latch.forged()), not the process-wide counter.
3. REDs: /healthz answers 503 while the handle latch is tripped (plugin_dispatch_halted); an instance
   adopted by the dispatcher whose ticket-less call hangs past its budget is faulted by the watchdog's
   ticket-less path (watchdog.rs ticketless -> Instance::revoke) and its handle names nothing, no
   forgery counted.
4. The 503 reason line is neutral ("plugin dispatch halted; restart required"): the route is
   unauthenticated, and the detail is the latch's ERROR line.
@MattJackson

Copy link
Copy Markdown
Collaborator Author

Rule 20 note from #1030 (row 155): disjoint hunks in busbar_plugin.h, abi-layout.golden, layout_golden.rs (OnPieceOut gains tap_fault at 181; _fault_reserved moves to 182, plus BB_PLANE_TAP_FAULT_* consts after UNITS_FLOOR). A test merge is clean.

…es in the root crate, no inline test

- Latch::forged/unattributed are the tests' readings: cfg(test), not dead code in a no-test build.
- plane_forged_context: the forged context comes from the plane-driver test plane's own
  /forge-context arrival, so the test declares no extern "C" door of its own. Each "C" was a
  hit on busbar x instance:secret (the census learns `c` from busbar-secret-c): the cell is back
  at its base 35, the ceiling untouched.
- endpoints: the /healthz latch RED moves to tests/plugin_dispatch_halted_tests.rs.
MattJackson added a commit that referenced this pull request Oct 15, 2026
…ext a u64 handle); the streaming door's PlaneTail states no metric lanes (the tail grew metric_lanes beside its stream ceiling)

This branch has not been deployed

No deployments
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