docs(architecture): resolve review #002 remaining Tier 4 findings
Add ADR-026 (vault key model — HD derivation) recording the foundational HD-derivation decision, 74' coin type reservation, SLIP-0010/Ed25519 default, secp256k1 feature-gating, and AES-256-GCM cipher choice. These were previously inline rationale with no ADR (W9). Extend ADR-018 with an explicit EncryptedData wire format lock — fields, encoding, and semantics are frozen; no removal without a format-version migration (W10). Resolve the remaining guard clauses and spec decisions: - W2: Capabilities must be immutable after construction (no interior mutability). Makes the Arc vs deep-copy clone semantics genuinely two-way. - W5: Published to_* specs are compatibility contracts — best-effort mappings are two-way before first publication, one-way after. Version generated specs. - W6: Salt field clarification — v2 salt is permanently unused; a future KDF is a different derivation family, not a version-indexed path; the field saves a wire-format change only. - W7: unlock_new returns Zeroizing<String> — the mnemonic is the root of trust and must not linger in freed memory. - W17: OQ-09 WASM — server-side dispatch door is honestly closed (Connection is concrete, tokio-bound), not implicitly preserved. - W18: OQ-10 git — composability fork (raw smart protocol vs call-protocol projection) is a separate decision from ERC721 scope. - W20: from_openapi must prefix imported error codes (HTTP_404) to avoid collision with protocol-level codes (NOT_FOUND). Normative rule, not naming convention. - W21: ScopedOperationEnv field is private — construction via new()/ empty(), query via allows(). Makes the future subgraph refactor non-breaking. - C13: Connection::set_identity — the endpoint does not read identity() after handle() returns (Connection is moved into the spawned task). Observability is handler-side logging. Simplest honest answer. - W1: OperationAdapter trait is async, returns Vec<HandlerRegistration>. from_call requires async discovery; ADR-022 changed the return type. - W11: CompositionAuthority::as_identity() defined — constructs a synthetic Identity (label as id, scopes, resources) not resolvable via IdentityProvider. Second Identity construction path, acknowledged. - W14: SecretKey is iroh::SecretKey (Ed25519) — consistent with the endpoint's iroh dependency. - W19: Grandchild abort propagation is inherit-by-default (option a) — invoke() with no explicit policy inherits parent's policy. ContinueRunning auto-propagates to grandchildren unless explicitly overridden.
This commit is contained in:
@@ -7,7 +7,7 @@ last_updated: 2026-06-22-20
|
|||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
**Pre-implementation.** The project has completed a pivot from a three-layer model to an ALPN-as-service model. The greenfield workspace contains only `alknet-vault` (stable — implementation exists, pending ADR-025 refactor to drop irpc) and research/reference material. Foundational ADRs (001–025) are in place, including the BiStream type definition (ADR-007), vault integration (ADR-008), ALPN router/endpoint (ADR-010), AuthContext structure (ADR-011), call protocol stream model (ADR-012), Rust as canonical implementation language (ADR-013), secret material flow with capability injection (ADR-014), privilege model with authority context (ADR-015), abort cascade for nested calls (ADR-016), call protocol client and adapter contract (ADR-017), vault standalone crate (ADR-018), vault assembly-layer-only access (ADR-019), HD derivation for encryption keys (ADR-020), key rotation via version-indexed paths (ADR-021), handler registration, provenance, and composition authority (ADR-022), operation error schemas (ADR-023), operation registry layering (ADR-024), and vault local-only dispatch (ADR-025). ADR-024 resolves the registry mutability question (`from_call` imports require a runtime-mutable home) and the `OperationContext.env` type identity crisis (review #002 C6), by layering the registry by trust boundary and making `OperationEnv` a trait-object integration point. ADR-025 drops irpc from the vault, making it local-only by construction (inverting the security default from remote-capable-by-default to local-only-by-default) and resolving OQ-21, C7, C8, and W8. The alknet-core, alknet-call, and alknet-vault crate specs are in draft.
|
**Pre-implementation.** The project has completed a pivot from a three-layer model to an ALPN-as-service model. The greenfield workspace contains only `alknet-vault` (stable — implementation exists, pending ADR-025/026 refactor to drop irpc and remove derive_password) and research/reference material. Foundational ADRs (001–026) are in place. ADR-024 resolves the registry mutability question and the `OperationContext.env` type identity crisis by layering the registry by trust boundary. ADR-025 drops irpc from the vault, making it local-only by construction. ADR-026 records the HD-derivation key model as a foundational decision. The alknet-core, alknet-call, and alknet-vault crate specs are in draft.
|
||||||
|
|
||||||
**Next step**: Continue working through review #002's remaining Tier 4 findings (vault security decisions, guard clauses, ADR-writing exercises, smaller spec decisions). All open questions for the core and call crates are resolved; the vault crate's OQ-21 (remote vault) is now resolved (ADR-025 — vault is local-only by construction).
|
**Next step**: Continue working through review #002's remaining Tier 4 findings (vault security decisions, guard clauses, ADR-writing exercises, smaller spec decisions). All open questions for the core and call crates are resolved; the vault crate's OQ-21 (remote vault) is now resolved (ADR-025 — vault is local-only by construction).
|
||||||
|
|
||||||
@@ -60,6 +60,7 @@ last_updated: 2026-06-22-20
|
|||||||
| [023](decisions/023-operation-error-schemas.md) | Operation Error Schemas | Accepted |
|
| [023](decisions/023-operation-error-schemas.md) | Operation Error Schemas | Accepted |
|
||||||
| [024](decisions/024-operation-registry-layering.md) | Operation Registry Layering | Accepted |
|
| [024](decisions/024-operation-registry-layering.md) | Operation Registry Layering | Accepted |
|
||||||
| [025](decisions/025-vault-local-only-dispatch.md) | Vault Local-Only Dispatch | Accepted |
|
| [025](decisions/025-vault-local-only-dispatch.md) | Vault Local-Only Dispatch | Accepted |
|
||||||
|
| [026](decisions/026-vault-key-model-hd-derivation.md) | Vault Key Model — HD Derivation | Accepted |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
@@ -510,6 +510,7 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
|
|||||||
- Capabilities hold secret material that does not implement `Serialize` and does not appear in `EventEnvelope` payloads.
|
- Capabilities hold secret material that does not implement `Serialize` and does not appear in `EventEnvelope` payloads.
|
||||||
- The call protocol carries no secret material. See [call-protocol.md](call-protocol.md) for the wire-level constraint.
|
- The call protocol carries no secret material. See [call-protocol.md](call-protocol.md) for the wire-level constraint.
|
||||||
- **Capabilities are `Clone` and cloned through composition.** `OperationEnv::invoke()` calls `parent.capabilities.clone()` to pass capabilities to nested calls. This is intentional: a child handler needs the same outbound credentials as its parent (e.g., the `/agent/chat` handler composing `/fs/readFile` may need the same API key for an outbound LLM call). The security implication is that each composition step duplicates the secret material reference — but capabilities are scoped (the handler can only use what the assembly layer declared on the registration bundle), and children run under the parent's composition authority (ADR-015, ADR-022). A clone is the same scoped handle, not a widening of scope. The concrete cloning semantics (reference-counted `Arc` vs deep copy of zeroized material) is a two-way door for implementation, but `Capabilities: Clone` is required by the composition model.
|
- **Capabilities are `Clone` and cloned through composition.** `OperationEnv::invoke()` calls `parent.capabilities.clone()` to pass capabilities to nested calls. This is intentional: a child handler needs the same outbound credentials as its parent (e.g., the `/agent/chat` handler composing `/fs/readFile` may need the same API key for an outbound LLM call). The security implication is that each composition step duplicates the secret material reference — but capabilities are scoped (the handler can only use what the assembly layer declared on the registration bundle), and children run under the parent's composition authority (ADR-015, ADR-022). A clone is the same scoped handle, not a widening of scope. The concrete cloning semantics (reference-counted `Arc` vs deep copy of zeroized material) is a two-way door for implementation, but `Capabilities: Clone` is required by the composition model.
|
||||||
|
- **Capabilities must be immutable after construction.** No interior mutability, no `Mutex<Map>`, no `RefCell`. This makes the clone-semantics two-way door genuinely two-way: Arc-based clone (shared immutable state) and deep-copy clone (isolated state) are behaviorally identical when neither supports mutation. Without this guard, a handler that mutates capabilities (e.g., adds a derived key for a child) would make the mutation visible to siblings and the parent under Arc-based clone — shared mutable state across the call tree, a security-relevant behavior. Once shipped, handlers may depend on shared mutation, and switching from Arc-shared to deep-copy-isolated later is a behavior change that breaks them. The immutability guard prevents the "two-way door" from becoming a future one-way door.
|
||||||
|
|
||||||
**No vault operations are registered in the call protocol.** The vault is assembly-layer only (ADR-008, ADR-014). A handler that needs a child key for a specific operation (e.g., signing for GitHub auth) receives a scoped capability that performs the derivation in-process — it never holds the master seed and never calls a network-exposed vault operation.
|
**No vault operations are registered in the call protocol.** The vault is assembly-layer only (ADR-008, ADR-014). A handler that needs a child key for a specific operation (e.g., signing for GitHub auth) receives a scoped capability that performs the derivation in-process — it never holds the master seed and never calls a network-exposed vault operation.
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,11 @@ pub enum TlsIdentity {
|
|||||||
/// RFC 7250 raw Ed25519 public key.
|
/// RFC 7250 raw Ed25519 public key.
|
||||||
/// No domain, no CA, no cert renewal. Key = identity.
|
/// No domain, no CA, no cert renewal. Key = identity.
|
||||||
/// Same model as iroh's NodeId, but for direct QUIC connections.
|
/// Same model as iroh's NodeId, but for direct QUIC connections.
|
||||||
RawKey(SecretKey),
|
/// `SecretKey` is `iroh::SecretKey` (Ed25519) — re-exported from iroh,
|
||||||
|
/// which alknet-core already depends on (feature-gated, ADR-010). The
|
||||||
|
/// key can be derived from alknet-vault at the assembly layer
|
||||||
|
/// (endpoint.md) or generated fresh. See OQ-12, W14.
|
||||||
|
RawKey(iroh::SecretKey),
|
||||||
|
|
||||||
/// Self-signed X.509 cert for development.
|
/// Self-signed X.509 cert for development.
|
||||||
/// Generated on startup, not validated by external clients.
|
/// Generated on startup, not validated by external clients.
|
||||||
@@ -76,7 +80,7 @@ The reference `StaticConfig` (in `alknet-main/crates/alknet-core/src/config/stat
|
|||||||
// P2P / key-based identity (default for most nodes)
|
// P2P / key-based identity (default for most nodes)
|
||||||
let p2p_config = StaticConfig {
|
let p2p_config = StaticConfig {
|
||||||
listen_addr: Some("0.0.0.0:4433".parse()?),
|
listen_addr: Some("0.0.0.0:4433".parse()?),
|
||||||
tls_identity: Some(TlsIdentity::RawKey(SecretKey::generate())),
|
tls_identity: Some(TlsIdentity::RawKey(iroh::SecretKey::generate())),
|
||||||
iroh_relay: None,
|
iroh_relay: None,
|
||||||
drain_timeout: Duration::from_secs(2),
|
drain_timeout: Duration::from_secs(2),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -80,8 +80,7 @@ impl Connection {
|
|||||||
- `remote_alpn()`: The ALPN negotiated for this connection. Always present.
|
- `remote_alpn()`: The ALPN negotiated for this connection. Always present.
|
||||||
- `remote_addr()`: The peer's address, if available. Informational (NAT/proxy).
|
- `remote_addr()`: The peer's address, if available. Informational (NAT/proxy).
|
||||||
- `close()`: Close the connection with an error code and reason.
|
- `close()`: Close the connection with an error code and reason.
|
||||||
- `set_identity()`: Store the handler-resolved identity for observability (OQ-11). Write-once-read-many — a second call returns an error. Handlers that resolve identity inside `handle()` call this; the endpoint and observability layers read it via `identity()`.
|
- `set_identity()`: Store the handler-resolved identity for observability (OQ-11). Write-once-read-many — a second call returns an error. Handlers that resolve identity inside `handle()` call this; the identity is read by handler-side logging (the handler logs which identity it resolved) and is available on the `Connection` for any code that holds a reference to it. The endpoint does **not** read `identity()` after `handle()` returns — the `Connection` is moved into the spawned handler task (endpoint.md), so the endpoint no longer has a reference. Connection-level observability (remote addr, ALPN, connection ID) is logged by the endpoint before the move; identity-level observability is logged by the handler. See OQ-11 for the full resolution.
|
||||||
- `identity()`: Read the handler-resolved identity, if set. Returns `None` until `set_identity()` is called.
|
|
||||||
|
|
||||||
The `Connection` type does not expose quinn types in its public API. It wraps `quinn::Connection` internally, but the wrapper allows test implementations.
|
The `Connection` type does not expose quinn types in its public API. It wraps `quinn::Connection` internally, but the wrapper allows test implementations.
|
||||||
|
|
||||||
@@ -187,7 +186,7 @@ connection, ADR-006).
|
|||||||
| BiStream is a trait | [ADR-007](../../decisions/007-bistream-type-definition.md) | WASM door preserved, test mocks possible |
|
| BiStream is a trait | [ADR-007](../../decisions/007-bistream-type-definition.md) | WASM door preserved, test mocks possible |
|
||||||
| HandlerError is non-fatal | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Handler errors close the connection, not the endpoint |
|
| HandlerError is non-fatal | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Handler errors close the connection, not the endpoint |
|
||||||
| SendStream/RecvStream wrap quinn + iroh | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Internal enum dispatch for both QUIC sources |
|
| SendStream/RecvStream wrap quinn + iroh | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Internal enum dispatch for both QUIC sources |
|
||||||
| Connection stores handler-resolved identity | OQ-11 (resolved) | `set_identity` via `OnceLock` — write-once-read-many for observability |
|
| Connection stores handler-resolved identity | OQ-11 (resolved) | `set_identity` via `OnceLock` — write-once-read-many; read by handler-side logging, not by the endpoint (C13 resolved) |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ seed and derived private keys never cross the network.
|
|||||||
| [020](../../decisions/020-hd-derivation-for-encryption-keys.md) | HD Derivation for Encryption Keys | SLIP-0010 derivation, not PBKDF2; salt unused in v2 |
|
| [020](../../decisions/020-hd-derivation-for-encryption-keys.md) | HD Derivation for Encryption Keys | SLIP-0010 derivation, not PBKDF2; salt unused in v2 |
|
||||||
| [021](../../decisions/021-key-rotation-via-version-indexed-paths.md) | Key Rotation via Version-Indexed Paths | Version-indexed paths; `rotate` re-encrypts |
|
| [021](../../decisions/021-key-rotation-via-version-indexed-paths.md) | Key Rotation via Version-Indexed Paths | Version-indexed paths; `rotate` re-encrypts |
|
||||||
| [025](../../decisions/025-vault-local-only-dispatch.md) | Vault Local-Only Dispatch | Dropped irpc; direct method calls; local-only by construction |
|
| [025](../../decisions/025-vault-local-only-dispatch.md) | Vault Local-Only Dispatch | Dropped irpc; direct method calls; local-only by construction |
|
||||||
|
| [026](../../decisions/026-vault-key-model-hd-derivation.md) | Vault Key Model — HD Derivation | HD derivation from BIP39 seed; `74'` coin type; AES-256-GCM |
|
||||||
|
|
||||||
## Relevant Open Questions
|
## Relevant Open Questions
|
||||||
|
|
||||||
@@ -126,6 +127,7 @@ truth for drift tracking — if an item is fixed in source, update this table.
|
|||||||
| 5 | `DerivedKey` dual serialization | JSON redacts, postcard preserves bytes | Always redact on serialize; reject `"[REDACTED]"` on deserialize with error (ADR-025, resolves W8) | `protocol.rs` | [protocol.md → Serialization Redaction](protocol.md#serialization-redaction), [ADR-025](../../decisions/025-vault-local-only-dispatch.md) |
|
| 5 | `DerivedKey` dual serialization | JSON redacts, postcard preserves bytes | Always redact on serialize; reject `"[REDACTED]"` on deserialize with error (ADR-025, resolves W8) | `protocol.rs` | [protocol.md → Serialization Redaction](protocol.md#serialization-redaction), [ADR-025](../../decisions/025-vault-local-only-dispatch.md) |
|
||||||
| 6 | `HashMap::clear` zeroization | `KeyCache::clear()` removes entries and relies on `CachedKey`'s `Drop` impl for zeroization | Verify `HashMap::clear()` actually drops values (it does, but worth a test) | `cache.rs` | [service.md → Security Constraints](service.md#security-constraints) |
|
| 6 | `HashMap::clear` zeroization | `KeyCache::clear()` removes entries and relies on `CachedKey`'s `Drop` impl for zeroization | Verify `HashMap::clear()` actually drops values (it does, but worth a test) | `cache.rs` | [service.md → Security Constraints](service.md#security-constraints) |
|
||||||
| 7 | `derive_password` / `site_password_path` | `derive_password`, `derive_password_string`, `site_password_path` methods exist | Remove entirely — password-manager pattern not relevant to RPC system's vault (ADR-025, resolves C9) | `service.rs`, `mnemonic-derivation.rs` | [ADR-025](../../decisions/025-vault-local-only-dispatch.md) |
|
| 7 | `derive_password` / `site_password_path` | `derive_password`, `derive_password_string`, `site_password_path` methods exist | Remove entirely — password-manager pattern not relevant to RPC system's vault (ADR-025, resolves C9) | `service.rs`, `mnemonic-derivation.rs` | [ADR-025](../../decisions/025-vault-local-only-dispatch.md) |
|
||||||
|
| 8 | `unlock_new` return type | Returns `String` (not zeroized on drop) | Return `Zeroizing<String>` — the mnemonic is the root of trust and must not linger in freed memory (resolves W7) | `service.rs` | [service.md → unlock_new](service.md#unlock_newword_count--phrase) |
|
||||||
|
|
||||||
## Public API
|
## Public API
|
||||||
|
|
||||||
|
|||||||
@@ -218,13 +218,14 @@ not part of ADR-021's rotation scheme.
|
|||||||
|
|
||||||
| Decision | ADR | Summary |
|
| Decision | ADR | Summary |
|
||||||
|----------|-----|---------|
|
|----------|-----|---------|
|
||||||
| AES-256-GCM for credential encryption | — | Authenticated encryption, hardware-accelerated |
|
| AES-256-GCM for credential encryption | [ADR-026](../../decisions/026-vault-key-model-hd-derivation.md) | Authenticated encryption, hardware-accelerated |
|
||||||
| HD derivation, not PBKDF2 | [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) | Seed-derived key; no password; deterministic |
|
| HD derivation, not PBKDF2 | [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) | Seed-derived key; no password; deterministic |
|
||||||
| Salt unused in v2 (wire-format compat) | [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) | Kept for TS compat; not used in key derivation |
|
| Salt unused in v2 (wire-format compat) | [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) | Kept for TS compat; not used in key derivation |
|
||||||
| Key derived at `m/74'/2'/0'/0'` | — | Dedicated account for encryption keys |
|
| Key derived at `m/74'/2'/0'/0'` | [ADR-026](../../decisions/026-vault-key-model-hd-derivation.md) | Dedicated account for encryption keys |
|
||||||
| Version-indexed paths for rotation | [ADR-021](../../decisions/021-key-rotation-via-version-indexed-paths.md) | `m/74'/2'/0'/{version-2}'` |
|
| Version-indexed paths for rotation | [ADR-021](../../decisions/021-key-rotation-via-version-indexed-paths.md) | `m/74'/2'/0'/{version-2}'` |
|
||||||
| Key versioning (v1=TS PBKDF2, v2=vault HD) | [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) | Distinguishes derivation methods |
|
| Key versioning (v1=TS PBKDF2, v2=vault HD) | [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) | Distinguishes derivation methods |
|
||||||
| All fields base64-encoded | — | JSON serialization compatibility |
|
| All fields base64-encoded | — | JSON serialization compatibility |
|
||||||
|
| `EncryptedData` wire format frozen | [ADR-018](../../decisions/018-vault-standalone-crate.md) | Fields, encoding, semantics locked; no removal without migration |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
@@ -241,10 +241,11 @@ assembly-layer concern.
|
|||||||
| Decision | ADR | Summary |
|
| Decision | ADR | Summary |
|
||||||
|----------|-----|---------|
|
|----------|-----|---------|
|
||||||
| Vault is standalone | [ADR-018](../../decisions/018-vault-standalone-crate.md) | Zero alknet crate dependencies |
|
| Vault is standalone | [ADR-018](../../decisions/018-vault-standalone-crate.md) | Zero alknet crate dependencies |
|
||||||
| HD derivation (not stored keys) | — | One seed, many keys, no key storage |
|
| HD derivation (not stored keys) | [ADR-026](../../decisions/026-vault-key-model-hd-derivation.md) | One seed, many keys, no key storage; reproducible across nodes |
|
||||||
| `74'` coin type reserved for alknet | — | SLIP-0044 unallocated; alknet namespace |
|
| `74'` coin type reserved for alknet | [ADR-026](../../decisions/026-vault-key-model-hd-derivation.md) | SLIP-0044 unallocated; alknet namespace |
|
||||||
| secp256k1 feature-gated | — | Heavy dep; only needed for Ethereum |
|
| secp256k1 feature-gated | [ADR-026](../../decisions/026-vault-key-model-hd-derivation.md) | Heavy dep; only needed for Ethereum |
|
||||||
| Hardened-only for Ed25519 | SLIP-0010 | Ed25519 cannot do public derivation |
|
| Hardened-only for Ed25519 | SLIP-0010 | Ed25519 cannot do public derivation |
|
||||||
|
| Vault is local-only | [ADR-025](../../decisions/025-vault-local-only-dispatch.md) | Direct method calls, no irpc, no remote dispatch |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
@@ -74,15 +74,23 @@ produce different seeds.
|
|||||||
### unlock_new(word_count) → phrase
|
### unlock_new(word_count) → phrase
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub fn unlock_new(&self, word_count: usize) -> Result<String, VaultServiceError>;
|
pub fn unlock_new(&self, word_count: usize) -> Result<Zeroizing<String>, VaultServiceError>;
|
||||||
```
|
```
|
||||||
|
|
||||||
Generate a new random mnemonic, unlock with it, and return the phrase.
|
Generate a new random mnemonic, unlock with it, and return the phrase as
|
||||||
Store the returned phrase securely — it is the root of trust. Supported
|
a `Zeroizing<String>`. The returned phrase is the root of trust — it is
|
||||||
word counts: 12, 15, 18, 21, 24.
|
heap-allocated and zeroized on drop, so it does not linger in freed
|
||||||
|
memory. The caller should extract the phrase for secure storage (write
|
||||||
|
down, display to user) and let the `Zeroizing<String>` drop when done.
|
||||||
|
Do not clone the returned value or store it in a non-zeroizing container.
|
||||||
|
Supported word counts: 12, 15, 18, 21, 24.
|
||||||
|
|
||||||
This is the "first run" path — a new node generates its mnemonic, writes
|
This is the "first run" path — a new node generates its mnemonic, writes
|
||||||
it down, and the vault is unlocked for the process lifetime.
|
it down, and the vault is unlocked for the process lifetime. The
|
||||||
|
`Zeroizing<String>` wrapper (from the `zeroize` crate) ensures the
|
||||||
|
mnemonic is wiped from memory once the caller is done with it, matching
|
||||||
|
the `Mnemonic` type's own `ZeroizeOnDrop` behavior. This resolves review
|
||||||
|
#002 W7.
|
||||||
|
|
||||||
### lock()
|
### lock()
|
||||||
|
|
||||||
|
|||||||
@@ -152,10 +152,25 @@ context.env.invoke_with_policy(
|
|||||||
```
|
```
|
||||||
|
|
||||||
The child's `OperationContext` carries the policy. If the child itself
|
The child's `OperationContext` carries the policy. If the child itself
|
||||||
composes grandchildren, the policy propagates unless the child explicitly
|
composes grandchildren, the policy **propagates by inheritance** — the
|
||||||
overrides it. This is consistent with the composition authority and scoped
|
grandchild inherits the child's policy (which was the parent's policy,
|
||||||
env propagation in ADR-022 — the parent handler decides the child's
|
unless the parent overrode it for the child via `invoke_with_policy`).
|
||||||
runtime context, including abort policy.
|
`ContinueRunning` does auto-propagate to grandchildren: if a parent opts
|
||||||
|
its child into `ContinueRunning`, and the child composes grandchildren
|
||||||
|
without explicitly overriding, the grandchildren also get
|
||||||
|
`ContinueRunning`. This is consistent with the composition authority and
|
||||||
|
scoped env propagation in ADR-022 — the parent handler decides the
|
||||||
|
child's runtime context, including abort policy, and that decision
|
||||||
|
propagates through the composition tree by default.
|
||||||
|
|
||||||
|
**Review #002 W19 resolution**: `invoke()` with no explicit policy
|
||||||
|
argument inherits the parent's current policy (option a). It does **not**
|
||||||
|
reset to `AbortDependents`. A handler that wants a child to reset to the
|
||||||
|
default must explicitly call `invoke_with_policy(...,
|
||||||
|
AbortPolicy::AbortDependents)`. This makes the propagation predictable:
|
||||||
|
the policy I set for my child applies to my child's children unless they
|
||||||
|
re-decide. The `invoke()` default in operation-registry.md
|
||||||
|
(`abort_policy: parent.abort_policy.clone()`) is correct.
|
||||||
|
|
||||||
The `OperationEnv` trait gains an optional policy parameter. The specific
|
The `OperationEnv` trait gains an optional policy parameter. The specific
|
||||||
API shape (a separate `invoke_with_policy` method, a policy field on an
|
API shape (a separate `invoke_with_policy` method, a policy field on an
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ forwarding handlers.
|
|||||||
pub async fn from_call(
|
pub async fn from_call(
|
||||||
connection: &CallConnection,
|
connection: &CallConnection,
|
||||||
config: FromCallConfig,
|
config: FromCallConfig,
|
||||||
) -> Vec<(OperationSpec, Handler)>
|
) -> Vec<HandlerRegistration>
|
||||||
```
|
```
|
||||||
|
|
||||||
The adapter:
|
The adapter:
|
||||||
@@ -115,12 +115,15 @@ The adapter:
|
|||||||
operations
|
operations
|
||||||
2. Calls `services/schema` for each → gets the input/output JSON Schemas and
|
2. Calls `services/schema` for each → gets the input/output JSON Schemas and
|
||||||
declared error_schemas (ADR-023)
|
declared error_schemas (ADR-023)
|
||||||
3. For each discovered operation, constructs an `(OperationSpec, Handler)` pair:
|
3. For each discovered operation, constructs a `HandlerRegistration` bundle:
|
||||||
- The spec mirrors the remote operation's name, namespace, type, schemas
|
- The spec mirrors the remote operation's name, namespace, type, schemas
|
||||||
(input, output, and error_schemas — ADR-023), and access control
|
(input, output, and error_schemas — ADR-023), and access control
|
||||||
- The handler sends `call.requested` through the `CallConnection` and awaits
|
- The handler sends `call.requested` through the `CallConnection` and awaits
|
||||||
`call.responded` (or streams for subscriptions)
|
`call.responded` (or streams for subscriptions)
|
||||||
4. The caller registers these pairs in their local registry
|
- `provenance: FromCall`, `composition_authority: None`, `scoped_env: None`
|
||||||
|
(leaves — ADR-022)
|
||||||
|
4. The caller registers these bundles in their local registry (into the
|
||||||
|
connection's overlay — ADR-024)
|
||||||
|
|
||||||
`from_call`-registered operations are `Internal` by default (ADR-015) — they
|
`from_call`-registered operations are `Internal` by default (ADR-015) — they
|
||||||
are composition material, not directly callable from the wire. The handler
|
are composition material, not directly callable from the wire. The handler
|
||||||
@@ -149,15 +152,31 @@ registry; they project it.
|
|||||||
|
|
||||||
### 5. The adapter contract trait
|
### 5. The adapter contract trait
|
||||||
|
|
||||||
The adapter patterns share a common shape: they produce `(OperationSpec,
|
The adapter patterns share a common shape: they produce
|
||||||
Handler)` pairs that register in the local registry. The trait:
|
`HandlerRegistration` bundles that register in the local registry. The
|
||||||
|
trait:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
|
#[async_trait]
|
||||||
pub trait OperationAdapter: Send + Sync {
|
pub trait OperationAdapter: Send + Sync {
|
||||||
fn import(&self) -> Vec<(OperationSpec, Handler)>;
|
async fn import(&self) -> Vec<HandlerRegistration>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The return type is `Vec<HandlerRegistration>` (not `(OperationSpec,
|
||||||
|
Handler)` pairs) — ADR-022 changed the registration API to the bundle
|
||||||
|
shape, and adapters must produce bundles. Adapter convenience methods
|
||||||
|
construct bundles with `composition_authority: None` and `scoped_env: None`
|
||||||
|
for the leaf ops they produce.
|
||||||
|
|
||||||
|
The trait is **async** because `from_call` requires async discovery
|
||||||
|
(`services/list` + `services/schema` over a QUIC connection). A synchronous
|
||||||
|
trait cannot accommodate `from_call` without a separate async pre-step that
|
||||||
|
populates a cache. The sync adapters (`from_openapi`, `from_mcp` reading a
|
||||||
|
static spec) trivially satisfy an async trait — their `import()` bodies
|
||||||
|
contain no `.await` points. The async/sync question is decided: the trait
|
||||||
|
is async.
|
||||||
|
|
||||||
Implementations:
|
Implementations:
|
||||||
- `FromOpenAPI` — imports from an OpenAPI spec (HTTP-backed handlers)
|
- `FromOpenAPI` — imports from an OpenAPI spec (HTTP-backed handlers)
|
||||||
- `FromMCP` — imports from an MCP server (MCP-backed handlers)
|
- `FromMCP` — imports from an MCP server (MCP-backed handlers)
|
||||||
@@ -169,10 +188,10 @@ Implementations:
|
|||||||
The `to_*` adapters are outbound projections, not `OperationAdapter`
|
The `to_*` adapters are outbound projections, not `OperationAdapter`
|
||||||
implementations — they consume the registry, they don't produce entries for it.
|
implementations — they consume the registry, they don't produce entries for it.
|
||||||
|
|
||||||
The specific trait signatures (async vs sync, error types, configuration
|
The specific trait signatures (error types, configuration parameters) are
|
||||||
parameters) are two-way doors for implementation. The one-way door is the
|
two-way doors for implementation. The one-way doors are the architectural
|
||||||
architectural commitment that adapters produce `(OperationSpec, Handler)`
|
commitments: adapters produce `HandlerRegistration` bundles (ADR-022), the
|
||||||
pairs and live in alknet-call.
|
trait is async (required by `from_call`), and adapters live in alknet-call.
|
||||||
|
|
||||||
### 6. Cross-node call tree and abort cascade
|
### 6. Cross-node call tree and abort cascade
|
||||||
|
|
||||||
@@ -245,6 +264,29 @@ same as `from_openapi` receives HTTP credentials.
|
|||||||
(OpenAPI paths, MCP tools). Some semantics don't map cleanly (e.g.,
|
(OpenAPI paths, MCP tools). Some semantics don't map cleanly (e.g.,
|
||||||
subscriptions in OpenAPI, bidirectional calls in MCP). The adapters handle
|
subscriptions in OpenAPI, bidirectional calls in MCP). The adapters handle
|
||||||
these with best-effort mappings and document the gaps.
|
these with best-effort mappings and document the gaps.
|
||||||
|
- **Published `to_*` specs are compatibility contracts.** The "best-effort"
|
||||||
|
mapping label is internal framing. Once a generated spec is published and
|
||||||
|
external clients build against it, the mapping semantics (e.g.,
|
||||||
|
subscriptions → SSE long-poll) become a de facto contract. Changing the
|
||||||
|
mapping later breaks every client. `to_*` mapping choices are two-way
|
||||||
|
*before* first publication but one-way *after*. Version the generated
|
||||||
|
specs (e.g., OpenAPI spec version tied to the registry's External
|
||||||
|
operation set version) and emit a spec version marker so consumers can
|
||||||
|
detect mapping changes. This is the "published artifact is a contract"
|
||||||
|
blind spot in ADR-009's framework: it classifies doors by reversal cost
|
||||||
|
in the codebase, not by compatibility cost for external consumers.
|
||||||
|
- **Sharing the global registry with a `CallClient` exposes local
|
||||||
|
capabilities to the remote peer.** Each `HandlerRegistration` carries
|
||||||
|
`Capabilities` with secret material. If the `CallClient` shares the
|
||||||
|
global registry, a remote peer calling an External operation triggers
|
||||||
|
dispatch that populates `OperationContext.capabilities` from the local
|
||||||
|
registration bundle — meaning the local node's API keys and signing keys
|
||||||
|
are used for the remote peer's call. A peer-scoped subset must filter by
|
||||||
|
capability remote-safety (is this operation's capability safe to expose
|
||||||
|
to this peer?), not just operation name. The registry-mechanism choice
|
||||||
|
(share global vs subset vs separate) is two-way mechanically but has a
|
||||||
|
security dimension post-ADR-022: the "share global" option is a
|
||||||
|
capability-exposure decision, not just a dispatch decision.
|
||||||
- The `CallConnection` abstraction adds a layer between the handler and the
|
- The `CallConnection` abstraction adds a layer between the handler and the
|
||||||
raw QUIC stream. This is necessary for the `from_call` handler to be
|
raw QUIC stream. This is necessary for the `from_call` handler to be
|
||||||
transparent — it shouldn't know about QUIC streams, only about call/request
|
transparent — it shouldn't know about QUIC streams, only about call/request
|
||||||
|
|||||||
@@ -104,7 +104,49 @@ The vault defines its own types and does not share types with alknet-core:
|
|||||||
- `EncryptedData` is the vault's encrypted blob format. It is shared with
|
- `EncryptedData` is the vault's encrypted blob format. It is shared with
|
||||||
`alknet-storage` (a future crate) by type-level agreement, not by a crate
|
`alknet-storage` (a future crate) by type-level agreement, not by a crate
|
||||||
dependency — both crates must agree on the serialization format (see
|
dependency — both crates must agree on the serialization format (see
|
||||||
[encryption.md](../crates/vault/encryption.md)).
|
[encryption.md](../crates/vault/encryption.md)). The format is **frozen**
|
||||||
|
(see Decision below).
|
||||||
|
|
||||||
|
## `EncryptedData` Wire Format Lock
|
||||||
|
|
||||||
|
The `EncryptedData` struct is a **stable wire format** shared with
|
||||||
|
`alknet-storage` (a future crate) and the TypeScript consumer
|
||||||
|
(`@alkdev/storage`) by type-level agreement, not by a crate dependency.
|
||||||
|
Both crates and the TypeScript consumer must agree on the serialization
|
||||||
|
format. The format is now explicitly **frozen**:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct EncryptedData {
|
||||||
|
pub key_version: u32, // rotation tracking
|
||||||
|
pub salt: String, // base64, 32 bytes — unused in v2 (wire-format compat)
|
||||||
|
pub iv: String, // base64, 12 bytes — AES-GCM nonce
|
||||||
|
pub data: String, // base64 — ciphertext + auth tag
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The frozen compatibility surface:
|
||||||
|
|
||||||
|
- **Fields**: `key_version`, `salt`, `iv`, `data` — no fields may be
|
||||||
|
removed or renamed. New fields may be added only if they are optional
|
||||||
|
(default on deserialization) and do not change the meaning of existing
|
||||||
|
fields.
|
||||||
|
- **Encoding**: all binary fields are base64-encoded as strings for JSON
|
||||||
|
serialization. This is the cross-language wire format.
|
||||||
|
- **Field semantics**: `key_version` selects the derivation path
|
||||||
|
(ADR-021). `salt` is unused in v2 but is part of the frozen format —
|
||||||
|
it cannot be removed without a format-version migration (a future KDF
|
||||||
|
in v3 would use the salt for *new* data, not retroactively for v2 data
|
||||||
|
— see ADR-020, W6). `iv` is the 12-byte GCM nonce. `data` is the
|
||||||
|
ciphertext with the GCM auth tag appended.
|
||||||
|
|
||||||
|
**Why this needs an explicit lock**: the "type-level agreement, not a
|
||||||
|
crate dependency" approach means there is no compiler enforcement of the
|
||||||
|
format across crates. The stability contract existed only in prose. An
|
||||||
|
implementer modifying `EncryptedData` (e.g., removing the unused `salt`
|
||||||
|
field) would find no ADR saying "this format is frozen." This decision
|
||||||
|
makes the freeze explicit and enforceable by review.
|
||||||
|
|
||||||
|
**This resolves review #002 W10.**
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
|
|||||||
@@ -153,8 +153,20 @@ not a bug. The migration tool handles the TS→vault transition.
|
|||||||
If a future use case requires KDF-based key derivation (e.g., stretching a
|
If a future use case requires KDF-based key derivation (e.g., stretching a
|
||||||
key derived from a non-seed source, or using a salt for additional domain
|
key derived from a non-seed source, or using a salt for additional domain
|
||||||
separation), it would be a new key_version with its own derivation method.
|
separation), it would be a new key_version with its own derivation method.
|
||||||
The `salt` field is available for this. This is additive — it doesn't
|
The `salt` field is available for this.
|
||||||
change v2 data. See OQ-22 (key rotation).
|
|
||||||
|
**Clarification (review #002 W6)**: the salt field is reserved for *future
|
||||||
|
versions'* use. v2 data's salt is permanently unused — it was random, never
|
||||||
|
participated in key derivation, and cannot be retroactively made
|
||||||
|
load-bearing for v2 data. Introducing a KDF in v3 is a new derivation
|
||||||
|
method (not a version-indexed path), requiring its own design and a v2→v3
|
||||||
|
migration (re-encrypt with the new KDF, using a newly-generated v3 salt —
|
||||||
|
the v2 salt is not reused). The field's presence saves a wire-format struct
|
||||||
|
change only (ADR-018 locks the wire format); it does not make the KDF
|
||||||
|
design or migration trivial. A KDF doesn't fit the rotation scheme
|
||||||
|
(version-indexed paths, ADR-021) — it's a different derivation *family*,
|
||||||
|
not another version index. See OQ-22 (key rotation) and ADR-018
|
||||||
|
(`EncryptedData` wire format lock).
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
|
|||||||
@@ -196,6 +196,30 @@ impl CompositionAuthority {
|
|||||||
resources: HashMap::new(),
|
resources: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert to a synthetic `Identity` for ACL matching on child calls.
|
||||||
|
///
|
||||||
|
/// When a handler composes a child via `env.invoke()`, the child's
|
||||||
|
/// `identity` (the caller identity for ACL) is set to the parent's
|
||||||
|
/// composition authority converted to an `Identity`. This constructs
|
||||||
|
/// a synthetic `Identity { id: label, scopes, resources }` that is
|
||||||
|
/// **not** resolvable via `IdentityProvider` — it's not a peer
|
||||||
|
/// identity, it's a declared authority bundle used directly for ACL
|
||||||
|
/// matching. This creates a second `Identity` construction path (the
|
||||||
|
/// first is `IdentityProvider::resolve_*`), which is acknowledged and
|
||||||
|
/// intentional: the composition authority is a declared authority, not
|
||||||
|
/// a resolved credential.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the authority is `None` (leaf case — leaves
|
||||||
|
/// don't compose, so `as_identity()` is never called on them in
|
||||||
|
/// practice, but the `Option` makes the types line up).
|
||||||
|
pub fn as_identity(&self) -> Option<Identity> {
|
||||||
|
Some(Identity {
|
||||||
|
id: self.label.clone(),
|
||||||
|
scopes: self.scopes.clone(),
|
||||||
|
resources: self.resources.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -224,12 +248,17 @@ as a subgraph of the operation graph.
|
|||||||
/// set of operation names — the *model* is a subgraph (which nodes this
|
/// set of operation names — the *model* is a subgraph (which nodes this
|
||||||
/// handler can reach), but type-compatibility edges between those nodes are
|
/// handler can reach), but type-compatibility edges between those nodes are
|
||||||
/// a future enhancement for static validation, not a v1 requirement.
|
/// a future enhancement for static validation, not a v1 requirement.
|
||||||
|
///
|
||||||
|
/// The `allowed_operations` field is **private** (not `pub`). Construction
|
||||||
|
/// is via `ScopedOperationEnv::new(ops)` or `ScopedOperationEnv::empty()`.
|
||||||
|
/// Reachability is queried via `allows(&name)`. This encapsulation makes the
|
||||||
|
/// future subgraph refactor (from `HashSet<String>` to a typed subgraph) a
|
||||||
|
/// non-breaking change to construction sites (review #002 W21). The
|
||||||
|
/// `HashSet<String>` representation does not support type-compatibility
|
||||||
|
/// validation — session-scoped ops (OQ-19, untrusted code) compose without
|
||||||
|
/// static type checking until a flowgraph crate is built.
|
||||||
pub struct ScopedOperationEnv {
|
pub struct ScopedOperationEnv {
|
||||||
/// Operation names this handler may compose (e.g., {"fs/readFile",
|
allowed_operations: HashSet<String>,
|
||||||
/// "vastai/listMachines"}). `env.invoke()` for any name not in this set
|
|
||||||
/// returns NOT_FOUND. This is the reachability boundary — it bounds the
|
|
||||||
/// parameterized-dispatch attack surface.
|
|
||||||
pub allowed_operations: HashSet<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScopedOperationEnv {
|
impl ScopedOperationEnv {
|
||||||
@@ -353,7 +382,7 @@ async fn invoke(&self, namespace: &str, operation: &str, input: Value,
|
|||||||
let context = OperationContext {
|
let context = OperationContext {
|
||||||
request_id: generate_request_id(),
|
request_id: generate_request_id(),
|
||||||
parent_request_id: Some(parent.request_id.clone()),
|
parent_request_id: Some(parent.request_id.clone()),
|
||||||
identity: parent.handler_identity_as_identity(), // parent's authority becomes the caller
|
identity: parent.handler_identity.as_identity(), // parent's authority becomes the caller
|
||||||
handler_identity: registration.composition_authority.clone(), // C1: child's own authority
|
handler_identity: registration.composition_authority.clone(), // C1: child's own authority
|
||||||
capabilities: parent.capabilities.clone(), // C3: propagate through composition
|
capabilities: parent.capabilities.clone(), // C3: propagate through composition
|
||||||
metadata: HashMap::new(), // fresh — does NOT propagate (ADR-014)
|
metadata: HashMap::new(), // fresh — does NOT propagate (ADR-014)
|
||||||
@@ -409,10 +438,8 @@ let agent_registration = HandlerRegistration {
|
|||||||
scopes: vec!["llm:call".into(), "fs:read".into(), "vastai:query".into()],
|
scopes: vec!["llm:call".into(), "fs:read".into(), "vastai:query".into()],
|
||||||
resources: HashMap::new(),
|
resources: HashMap::new(),
|
||||||
}),
|
}),
|
||||||
scoped_env: Some(ScopedOperationEnv {
|
scoped_env: Some(ScopedOperationEnv::new(
|
||||||
allowed_operations: HashSet::from(["fs/readFile".into(), "vastai/listMachines".into(),
|
["fs/readFile", "vastai/listMachines", "llm/generate"])),
|
||||||
"llm/generate".into()]),
|
|
||||||
}),
|
|
||||||
capabilities: Capabilities::new()
|
capabilities: Capabilities::new()
|
||||||
.with_api_key("google", google_api_key), // C3: in the bundle, not the closure
|
.with_api_key("google", google_api_key), // C3: in the bundle, not the closure
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -238,9 +238,23 @@ accordingly.
|
|||||||
|
|
||||||
```rust
|
```rust
|
||||||
// OpenAPI: 404: { schema: NotFoundError }
|
// OpenAPI: 404: { schema: NotFoundError }
|
||||||
// → ErrorDefinition { code: "NOT_FOUND", http_status: Some(404), schema: NotFoundError }
|
// → ErrorDefinition { code: "HTTP_404", http_status: Some(404), schema: NotFoundError }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Normative rule (review #002 W20)**: `from_openapi` must not produce error
|
||||||
|
codes that collide with the five protocol-level codes (`NOT_FOUND`,
|
||||||
|
`FORBIDDEN`, `INVALID_INPUT`, `INTERNAL`, `TIMEOUT`). The adapter prefixes
|
||||||
|
imported error codes with `HTTP_` and the status number (e.g., `HTTP_404`,
|
||||||
|
`HTTP_429`) to avoid collision. This is a requirement for the adapter, not
|
||||||
|
a naming convention — the `from_openapi` example above was previously shown
|
||||||
|
producing `NOT_FOUND` from a 404, which collided with the protocol-level
|
||||||
|
`NOT_FOUND` (operation not registered). The `details` field disambiguates
|
||||||
|
in practice (present for operation-level, absent for protocol-level), but
|
||||||
|
ADR-023 says "clients should switch on `code`, not parse `message`" — so
|
||||||
|
the `code` alone must be unambiguous. Operations that hand-write their own
|
||||||
|
`ErrorDefinition`s should use domain-specific codes (`FILE_NOT_FOUND`,
|
||||||
|
`RATE_LIMITED`) rather than reusing protocol codes.
|
||||||
|
|
||||||
The adapter maps the OpenAPI error schema to alknet's JSON Schema format
|
The adapter maps the OpenAPI error schema to alknet's JSON Schema format
|
||||||
(same conversion as input/output schemas). The `http_status` field records
|
(same conversion as input/output schemas). The `http_status` field records
|
||||||
the original status code so `to_openapi` can project it back.
|
the original status code so `to_openapi` can project it back.
|
||||||
|
|||||||
185
docs/architecture/decisions/026-vault-key-model-hd-derivation.md
Normal file
185
docs/architecture/decisions/026-vault-key-model-hd-derivation.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# ADR-026: Vault Key Model — HD Derivation
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The vault's primary use of HD (hierarchical deterministic) derivation is
|
||||||
|
for identity keys, SSH host keys, and signing keys. ADR-020 covers HD
|
||||||
|
derivation for *encryption* keys specifically, but the broader decision —
|
||||||
|
"the vault uses HD derivation from a single BIP39 seed for all
|
||||||
|
self-generated secrets, not stored keys" — has no ADR. The rationale is
|
||||||
|
inline in `mnemonic-derivation.md`'s "Why HD Derivation" section, but the
|
||||||
|
choice is a one-way door: switching to stored keys would change the entire
|
||||||
|
trust model, the backup story, and the derivation path semantics.
|
||||||
|
|
||||||
|
Several related design choices also have inline rationale but no ADR:
|
||||||
|
|
||||||
|
- **`74'` coin type reservation** (SLIP-0044): alknet claims an unallocated
|
||||||
|
coin type for its derivation paths. Once keys are derived at `m/74'/...`,
|
||||||
|
changing the coin type would re-derive all keys — effectively one-way.
|
||||||
|
- **secp256k1 feature-gating**: the secp256k1/BIP-0032 dependency (needed
|
||||||
|
only for Ethereum signing) is feature-gated to avoid pulling a heavy C
|
||||||
|
dependency into nodes that don't do Ethereum signing.
|
||||||
|
- **AES-256-GCM cipher/mode choice**: the authenticated encryption scheme
|
||||||
|
for credential storage. The rationale (authenticated, hardware-accelerated)
|
||||||
|
is inline in `encryption.md` with no ADR.
|
||||||
|
|
||||||
|
These are foundational one-way doors that the entire vault model depends
|
||||||
|
on. They should be recorded as ADRs so a future reader sees *why* these
|
||||||
|
choices were made, not just *what* they are.
|
||||||
|
|
||||||
|
### Relationship to ADR-020
|
||||||
|
|
||||||
|
ADR-020 is a special case of this ADR — it covers HD derivation for the
|
||||||
|
*encryption key* specifically, including the v1→v2 migration from PBKDF2
|
||||||
|
to HD derivation. This ADR covers the *general* HD-derivation model that
|
||||||
|
ADR-020 builds on. ADR-020's decision (HD derivation at `m/74'/2'/0'/0'`
|
||||||
|
for encryption keys) is unchanged; this ADR records the overarching
|
||||||
|
principle.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. HD derivation from a single BIP39 seed is the vault's key model
|
||||||
|
|
||||||
|
All self-generated secrets in alknet are derived from a single BIP39
|
||||||
|
mnemonic via hierarchical deterministic (HD) derivation. The vault does
|
||||||
|
not store keys — it derives them on demand from the seed and caches them
|
||||||
|
for performance (the cache is rebuildable from the seed).
|
||||||
|
|
||||||
|
This is the same model as cryptocurrency wallets: one seed phrase, many
|
||||||
|
derived keys at deterministic paths. The properties that make this the
|
||||||
|
right model for alknet:
|
||||||
|
|
||||||
|
- **No key storage**: keys are derived on demand, not stored. The vault
|
||||||
|
caches derived keys for performance, but the cache is rebuildable from
|
||||||
|
the seed. No key file management, no key rotation infrastructure, no
|
||||||
|
per-key backup.
|
||||||
|
- **Reproducible across nodes**: the same mnemonic on a different node
|
||||||
|
produces the same keys. A backup node derives the same identity key.
|
||||||
|
This is critical for disaster recovery — the mnemonic is the only thing
|
||||||
|
that needs to be backed up.
|
||||||
|
- **Domain separation**: different paths produce cryptographically
|
||||||
|
independent keys. The identity key, SSH host key, encryption key, and
|
||||||
|
signing keys are all independent despite coming from one seed.
|
||||||
|
- **Auditable derivation**: the path records what a key is for.
|
||||||
|
`m/74'/0'/0'/0'` is the identity key; `m/74'/0'/1'/0'` is the SSH host
|
||||||
|
key. The path is the documentation.
|
||||||
|
|
||||||
|
### 2. SLIP-0010 (Ed25519) is the default derivation scheme
|
||||||
|
|
||||||
|
Ed25519 is alknet's default curve — it's what TLS raw key identity
|
||||||
|
(ADR-010), SSH host keys, and signing keys use. SLIP-0010 is the HD
|
||||||
|
derivation standard for Ed25519 (hardened-only, HMAC-SHA512 with
|
||||||
|
`"ed25519 seed"` as the key).
|
||||||
|
|
||||||
|
BIP-0032 (secp256k1) is supported for Ethereum signing (the standard
|
||||||
|
Ethereum path `m/44'/60'/0'/0/0` requires unhardened indices, which
|
||||||
|
SLIP-0010 cannot handle). secp256k1 is feature-gated (see Decision 4).
|
||||||
|
|
||||||
|
### 3. `74'` coin type is reserved for alknet
|
||||||
|
|
||||||
|
alknet reserves the `74'` coin type (unallocated per SLIP-0044) for its
|
||||||
|
derivation paths. All alknet paths start with `m/74'/...`:
|
||||||
|
|
||||||
|
| Path prefix | Purpose |
|
||||||
|
|-------------|---------|
|
||||||
|
| `m/74'/0'/...` | Identity keys (node, device, SSH host) |
|
||||||
|
| `m/74'/2'/...` | Encryption keys (credential storage) |
|
||||||
|
| `m/44'/60'/...` | Ethereum signing keys (secp256k1, standard BIP-44) |
|
||||||
|
|
||||||
|
Once keys are derived at `m/74'/...`, the coin type cannot be changed
|
||||||
|
without re-deriving all keys from a new path — which would produce
|
||||||
|
different keys, breaking all existing identity, TLS, SSH, and encryption
|
||||||
|
contexts. This is effectively one-way once any deployment generates keys.
|
||||||
|
|
||||||
|
### 4. secp256k1 is feature-gated
|
||||||
|
|
||||||
|
The `secp256k1` crate (BIP-0032 derivation for Ethereum) is a heavy C
|
||||||
|
dependency. Most alknet nodes do not do Ethereum signing and should not
|
||||||
|
pay the compilation cost. The `secp256k1` feature flag gates
|
||||||
|
Ethereum-specific derivation:
|
||||||
|
|
||||||
|
- Without the feature: `derive_ethereum_key` returns
|
||||||
|
`VaultServiceError::UnsupportedKeyType`.
|
||||||
|
- With the feature: full BIP-0032 secp256k1 derivation at the standard
|
||||||
|
Ethereum path.
|
||||||
|
|
||||||
|
### 5. AES-256-GCM for credential encryption
|
||||||
|
|
||||||
|
External credentials (API keys, OAuth tokens, bearer tokens) are encrypted
|
||||||
|
at rest using AES-256-GCM with a seed-derived key. AES-256-GCM is an
|
||||||
|
authenticated encryption scheme — it provides both confidentiality
|
||||||
|
(encryption) and integrity (authentication tag). A tampered ciphertext
|
||||||
|
fails decryption, which is the correct behavior for credential storage:
|
||||||
|
if an attacker modifies an encrypted API key in storage, decryption fails
|
||||||
|
rather than producing a different plaintext.
|
||||||
|
|
||||||
|
GCM is hardware-accelerated on modern CPUs (AES-NI), making it fast enough
|
||||||
|
that encryption is never a bottleneck. The 12-byte nonce (IV) is generated
|
||||||
|
with `OsRng` (CSPRNG) — IV reuse under the same key is catastrophic for
|
||||||
|
GCM.
|
||||||
|
|
||||||
|
The encryption key is derived from the seed at `m/74'/2'/0'/0'` via
|
||||||
|
SLIP-0010 — see ADR-020 for the full encryption key derivation rationale
|
||||||
|
and the v1→v2 migration from PBKDF2.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
|
||||||
|
- One seed, many keys, no key storage. The mnemonic is the only thing
|
||||||
|
that needs to be backed up. Disaster recovery is "restore the mnemonic,
|
||||||
|
re-derive everything."
|
||||||
|
- Reproducibility across nodes. A backup node with the same mnemonic
|
||||||
|
derives the same identity key, SSH host key, and encryption key. This
|
||||||
|
is critical for failover and migration.
|
||||||
|
- Domain separation via paths. The path *is* the documentation of what a
|
||||||
|
key is for. No separate key registry or metadata needed.
|
||||||
|
- Ed25519 as the default curve aligns with TLS raw key identity (RFC 7250,
|
||||||
|
ADR-010), SSH key-based auth, and iroh's NodeId model. One key type for
|
||||||
|
all identity purposes.
|
||||||
|
- secp256k1 feature-gating keeps the default dependency tree lean. Nodes
|
||||||
|
that don't do Ethereum signing don't pay the secp256k1 compilation cost.
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
|
||||||
|
- The mnemonic is a single point of failure. If the mnemonic is lost, all
|
||||||
|
derived keys are lost. If the mnemonic is compromised, all derived keys
|
||||||
|
are compromised. Mitigated: the mnemonic is stored offline (written
|
||||||
|
down), the vault is local-only (ADR-025), and the passphrase (BIP39
|
||||||
|
password extension) adds a second factor.
|
||||||
|
- Changing the coin type (`74'`) or any path prefix is effectively
|
||||||
|
one-way once keys are derived. This is inherent to HD derivation — the
|
||||||
|
path *is* the key identity. Mitigated: the path scheme is designed to
|
||||||
|
accommodate future use cases (device index, key version) without
|
||||||
|
changing prefixes.
|
||||||
|
- Ed25519-only for the default derivation scheme means non-hardened
|
||||||
|
derivation is not available (SLIP-0010 limitation). If a future use case
|
||||||
|
needs non-hardened Ed25519 derivation (e.g., deriving public keys from a
|
||||||
|
public key without the seed), SLIP-0010 cannot do it. Mitigated: this is
|
||||||
|
not a current use case; if it becomes one, a different derivation scheme
|
||||||
|
or a non-HD approach would be needed for that specific key.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- ADR-020: HD derivation for encryption keys (a special case of this ADR —
|
||||||
|
covers the encryption key at `m/74'/2'/0'/0'` and the v1→v2 migration
|
||||||
|
from PBKDF2)
|
||||||
|
- ADR-010: ALPN router and endpoint (Ed25519 as the default curve for TLS
|
||||||
|
raw key identity — the identity key at `m/74'/0'/0'/0'`)
|
||||||
|
- ADR-018: Vault as standalone crate (the vault defines its own key types
|
||||||
|
and derivation paths)
|
||||||
|
- ADR-025: Vault local-only dispatch (the vault is local-only; the seed
|
||||||
|
never crosses the network)
|
||||||
|
- [mnemonic-derivation.md](../crates/vault/mnemonic-derivation.md) —
|
||||||
|
BIP39, SLIP-0010, BIP-0032, derivation paths, PATHS module
|
||||||
|
- [encryption.md](../crates/vault/encryption.md) — AES-256-GCM,
|
||||||
|
EncryptedData, key versioning
|
||||||
|
- SLIP-0010: Universal hierarchical deterministic keys (Ed25519)
|
||||||
|
- SLIP-0044: Registered coin types for BIP-0032 / SLIP-0010 (`74'` is
|
||||||
|
unallocated)
|
||||||
|
- BIP-0032: Hierarchical deterministic wallets (secp256k1)
|
||||||
|
- BIP-39: Mnemonic code for generating deterministic keys
|
||||||
@@ -118,7 +118,7 @@ These questions are acknowledged but not active. They will be promoted to open w
|
|||||||
- **Status**: deferred
|
- **Status**: deferred
|
||||||
- **Door type**: One-way (when applicable)
|
- **Door type**: One-way (when applicable)
|
||||||
- **Priority**: low
|
- **Priority**: low
|
||||||
- **Resolution**: Not an active question — WASM compatibility is a design constraint (see ADR-009, overview.md design principles), not a deliverable. Specific WASM targeting decisions will be made when individual crates are implemented. The BiStream trait decision (ADR-007) has already preserved the most important WASM door.
|
- **Resolution**: Not an active question — WASM compatibility is a design constraint (see ADR-009, overview.md design principles), not a deliverable. Specific WASM targeting decisions will be made when individual crates are implemented. **BiStream being a trait preserves the *client-side* stream door** — a browser can implement BiStream over WebTransport streams. **The *server-side* dispatch door is NOT preserved by ADR-007 and is a known, accepted closure**: `Connection` is a concrete quinn-bound struct (not a trait), the accept loop uses `tokio::spawn` (tokio does not run on WASM), and the call-protocol dispatch internals (`PendingRequestMap`, `CallAdapter`) use tokio `oneshot`/`mpsc` channels. A WASM server-side peer would require a `Connection` trait and a runtime-abstracted accept loop — not planned. The browser path is client-side via a JS SDK, not server-side Rust-to-WASM. This is an explicit one-way door, not an oversight.
|
||||||
- **Cross-references**: ADR-007, ADR-009
|
- **Cross-references**: ADR-007, ADR-009
|
||||||
|
|
||||||
### OQ-10: Git Adapter Scope — Smart Protocol Only or Full Server?
|
### OQ-10: Git Adapter Scope — Smart Protocol Only or Full Server?
|
||||||
@@ -127,7 +127,7 @@ These questions are acknowledged but not active. They will be promoted to open w
|
|||||||
- **Status**: deferred
|
- **Status**: deferred
|
||||||
- **Door type**: Two-way
|
- **Door type**: Two-way
|
||||||
- **Priority**: low
|
- **Priority**: low
|
||||||
- **Resolution**: Deferred per the cleanup plan. Start with git smart protocol over QUIC streams. ERC721 integration and full server capabilities are additive. Resolve when speccing alknet-git.
|
- **Resolution**: Deferred per the cleanup plan. Start with git smart protocol over QUIC streams. ERC721 integration and full server capabilities are additive. **Composability fork (review #002 W18)**: whether git operations are registered in the `OperationRegistry` and callable via `env.invoke()`, or only available as raw smart protocol on `alknet/git`, is a separate decision from ERC721 scope. The path of least resistance (raw smart protocol only) forecloses agent composition of git operations — an agent handler that wants to compose `git/clone` cannot, because there's no `OperationSpec`, no `Handler`, no registration. To make git composable, a call-protocol projection (a set of `HandlerRegistration` bundles wrapping git operations behind the registry) must be built alongside or instead of the raw handler. Resolve this when speccing alknet-git, not deferred past it.
|
||||||
- **Cross-references**: ADR-001
|
- **Cross-references**: ADR-001
|
||||||
|
|
||||||
## Theme: alknet-core
|
## Theme: alknet-core
|
||||||
@@ -150,7 +150,18 @@ These questions are acknowledged but not active. They will be promoted to open w
|
|||||||
|
|
||||||
Both exist. The connection-level identity is the stable "who is this connection from"; the per-request identity is the dynamic "who is this specific call from." The call protocol's per-request resolution (which may produce a different identity than the connection-level resolution) takes precedence for ACL on `OperationContext` — the connection-level identity is for observability only, not for ACL.
|
Both exist. The connection-level identity is the stable "who is this connection from"; the per-request identity is the dynamic "who is this specific call from." The call protocol's per-request resolution (which may produce a different identity than the connection-level resolution) takes precedence for ACL on `OperationContext` — the connection-level identity is for observability only, not for ACL.
|
||||||
|
|
||||||
`Connection` exposes `set_identity` via interior mutability (`OnceLock<Identity>` or `RwLock<Option<Identity>>` — the handler sets it once when resolved, the endpoint and observability layers read it). `handle()` receives `Connection` by value (owned), but the endpoint may also hold a reference for logging. The identity is write-once-read-many.
|
**C13 resolution (review #002)**: the endpoint does **not** read
|
||||||
|
`identity()` after `handle()` returns. The `Connection` is moved into the
|
||||||
|
spawned handler task (endpoint.md), so the endpoint no longer has a
|
||||||
|
reference to it. Connection-level observability (remote addr, ALPN,
|
||||||
|
connection ID) is logged by the endpoint *before* the move. Identity-level
|
||||||
|
observability is logged by the handler (the handler knows which identity
|
||||||
|
it resolved and can log it). There is no `Arc<Connection>` sharing or
|
||||||
|
channel-based identity-reporting mechanism — the simplest honest answer
|
||||||
|
that avoids over-engineering the observability path before there's a
|
||||||
|
demonstrated need. If a future use case requires the endpoint to
|
||||||
|
correlate connections to identities, an `Arc<Connection>` or a
|
||||||
|
side-channel can be added then.
|
||||||
- **Cross-references**: ADR-004, ADR-011, ADR-015 (per-request identity on OperationContext), [auth.md](crates/core/auth.md)
|
- **Cross-references**: ADR-004, ADR-011, ADR-015 (per-request identity on OperationContext), [auth.md](crates/core/auth.md)
|
||||||
|
|
||||||
### OQ-12: TLS Identity Provisioning in AlknetEndpoint
|
### OQ-12: TLS Identity Provisioning in AlknetEndpoint
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
| [023](decisions/023-operation-error-schemas.md) | Operation Error Schemas | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity for `from_openapi`/`to_openapi` |
|
| [023](decisions/023-operation-error-schemas.md) | Operation Error Schemas | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity for `from_openapi`/`to_openapi` |
|
||||||
| [024](decisions/024-operation-registry-layering.md) | Operation Registry Layering | Curated (static) + session/connection overlays (dynamic); `OperationEnv` as trait-object integration point |
|
| [024](decisions/024-operation-registry-layering.md) | Operation Registry Layering | Curated (static) + session/connection overlays (dynamic); `OperationEnv` as trait-object integration point |
|
||||||
| [025](decisions/025-vault-local-only-dispatch.md) | Vault Local-Only Dispatch | Dropped irpc from vault; direct method calls; local-only by construction |
|
| [025](decisions/025-vault-local-only-dispatch.md) | Vault Local-Only Dispatch | Dropped irpc from vault; direct method calls; local-only by construction |
|
||||||
|
| [026](decisions/026-vault-key-model-hd-derivation.md) | Vault Key Model — HD Derivation | HD derivation from BIP39 seed; `74'` coin type; SLIP-0010/Ed25519 default; AES-256-GCM for credentials |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user