Files
alkhttp/docs/architecture/decisions/014-secret-material-flow-and-capability-injection.md
T
glm-5.3-flash 4a825d33e7 feat(infra): full-surface integration suite + docs sync + publish prep
Full-surface integration suite (tests/full_surface.rs, mcp feature):
- one HttpAdapter over real TCP (ProtocolHandler::handle path) serving
  gateway endpoints, /openapi.json, /mcp, and the WS channels session
- gateway: search/schema/call/subscribe/batch/publish presence,
  envelope shapes, error fidelity end-to-end
- from_openapi import -> Internal-by-default invisible from the wire ->
  External facade composes it via env.invoke -> upstream HTTP API
  called end-to-end (ADR-015 composition model exercised)
- to_openapi 6-path doc validated against openapiv3 over the wire
- to_mcp: MCP client connects to /mcp on the served adapter, lists the
  4 gateway tools, search returns ACL-filtered ops (Sub excluded)

Production fix: the WS upgrade route was reserved but never wired into
HttpAdapter's router (the ws-upgrade-session tests built their own
router). Now wired with ws_bearer_auth (401 without a resolvable
token) around ws_upgrade_handler.

Docs sync: all 28 'Port notes' sections/blockquotes stripped from
ported ADRs/specs; OQ-01/OQ-02 statuses corrected to resolved in
overview.md, websocket.md, and the README table (open-questions.md was
already current).

Publish prep: cargo publish --dry-run --allow-dirty succeeds;
cargo doc --no-deps warning-free (ADR link targets fixed); feature
combinations (default / test-support / mcp / wss / all) compile
warning-free under clippy -D warnings.

Verified: cargo test (182 lib default), --all-features (227 lib + 29
integration), clippy -D warnings x3 feature sets, fmt, doc,
publish --dry-run.
2026-08-28 16:07:56 +00:00

11 KiB

ADR-014: Secret Material Flow and Capability Injection

Ported from alknet ADR-014 (Secret Material Flow and Capability Injection); re-targeted to alkhttp.

Status

Accepted

Context

alkvault holds the master seed and can derive keys and encrypt/decrypt arbitrary data. ADR-008 established that the vault is a capability source: "derived keys and decrypted credentials are injected into operation contexts at the assembly layer, not passed as vault references to handlers." That prose was correct but the mechanism was never specified.

The result was a contradiction in the spec documents. ADR-008 said the master seed never crosses the network, but operation-registry.md showed vault/derive, vault/unlock, and vault/decrypt registered as call protocol operations — directly on the wire. Those two statements cannot both be true. The contradiction arose because no injection mechanism existed in the architecture, so the only way the docs could show a handler obtaining a key was to expose vault operations over the call protocol.

This is a one-way door. Once secret material crosses the wire as a call protocol operation, the attack surface is permanent:

  • vault/unlock accepts a BIP39 mnemonic — the root of trust — over the wire. A compromised peer, a logging accident, a tracing span, and the seed is gone.
  • vault/derive returns a DerivedKey. The type redacts the private key in JSON today, but the operation's existence means a serialization change, a binary codec addition, or a wrapper change would leak it. The surface is the risk, not the current implementation.
  • vault/decrypt accepts an encrypted blob and returns plaintext. Any authorized caller can decrypt any blob they possess.

The broader problem this decision addresses is structural: the industry default for storing LLM provider keys, API tokens, and other credentials is plaintext config files and environment variables (e.g., the aisdk Rust port reads std::env::var("GOOGLE_API_KEY") and the example backend calls dotenv::dotenv()). The alk stack replaces that with a vault (alkvault). But the vault only solves the storage problem; the flow problem — how decrypted material reaches the code that needs it without crossing the network — requires its own decision.

There is a separate, second axis that the current OperationContext conflates with the secret-flow problem. A handler has two orthogonal credential concerns:

  • Identity (inbound): who is calling me? Resolved per-request from AuthContext (TLS client cert, auth token). Already in OperationContext.
  • Capabilities (outbound): what secrets can I use for outbound calls? This is the missing axis. A handler calling Google's API needs a decrypted Google API key. That is not the caller's identity — it is the handler's own outbound credential, provisioned by the assembly layer.

Mixing these two into one channel (e.g., stuffing secrets into OperationContext.metadata: HashMap<String, Value>) is a leak risk: metadata propagates through nested calls via OperationEnv::invoke(), so a secret placed there by one handler would flow to every downstream operation.

Decision

1. The vault is assembly-layer only.

The CLI binary (or an embedded assembly layer) is the sole component that talks to VaultServiceHandle directly. It unlocks the vault at startup, derives and decrypts what each handler needs, and constructs handlers with the results. No vault operation (derive, decrypt, unlock, lock) is registered as a call protocol operation. The vault has no ALPN. The master seed and derived private keys never enter the call protocol.

2. Capabilities are the injection mechanism.

A Capabilities type carries outbound secret material from the assembly layer into handlers. Capabilities are distinct from identity (inbound auth) and distinct from per-request metadata. The concrete shape of the Capabilities type is a two-way door — to be decided during implementation of the call protocol crate (now the alkcall crate). The one-way constraint is:

  • Capabilities hold non-serializable, zeroized secret material. They cannot cross the call protocol wire even by accident — they are not serde_json::Value, they do not implement Serialize, and they do not appear in EventEnvelope payloads.
  • Capabilities are injected at handler construction (the common case: a static decrypted API key held for the handler's lifetime) or scoped per-request for internal-only flows. They are never populated from call protocol inputs.

3. The call protocol carries no secret material.

This is a wire-level constraint on the call protocol, not a handler-level convention. Secret material (private keys, API keys, mnemonics, decrypted credentials, raw tokens) must not appear in:

  • call.requested payloads (inputs)
  • call.responded payloads (outputs)
  • OperationContext.metadata

The wire format does not enforce this — it carries serde_json::Value — so the constraint is architectural, enforced by the operation registry and by convention. Operations that need to share public key material (e.g., for identity verification) use a dedicated operation that returns only the public component, never the private key.

4. Adapters take credential sources, not static tokens.

The from_openapi and from_jsonschema adapter patterns (the OperationAdapter trait lives in the alkcall crate per alkcall ADR-033; the HTTP-backed adapter implementations live in alkhttp — see ADR-066) register HTTP-backed operations. The TypeScript @alkdev/operations from_openapi takes config.auth: { token: "..." } — a static string. The Rust adapters take a credential source wired to the assembly layer (a resolver, a capability handle, or an injected secret), not a literal token. This is the integration point where the vault feeds credentials into HTTP-backed operations: the assembly layer decrypts the token at startup and provides it to the adapter at registration time.

5. Handlers that need per-request vault access receive a scoped capability.

The common case (a static decrypted API key) is covered by construction-time injection. A narrower case — a handler that derives a child key for a specific operation (e.g., signing for GitHub authentication) — receives a scoped capability that can only derive at a restricted path set. This is still not a vault reference: it is a restricted handle that performs a specific derivation and returns the result to the handler, in-process. The handler never sees the master seed. Whether this scoped capability is a distinct type or modeled as a pre-derived key injected at construction is a two-way door left to the alkcall and agent crate specs.

Consequences

Positive:

  • The master seed and derived private keys never cross the network. The attack surface for the root of trust is local-only.
  • The OperationContext gains a clean second axis (capabilities) instead of overloading metadata for secrets, preventing accidental propagation of secret material through nested calls.
  • Handlers that need outbound credentials (the agent handler calling an LLM provider) receive them directly — no indirection through a vault/derive call, no latency, no failure mode where the vault must be reachable at call time.
  • The adapter contract (alknet OQ-15) gains a concrete shape: adapters take a credential source from the assembly layer, not a static token. This makes the from_openapi / from_jsonschema / from_call patterns safe by construction.
  • The model is structurally incompatible with the env-var / plaintext-config default. There is no std::env::var("API_KEY") path — the only way a handler gets a credential is through a capability, and the only way a capability is populated is through the assembly layer from the vault.

Negative:

  • The assembly layer (CLI binary) has more construction-time responsibility: it must know which handlers need which credentials and wire them. This is expected — the CLI assembles everything (alknet ADR-008).
  • Adding a new handler that needs a new credential requires updating the assembly layer, not just registering an operation. This is a feature, not a bug: it forces an explicit decision about what secret material a handler needs.
  • Remote vault administration (unlock a running node's vault over the network) is not supported by this decision. If that capability is needed in the future, it would require a separate, heavily restricted mechanism (admin scope, mTLS-only, never expose the mnemonic over an unauthenticated channel) and its own ADR. This decision does not close that door; it simply does not open it.
  • The Capabilities type shape is not fully specified here. The one-way constraint (non-serializable, zeroized, injection-only) is fixed; the concrete API is a two-way door for the call protocol spec.

Assumptions

These are the load-bearing assumptions. If any of them breaks, the decision should be revisited:

  1. Handlers need credentials at construction time or at call time, not dynamically discovered at call time. If a handler needs to derive a key at an unpredictable path determined by call input, the scoped-capability model still covers it (the handler holds a scoped vault access), but the surface area is larger. The assumption is that this case is rare.
  2. The call protocol's threat model excludes the assembly layer. The CLI binary is trusted to hold the vault handle and inject capabilities. If the assembly layer is compromised, all handlers' capabilities are compromised. This is the same trust boundary as alknet ADR-008.
  3. No legitimate use case requires returning a private key over the wire. Public key sharing (identity verification, encryption to a recipient) is the only cross-node key material flow. If a use case for returning a private key emerges (e.g., a key-escrow service), it needs its own ADR and a very different threat model.
  4. Adapters are registered at startup, not at call time. The credential source is wired to the adapter when the operation is registered, not when the operation is invoked. This is consistent with alknet OQ-04 (static registration at startup).

References

  • alknet ADR-003: Crate decomposition (alknet-vault was standalone; now extracted as alkvault) — ported here
  • alkcall ADR-013: irpc as call protocol foundation (alknet ADR-005; superseded — see alkcall ADR-014, irpc was never integrated)
  • alknet ADR-008: Vault integration point (capability source — this ADR specifies the mechanism that ADR-008 described in prose; alknet mono-repo ADR — the vault is an alkvault concern)
  • alknet ADR-009: One-way door decision framework (now alkcall ADR-032)
  • alkcall ADR-033: Rust as canonical implementation language (alknet ADR-013)
  • alknet OQ-15: Call protocol client and adapter contract (this ADR constrains the adapter contract: adapters take credential sources, not static tokens; resolved by alkcall ADR-022, ported here as ADR-017)
  • alknet OQ-16: Safe vault operations for call protocol exposure (resolved by this ADR: none, for now)
  • alkvault crate (extracted from the alknet mono-repo's alknet-vault)