Skip to content

RFC: capability-token mechanism for kernel-verified uplink publish-as #30

Description

@joshuajbouw

Summary

Define a kernel-level capability-token mechanism that lets uplinks publish events with kernel-verified principal attribution instead of the current uplink-claimed (unverified) attribution. The kernel mints ed25519-signed tokens binding (principal_id, session_id, expiry) on capsule request; uplinks present those tokens to astridd at connect; astridd verifies the signature and applies principal-attribution::verified(<principal>) to subsequent publish-as calls on that connection.

This closes a forgeability hole that limits what subscribers can trust on the bus today, and provides the cryptographic basis for non-spoofable hook events from native subprocess bridges (e.g. astrid-emit per unicity-astrid/astrid#814).

Motivation

Astrid's current uplink contract (wit/host/[email protected]) marks publish-as events as principal-attribution::claimed(<principal>):

"A claimed principal is uplink-asserted (via publish-as) and the kernel has NOT verified the asserting uplink actually authenticated the named principal. Treat claimed as caller-input, not authenticated context."

This is correct as a security disclaimer but problematic in practice: subscribers that want to act on a principal claim (capability checks, policy gates, per-principal audit aggregation) have to either trust claimed attribution (insecure) or implement their own validation chain (every-subscriber-validates anti-pattern).

The driving concrete case is astrid-emit (per astrid#814) — a native binary exec'd by Claude Code hooks to forward events onto the bus. Any same-OS-user process can connect to astridd as an uplink and claim any principal. The interim mitigation uses sage as a per-session validator (sage mints a secret, the binary forwards it, sage validates against its kernel-namespaced KV) — but this still leaks on Linux because /proc/<pid>/environ is readable by the same OS user, allowing token theft from an active Claude session.

Cryptographic tokens minted and verified by the kernel close the forgeability gap independently of any one capsule's validation logic, and align with Astrid's stated design principle ("Cryptography over prompts. Authorization = ed25519 signatures + capability tokens, not LLM instructions.").

Proposed mechanism

Token shape

HookToken = (principal_id, session_id, issued_at, expiry, nonce, signature)
signature = ed25519_sign(kernel_signing_key, principal_id || session_id || issued_at || expiry || nonce)
  • principal_id — the principal the token authorizes
  • session_id — narrows the token to one Astrid session; multiple concurrent sessions per principal get different tokens
  • issued_at, expiry — bounded validity (proposed: 1h default, configurable per mint request)
  • nonce — random 16+ bytes; allows token revocation by nonce-blocklist
  • signature — ed25519 over the canonicalized fields with the kernel's signing key

Token is serialized as base64-url for env-var-safe transport.

Mint surface

New IPC topic on the identity capsule (or kernel-direct, TBD):

request:  identity.v1.request.mint_hook_token { principal_id, session_id, ttl_seconds }
response: identity.v1.response.mint_hook_token.<corr_id> { token: string, expires_at: timestamp }

Mint authorization: capsule must hold a new mint_hook_token capability declared in its Capsule.toml. Reserved for trusted-spawner capsules (sage, future codex/gemini providers). Audit trail: every mint published on astrid.v1.audit.hook_token_mint.

Verify surface

New uplink protocol message in wit/host/[email protected]:

present_hook_token: func(token: string) -> result<_, error-code>;

Called once per uplink connection, after connect, before any publish-as. Astridd:

  1. Decodes the token bytes.
  2. Verifies the signature with its public key (which is the same key all astridd processes share; kernel infrastructure already manages it).
  3. Checks expiry > now.
  4. Checks the nonce isn't on the revocation blocklist.
  5. On success: stores the verified principal_id against this uplink connection. All subsequent publish-as calls on this connection that target this principal are tagged principal-attribution::verified(<principal>). publish-as for a different principal returns capability-denied.
  6. On failure: the connection's permission set is unchanged (still claimed-only); calls to publish-as continue to produce claimed attribution as today.

Revocation

Optional but recommended:

identity.v1.request.revoke_hook_token { nonce }

Adds the nonce to astridd's in-memory revocation set; persists across restarts via a small file under ~/.astrid/var/. Capsules revoke their own minted tokens on session end (in addition to letting them expire naturally).

How astrid-emit consumes this (post-RFC)

  1. Sage's handle_spawn: publish identity.v1.request.mint_hook_token { principal_id, session_id, ttl: session_lifetime }. Drain the response; capture the token.
  2. Set ASTRID_HOOK_TOKEN=<token> in claude's env (replacing the random-secret-into-KV pattern).
  3. astrid-emit reads the env, calls present_hook_token(<token>) on its uplink connection, then publish-as(<topic>, <payload>, <principal_id>).
  4. Subscribers receive events with principal-attribution::verified(<principal>). The sage-as-validator pattern becomes optional; subscribers can trust the canonical topic directly.
  5. Sage's handle_stop: publish identity.v1.request.revoke_hook_token { nonce } to harden against token reuse after session end.

The sage-as-validator pattern (per astrid#814) remains in place as a fallback during the transition — sage can dual-write to sage.v1.unverified_hook.* and (post-RFC) directly to hook.v1.event.* based on whether tokens are kernel-mintable. Once the RFC ships, the unverified topic can be deprecated.

Residual Linux env-stealability

Token-in-env is still readable on Linux via /proc/<pid>/environ by same-OS-user processes. The kernel-signed token is unforgeable cryptographically but not un-stealable. With a stolen token, an attacker can publish as the principal until the token expires.

Mitigations (in this RFC's scope):

  • Short default TTL (1h) caps damage window.
  • Revocation on session end kills tokens before natural expiry.
  • Per-session token binding — a stolen token is only valid for the session that minted it; killing that session revokes the token automatically (server-side: astridd treats session_id not in the active set as expired).

Fully closing the env-stealability gap requires fd inheritance — sage opens a pre-authenticated uplink fd and passes it to claude via the spawn API, claude passes it to the hook command, the bridge uses the inherited fd. This is a much larger contract change (the kernel needs an exec API that accepts fd handoff from a capsule context) and is out of this RFC's scope; flagged as a separate future RFC if/when the threat-model demands it.

Acceptance criteria

  • WIT contract change: add present_hook_token to astrid:[email protected] (per the ABI evolution discipline: ships as a new file at a new version path; existing v1.0.0 unchanged).
  • Capsule manifest schema: add mint_hook_token = true capability flag.
  • Kernel: token mint endpoint (identity capsule or kernel direct), token verify in astridd uplink path, revocation set, audit emission.
  • SDK: helper for capsules to mint/revoke tokens, helper for uplinks to present tokens at connect.
  • Migration story: capsules using publish-as without present_hook_token keep current claimed behavior (backwards-compatible). New tokens upgrade the connection to verified.
  • Documentation: README + capsule-authoring guide updates explaining the verified-vs-claimed split.

Alternatives considered

  • HMAC instead of ed25519: simpler but ties astridd instances to a shared secret; ed25519 lets the kernel publish its public key for offline verification (e.g. by external audit tooling).
  • JWT-style envelopes: heavier wire format, more parsing surface, no advantage over a fixed-shape signed blob.
  • Per-principal Unix sockets: kernel creates ~/.astrid/principals/<id>/uplink.sock with the principal binding baked into the socket. Avoids tokens entirely. Trade-off: every connection is bound to one principal, harder to multiplex; doesn't solve fd-inheritance directly.
  • Status quo + sage-as-validator only: capsule validation chain (sage-as-CA) works for the immediate hook-events use case but each new validating capsule reinvents the wheel. Kernel-level tokens factor the trust-anchor pattern out once.

Related

  • unicity-astrid/astrid#814astrid-emit binary. Drives the immediate need.
  • unicity-astrid/wit/host/[email protected] — current uplink contract this RFC extends.
  • astrid-rfcs/rfc/hook-lifecycle-protocol — in-flight hook taxonomy work. Parallel; this RFC is orthogonal (covers attribution, not naming).
  • Sage v1.1+ (unicity-astrid/sage, commit 4ae16de+) — first consumer of both this RFC and #814.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions