From dd1ca1de702f56694b9d9c0e109793240836dbe1 Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Fri, 19 Jun 2026 09:23:47 +0000 Subject: [PATCH] docs(architecture): add alknet-vault spec, ADR-018, ADR-019, OQ-20/21/22 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec the vault crate from its existing implementation. The vault is stable (implementation exists); this spec documents what IS so the implementation-sync agent can reconcile source drift. New spec documents (crates/vault/): - README.md — crate index, security constraints, public API - mnemonic-derivation.md — BIP39, SLIP-0010, BIP-0032, derivation paths - encryption.md — AES-256-GCM, EncryptedData, key versioning, salt - service.md — VaultServiceHandle lifecycle, actor dispatch, cache - protocol.md — VaultProtocol irpc messages, DerivedKey redaction New ADRs: - ADR-018: Vault as standalone crate (zero alknet deps; own types/errors) - ADR-019: Vault assembly-layer-only access (CLI is sole caller) New open questions: - OQ-20: Salt/KDF Phase B (open, low priority — salt field reserved) - OQ-21: Remote vault administration (deferred — needs ADR if ever needed) - OQ-22: Key rotation mechanism (open, low priority — workflow not specced) Spec-vs-source drift explicitly flagged (for the sync agent): - rand::random() used for IVs instead of OsRng (security-critical) - unwrap() on every RwLock acquisition (must use unwrap_or_else) - ADR-038 / OQ-SVC-03 references in source comments are stale (old numbering) - VaultServiceActor::spawn returns a non-functional second actor (source bug) - KeyVersionMismatch error variant is defined but unused in v1 --- docs/architecture/README.md | 20 +- docs/architecture/crates/vault/README.md | 131 +++++++ docs/architecture/crates/vault/encryption.md | 215 +++++++++++ .../crates/vault/mnemonic-derivation.md | 301 +++++++++++++++ docs/architecture/crates/vault/protocol.md | 177 +++++++++ docs/architecture/crates/vault/service.md | 361 ++++++++++++++++++ .../decisions/018-vault-standalone-crate.md | 162 ++++++++ .../019-vault-assembly-layer-only.md | 165 ++++++++ docs/architecture/open-questions.md | 31 +- docs/architecture/overview.md | 9 +- 10 files changed, 1564 insertions(+), 8 deletions(-) create mode 100644 docs/architecture/crates/vault/README.md create mode 100644 docs/architecture/crates/vault/encryption.md create mode 100644 docs/architecture/crates/vault/mnemonic-derivation.md create mode 100644 docs/architecture/crates/vault/protocol.md create mode 100644 docs/architecture/crates/vault/service.md create mode 100644 docs/architecture/decisions/018-vault-standalone-crate.md create mode 100644 docs/architecture/decisions/019-vault-assembly-layer-only.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 7344f6c..34d7a37 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -1,15 +1,15 @@ --- status: draft -last_updated: 2026-06-21 +last_updated: 2026-06-19 --- # Alknet Architecture ## 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) and research/reference material. Foundational ADRs (001–017) 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), and call protocol client and adapter contract (ADR-017). The alknet-core and alknet-call 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) and research/reference material. Foundational ADRs (001–019) 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), and vault assembly-layer-only access (ADR-019). The alknet-core, alknet-call, and alknet-vault crate specs are in draft. -**Next step**: Review alknet-call spec documents, then begin implementation. All open questions are resolved. +**Next step**: Review the vault spec documents (newly added), then begin implementation. All open questions for the core and call crates are resolved; the vault crate has three open/deferred OQs (OQ-20, OQ-21, OQ-22) that do not block implementation. ## Architecture Documents @@ -25,6 +25,11 @@ last_updated: 2026-06-21 | [crates/call/README.md](crates/call/README.md) | draft | alknet-call crate index | | [crates/call/call-protocol.md](crates/call/call-protocol.md) | draft | CallAdapter, EventEnvelope framing, stream model, PendingRequestMap, bidirectional calls, streaming subscribe example | | [crates/call/operation-registry.md](crates/call/operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, capability injection, service discovery, irpc integration | +| [crates/vault/README.md](crates/vault/README.md) | draft | alknet-vault crate index | +| [crates/vault/mnemonic-derivation.md](crates/vault/mnemonic-derivation.md) | draft | BIP39, SLIP-0010, BIP-0032, derivation paths, key types | +| [crates/vault/encryption.md](crates/vault/encryption.md) | draft | AES-256-GCM, EncryptedData, key versioning, salt (Phase B reserved) | +| [crates/vault/service.md](crates/vault/service.md) | draft | VaultServiceHandle lifecycle, actor dispatch, cache, error model | +| [crates/vault/protocol.md](crates/vault/protocol.md) | draft | VaultProtocol irpc messages, DerivedKey redaction, serialization | ## ADR Table @@ -47,6 +52,8 @@ last_updated: 2026-06-21 | [015](decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | Accepted | | [016](decisions/016-abort-cascade-for-nested-calls.md) | Abort Cascade for Nested Calls | Accepted | | [017](decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | Accepted | +| [018](decisions/018-vault-standalone-crate.md) | Vault as Standalone Crate | Accepted | +| [019](decisions/019-vault-assembly-layer-only.md) | Vault Assembly-Layer-Only Access | Accepted | ## Open Questions @@ -73,11 +80,14 @@ See [open-questions.md](open-questions.md) for the full tracker. - **OQ-14**: Batch operation semantics — multiple correlated `call.requested` events is the correct protocol design, not a simplification - **OQ-19**: Session-scoped registries — agent-written operations via `OperationEnv` trait layering; protocol doesn't need changes; `OperationEnv` must remain a trait +**Open (low priority, does not block implementation):** +- **OQ-20**: Salt/KDF Phase B — the `EncryptedData.salt` field is reserved; v1 does not use it. Two-way door. +- **OQ-22**: Key rotation mechanism — key versioning is in place; the rotation workflow is not specced. Two-way door. + **Deferred (not active):** - **OQ-09**: WASM target boundaries — design constraint, not deliverable - **OQ-10**: Git adapter scope — start with smart protocol, add ERC721 later - -**All open questions are resolved.** No open one-way or two-way doors remain. The architecture is ready for review. +- **OQ-21**: Remote vault administration — network unlock not supported; needs ADR if ever needed ## Document Lifecycle diff --git a/docs/architecture/crates/vault/README.md b/docs/architecture/crates/vault/README.md new file mode 100644 index 0000000..a834762 --- /dev/null +++ b/docs/architecture/crates/vault/README.md @@ -0,0 +1,131 @@ +--- +status: draft +last_updated: 2026-06-19 +--- + +# alknet-vault + +Local key vault: BIP39 mnemonic generation, SLIP-0010 Ed25519 HD key +derivation, BIP-0032 secp256k1 derivation (feature-gated), and AES-256-GCM +encryption. Holds the master seed — the root of trust for all derived keys +and encrypted credentials in the alknet system. + +## What This Crate Is + +alknet-vault is a **standalone crate** with zero alknet crate dependencies +(ADR-018). It provides the cryptographic primitives and runtime API for +managing the root of trust. The CLI binary (the `alknet` crate) is the sole +component that talks to the vault directly (ADR-019) — handlers receive +derived/decrypted material through capabilities, never through a vault +reference. + +The vault is **not a network service**. It has no ALPN, no +`ProtocolHandler` implementation, and no operations registered in the call +protocol (ADR-008, ADR-014). The master seed and derived private keys never +cross the network. + +## Documents + +| Document | Status | Description | +|----------|--------|-------------| +| [mnemonic-derivation.md](mnemonic-derivation.md) | draft | BIP39, SLIP-0010, BIP-0032, derivation paths, key types | +| [encryption.md](encryption.md) | draft | AES-256-GCM, EncryptedData, key versioning, salt (Phase B reserved) | +| [service.md](service.md) | draft | VaultServiceHandle lifecycle, actor dispatch, cache, error model | +| [protocol.md](protocol.md) | draft | VaultProtocol irpc messages, DerivedKey redaction, serialization | + +## Applicable ADRs + +| ADR | Title | Relevance | +|-----|-------|-----------| +| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-vault's standalone position | +| [005](../../decisions/005-irpc-as-call-protocol-foundation.md) | irpc as Call Protocol Foundation | VaultProtocol uses irpc directly | +| [008](../../decisions/008-secret-service-integration.md) | Vault Integration Point | CLI-embedded, capability source | +| [014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Capabilities carry vault-derived material | +| [018](../../decisions/018-vault-standalone-crate.md) | Vault as Standalone Crate | Zero alknet crate dependencies | +| [019](../../decisions/019-vault-assembly-layer-only.md) | Vault Assembly-Layer-Only Access | The assembly layer is the sole caller | + +## Relevant Open Questions + +| OQ | Title | Status | Relevance | +|----|-------|--------|-----------| +| OQ-20 | Salt/KDF Phase B | open | Salt field is reserved; v1 does not use it | +| OQ-21 | Remote vault administration | deferred | Network unlock not supported; needs ADR if ever needed | +| OQ-22 | Key rotation mechanism | open | Key versioning is in place; rotation workflow is not specced | + +## Key Design Principles + +1. **Standalone**: The vault depends on no alknet crate. It defines its own + types, errors, and protocol. External crates depend on the vault; the + vault depends on nothing in alknet. +2. **Assembly-layer only**: The vault's API is consumed by the CLI binary, + not by handlers. Handlers receive material through capabilities + (ADR-014). The vault is not on the wire. +3. **Zeroize everything sensitive**: The mnemonic, seed, derived private + keys, encryption keys, and cached keys all implement `Zeroize` and + `ZeroizeOnDrop`. Secret material does not linger in freed heap memory. +4. **Deterministic derivation**: The same mnemonic + passphrase + path + always produces the same key. Derivation is reproducible across runs + and across nodes. +5. **OsRng for nonces**: AES-GCM IVs and any cryptographic nonces use + `OsRng` (or equivalent CSPRNG), never `rand::random()`. IV reuse under + the same key is catastrophic for GCM. +6. **No `unwrap()` or `expect()` outside tests**: vault operations + propagate errors. A poisoned lock is recovered with + `unwrap_or_else(|e| e.into_inner())`, not `unwrap()`. A panic in one + vault operation must not brick the vault for all other operations. + +## Security Constraints + +These are security-critical implementation requirements, not architectural +decisions (the architecture is locked by the ADRs above). They are +documented here so implementation agents don't miss them. See +[service.md → Security Constraints](service.md#security-constraints) for +the full list. + +- **OsRng for IVs**: AES-GCM IVs must use `OsRng`, not `rand::random()`. The + current source uses `rand::random()` — this is a known drift from the + spec and must be corrected during implementation sync. +- **Zeroized drop**: `Seed`, `Mnemonic`, `ExtendedPrivKey`, + `Secp256k1ExtendedPrivKey`, `EncryptionKey`, `CachedKey`, and + `DerivedKey` all derive `Zeroize` and `ZeroizeOnDrop`. The cache must + clear on drop, not just on explicit `lock()`. +- **No `unwrap()` outside tests**: poisoned lock recovery uses + `unwrap_or_else(|e| e.into_inner())` or explicit error propagation. The + current source uses `unwrap()` in `VaultServiceHandle` methods — this + is a known drift and must be corrected. +- **DerivedKey redaction in JSON**: `DerivedKey` serializes the + `private_key` as `"[REDACTED]"` in human-readable formats (JSON) and as + raw bytes in binary formats (postcard). The redaction is a defense-in- + depth measure, not the primary control — the primary control is that + `DerivedKey` never crosses the call protocol wire (ADR-014). + +## Public API + +The vault re-exports its primary types from the crate root: + +```rust +// Mnemonic and seed +pub use mnemonic::{Language, Mnemonic, Seed}; + +// Derivation +pub use derivation::{DerivationError, ExtendedPrivKey, PATHS}; + +// Encryption +pub use encryption::{EncryptedData, EncryptionError}; + +// Protocol (irpc messages) +pub use protocol::{DerivedKey, KeyType, VaultMessage, VaultProtocol}; + +// Service (runtime) +pub use service::{VaultService, VaultServiceActor, VaultServiceError, VaultServiceHandle}; + +// Cache +pub use cache::CacheConfig; +``` + +The `secp256k1` feature flag gates Ethereum (BIP-0032) derivation: + +```rust +#[cfg(feature = "secp256k1")] +pub mod ethereum; +``` \ No newline at end of file diff --git a/docs/architecture/crates/vault/encryption.md b/docs/architecture/crates/vault/encryption.md new file mode 100644 index 0000000..1198db5 --- /dev/null +++ b/docs/architecture/crates/vault/encryption.md @@ -0,0 +1,215 @@ +--- +status: draft +last_updated: 2026-06-19 +--- + +# Encryption + +AES-256-GCM encryption and decryption for external credentials that cannot +be derived from the seed. + +## What + +External credentials (API keys, OAuth tokens, signing keys obtained from +third parties) cannot be derived from the BIP39 seed — they're arbitrary +bytes, not deterministic functions of the seed. The vault encrypts these +with a key *derived from* the seed, producing an `EncryptedData` blob that +can be stored outside the vault (in a config file, a database, or external +storage) and decrypted later with the same seed. + +This is the second axis of the vault's secret model: + +| Axis | Source | Mechanism | Example | +|------|--------|-----------|---------| +| Derived keys | Seed → HD derivation | Deterministic | Node identity, SSH host key | +| Encrypted credentials | External → AES-256-GCM | Seed-derived key | Google API key, OAuth token | + +## Why AES-256-GCM + +AES-256-GCM is an authenticated encryption scheme — it provides both +confidentiality (encryption) and integrity (authentication tag). A +tampered ciphertext fails decryption. This is the correct mode for +credential storage: if an attacker modifies an encrypted API key in +storage, decryption fails rather than producing a different (potentially +dangerous) plaintext. + +GCM is also hardware-accelerated on modern CPUs (AES-NI), making it fast +enough that encryption is never a bottleneck. + +## Encryption Key + +The encryption key is derived from the seed at path `m/74'/2'/0'/0'` +(`PATHS::ENCRYPTION`): + +```rust +pub struct EncryptionKey { + key_bytes: [u8; 32], // 32-byte AES-256 key + key_version: u32, // for rotation tracking +} +``` + +- `new(key_bytes, key_version)`: Construct from raw bytes. +- `from_derived_bytes(bytes, key_version)`: Take the first 32 bytes of + derived key material (the private key bytes from SLIP-0010 derivation). +- `version()`: Return the key version (for rotation). + +`EncryptionKey` implements `Zeroize` and `ZeroizeOnDrop` — the key bytes +are zeroized before deallocation. + +The key is derived once (at unlock time or on first encrypt/decrypt) and +cached in the `KeyCache` (see [service.md](service.md)). Subsequent +encrypt/decrypt operations use the cached key. + +## EncryptedData + +The encrypted blob format. This is the **stable wire format** shared with +`alknet-storage` (a future crate) by type-level agreement, not by a crate +dependency. Both crates must agree on the serialization format. + +A TypeScript `EncryptedDataSchema` from the `@alkdev/storage` library +predates the Rust implementation. The Rust `EncryptedData` is a superset +of the TypeScript schema. The migration path is: re-encrypt +TypeScript-encrypted data using the Rust vault with a new key version. +This cross-language compatibility is why the wire format must stay stable — +changing it breaks both `alknet-storage` and the TypeScript consumer. + +```rust +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EncryptedData { + pub key_version: u32, // rotation tracking + pub salt: String, // base64, 32 bytes — reserved for Phase B (see OQ-20) + pub iv: String, // base64, 12 bytes — AES-GCM nonce + pub data: String, // base64 — ciphertext + auth tag +} +``` + +All binary fields are base64-encoded as strings for JSON serialization +compatibility. The `iv` is 12 bytes (the standard GCM nonce size). The +`data` field includes the GCM authentication tag appended to the ciphertext +(the `aes-gcm` crate handles this). + +### Salt field (reserved for Phase B) + +The `salt` field is **reserved for future KDF-based key derivation** (Phase +B, OQ-20). In v1, the encryption key is derived directly from the seed at +path `m/74'/2'/0'/0'` **without using the salt**. The salt is generated +randomly (32 bytes) and stored in `EncryptedData.salt` for forward +compatibility, but it plays no role in the v1 key derivation process. + +When key rotation is implemented in Phase B, the salt will be used as +input to HKDF or PBKDF2 for stretch-based key derivation, allowing the +same seed to produce different encryption keys without changing the +derivation path. The wire format does not need to change — the `salt` +field is already present and populated. + +This is a deliberate forward-compatibility decision: the field exists in +v1 so that v2 can use it without a format migration. The cost is 32 extra +bytes per `EncryptedData`; the benefit is no future format break. + +## Encrypt and Decrypt + +```rust +pub fn encrypt(plaintext: &str, key: &EncryptionKey) -> Result; +pub fn decrypt(encrypted: &EncryptedData, key: &EncryptionKey) -> Result; +``` + +`encrypt`: +1. Generates a random 12-byte IV (must use `OsRng` — see Security Constraints) +2. Generates a random 32-byte salt (stored, not used in v1) +3. Encrypts the plaintext with AES-256-GCM +4. Returns `EncryptedData { key_version, salt, iv, data }` + +`decrypt`: +1. Decodes the base64 IV and ciphertext +2. Decrypts with AES-256-GCM (verifies the auth tag) +3. Returns the plaintext string + +The IV is generated fresh for each encryption call. **IV reuse under the +same key is catastrophic for GCM** (authenticity breaks, two-time-pad on +plaintext). The use of `OsRng` for IV generation is a security-critical +constraint — see below. + +## Key Versioning + +`CURRENT_KEY_VERSION` is `1`. Key versioning allows re-encryption when the +encryption key is rotated: + +1. Derive a new key from a new derivation path or new seed +2. Decrypt all existing `EncryptedData` with key version 1 +3. Re-encrypt with key version 2 +4. Update storage + +The key version is stored in `EncryptedData.key_version` so decryption can +select the right key. The rotation workflow itself is not specced — see +OQ-22. + +## Errors + +```rust +pub enum EncryptionError { + Encryption(String), // encryption failed + Decryption(String), // decryption failed (wrong key, tampered data, bad UTF-8) + Decoding(String), // base64 decoding failed + KeyVersionMismatch { expected: u32, actual: u32 }, // reserved for Phase B +} +``` + +Decryption failures are intentionally generic — they don't distinguish +"wrong key" from "tampered data" from "corrupted storage" to avoid +leaking information to an attacker. + +`KeyVersionMismatch` is **defined but unused in v1** — neither `encrypt()` +nor `decrypt()` returns it. It is reserved for Phase B key rotation (OQ-22), +where the vault may enforce version matching before decrypting. In v1, the +`key_version` is stamped onto `EncryptedData` and `EncryptionKey` for +forward compatibility but does not gate decryption. An implementer should +not expect this variant to fire in v1. + +## Design Decisions + +| Decision | ADR | Summary | +|----------|-----|---------| +| AES-256-GCM for credential encryption | — | Authenticated encryption, hardware-accelerated | +| Salt reserved for Phase B (OQ-20) | — | Forward-compatible wire format; v1 doesn't use salt | +| Key derived at `m/74'/2'/0'/0'` | — | Dedicated account for encryption keys | +| Key versioning | — | Rotation support without format break | +| All fields base64-encoded | — | JSON serialization compatibility | + +## Open Questions + +See [open-questions.md](../../open-questions.md) for full details. + +- **OQ-20** (open): Salt/KDF Phase B — when and how to use the reserved + `salt` field for KDF-based key derivation. +- **OQ-22** (open): Key rotation mechanism — the key versioning is in place, + but the rotation workflow (re-encrypt all data, update storage) is not + specced. + +## Security Constraints + +These are security-critical implementation requirements. + +- **OsRng for IVs**: The IV must be generated with `OsRng` (or an + equivalent CSPRNG), never `rand::random()`. IV reuse under the same key + is catastrophic for GCM — it breaks authenticity and creates a + two-time-pad on the plaintext. **The current source uses + `rand::random()` for IV generation (`encryption.rs` line 133) — this is a + known drift from the spec and must be corrected during implementation + sync.** `rand::random()` uses the thread-local RNG which may not be a + CSPRNG on all platforms; `OsRng` reads from the operating system's + entropy source and is the correct choice for cryptographic nonces. +- **Zeroized drop**: `EncryptionKey` derives `Zeroize` and + `ZeroizeOnDrop`. The key bytes are zeroized before deallocation. Do not + store key material in types that don't zeroize. +- **No plaintext in logs**: `EncryptedData` is safe to log (it's + ciphertext). The plaintext and the `EncryptionKey` are not. Do not add + `Debug` or `Display` implementations that print key bytes or plaintext. + +## References + +- [NIST SP 800-38D](https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf) — + AES-GCM specification +- Implementation: `crates/alknet-vault/src/encryption.rs` +- Tests: `crates/alknet-vault/tests/test_vectors.rs`, + `crates/alknet-vault/src/encryption.rs` (unit tests) +- [service.md](service.md) — how the vault caches the encryption key \ No newline at end of file diff --git a/docs/architecture/crates/vault/mnemonic-derivation.md b/docs/architecture/crates/vault/mnemonic-derivation.md new file mode 100644 index 0000000..1778416 --- /dev/null +++ b/docs/architecture/crates/vault/mnemonic-derivation.md @@ -0,0 +1,301 @@ +--- +status: draft +last_updated: 2026-06-19 +--- + +# Mnemonic and Key Derivation + +BIP39 mnemonic generation, SLIP-0010 Ed25519 HD key derivation, BIP-0032 +secp256k1 derivation (feature-gated), and the derivation path constants that +alknet uses. + +## What + +The vault derives keys from a single root: a BIP39 mnemonic. From one +mnemonic, all self-generated secrets are derived on demand via +hierarchical deterministic (HD) derivation. This is the same model as +cryptocurrency wallets — one seed phrase, many derived keys. + +Two derivation schemes are supported: + +| Scheme | Curve | Standard | Paths | Feature | +|--------|-------|----------|-------|---------| +| SLIP-0010 | Ed25519 | HMAC-SHA512 with `"ed25519 seed"` | Hardened only | default | +| BIP-0032 | secp256k1 | HMAC-SHA512 with `"Bitcoin seed"` | Hardened + unhardened | `secp256k1` | + +Ed25519 is the default — it's what alknet's TLS identity (ADR-010), SSH +host keys, and signing keys use. secp256k1 is feature-gated for Ethereum +signing (the standard Ethereum path `m/44'/60'/0'/0/0` requires +unhardened indices, which SLIP-0010 cannot handle). + +## Why HD Derivation + +HD derivation lets one seed produce an unlimited number of keys at +deterministic paths. This means: + +- **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. +- **Reproducible across nodes**: the same mnemonic on a different node + produces the same keys. A backup node derives the same identity key. +- **Domain separation**: different paths produce different keys. The + identity key, SSH host key, encryption key, and signing keys are all + cryptographically 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. + +## BIP39 Mnemonic + +The root of trust is a BIP39 mnemonic seed phrase. The vault generates, +validates, and derives seeds from mnemonics. + +```rust +pub struct Mnemonic { + phrase: String, // zeroized on drop +} + +impl Mnemonic { + pub fn generate(word_count: usize) -> Result; + pub fn from_phrase(phrase: &str, language: Language) -> Result; + pub fn to_seed(&self, passphrase: Option<&str>) -> Seed; + pub fn phrase(&self) -> &str; +} +``` + +- `generate(word_count)`: Generate a new random mnemonic. Supported word + counts: 12, 15, 18, 21, 24. The mnemonic is the root of trust — store it + securely. +- `from_phrase(phrase, language)`: Restore from an existing phrase. + Validates against the BIP39 word list and checksum. +- `to_seed(passphrase)`: Derive the 64-byte master seed. The passphrase is + the optional BIP39 password extension (the "25th word"). Different + passphrases produce different seeds. +- `phrase()`: Return the phrase string. Handle with care — this is the + root of trust. + +`Mnemonic` implements `Zeroize` and `Drop` — the phrase is zeroized +before deallocation. Only English is supported (matching the BIP39 +reference and the majority of wallet software). + +### Seed + +```rust +#[derive(Clone, Zeroize)] +#[zeroize(drop)] +pub struct Seed { + bytes: Vec, // 64 bytes, zeroized on drop +} +``` + +The 64-byte seed from which all HD keys are derived. Zeroized on drop. +This is the input to SLIP-0010 / BIP-0032 master key derivation. + +## SLIP-0010 Ed25519 Derivation + +The default derivation scheme. SLIP-0010 specifies Ed25519 HD key +derivation using HMAC-SHA512 with the key `"ed25519 seed"`. + +```rust +pub fn derive_path_from_seed(seed: &[u8], path: &str) -> Result; +``` + +### Master key derivation + +The master key is derived from the seed via HMAC-SHA512: + +``` +HMAC-SHA512(key = "ed25519 seed", data = seed) + → first 32 bytes: private key (kL) + → next 32 bytes: chain code +``` + +The `ed25519-bip32` crate handles the extended key format (kL || kR || +chain code). The vault extracts the first 32 bytes as the private key and +the public key (32 bytes) via `XPrv::public()`. + +### Child derivation + +SLIP-0010 Ed25519 supports **hardened child derivation only**. Every child +index must have the `'` (or `h`) suffix, meaning `index + 0x80000000`. +Unhardened indices are rejected by the derivation logic (Ed25519 cannot +support them because public key derivation is not possible without the +private key). + +### Path parsing + +```rust +pub fn parse_derivation_path(path: &str) -> Result, DerivationError>; +``` + +Parses paths like `m/74'/0'/0'/0'` into child indices. The `m` prefix is +required. Hardened indices have `'` or `h` suffix; unhardened indices are +allowed in the parser (for BIP-0032 paths) but Ed25519 derivation will +fail on them. + +### ExtendedPrivKey + +```rust +#[derive(Clone, Zeroize)] +#[zeroize(drop)] +pub struct ExtendedPrivKey { + private_key: Vec, // 32 bytes + public_key: Vec, // 32 bytes + chain_code: Vec, // 32 bytes + path: String, // the path that produced this key +} +``` + +The result of SLIP-0010 derivation. Zeroized on drop. Accessors return +slices — the caller copies what it needs. + +## BIP-0032 secp256k1 Derivation (Ethereum) + +Feature-gated behind `secp256k1`. Implements BIP-0032 HD key derivation for +the secp256k1 curve, used for Ethereum signing keys. + +```rust +#[cfg(feature = "secp256k1")] +pub fn derive_secp256k1_path(seed: &[u8], path: &str) -> Result; +``` + +Unlike SLIP-0010 (Ed25519), BIP-0032 supports both hardened and +unhardened child derivation. The standard Ethereum path +`m/44'/60'/0'/0/0` uses unhardened indices for the last two levels. + +### Why a separate module + +SLIP-0010 and BIP-0032 differ in: + +| Aspect | SLIP-0010 (Ed25519) | BIP-0032 (secp256k1) | +|--------|---------------------|----------------------| +| HMAC key | `"ed25519 seed"` | `"Bitcoin seed"` | +| Child derivation | Hardened only | Hardened + unhardened | +| Public key size | 32 bytes | 33 bytes (compressed) | +| Public derivation | Not possible | Possible (unhardened) | + +The `secp256k1` crate is a heavy dependency (it includes a C library for +curve operations). Feature-gating it keeps the default vault lightweight — +nodes that don't need Ethereum signing don't pay the cost. + +When the feature is disabled, `derive_ethereum_key` returns +`VaultServiceError::UnsupportedKeyType`. + +## Derivation Paths + +alknet reserves the `74'` coin type (unallocated per SLIP-0044) for its +keys. Well-known paths are constants in the `PATHS` module: + +```rust +pub mod PATHS { + pub const IDENTITY: &str = "m/74'/0'/0'/0'"; // Primary identity keypair + pub const DEVICE_PREFIX: &str = "m/74'/0'/0'"; // Worker/device identity prefix + pub const SSH_HOST: &str = "m/74'/0'/1'/0'"; // SSH host key + pub const ENCRYPTION: &str = "m/74'/2'/0'/0'"; // AES-256-GCM encryption key + pub const ETHEREUM: &str = "m/44'/60'/0'/0/0"; // Ethereum signing key (secp256k1) +} +``` + +Helper functions construct parameterized paths: + +```rust +pub fn device_path(index: u32) -> String; // m/74'/0'/0'/{index}' +pub fn site_password_path(site_hash: &str) -> String; // m/74'/1'/0'/{site_hash}' +``` + +### Path semantics + +| Path | Purpose | Key type | Used by | +|------|---------|----------|---------| +| `m/74'/0'/0'/0'` | Primary node identity (Ed25519) | Ed25519 | TLS raw key (ADR-010), node identity | +| `m/74'/0'/0'/{n}'` | Worker/device identity | Ed25519 | Multi-device nodes, workers | +| `m/74'/0'/1'/0'` | SSH host key | Ed25519 | SSH handler | +| `m/74'/1'/0'/{hash}'` | Site-specific deterministic password | Ed25519 bytes | Per-site passwords (not cached) | +| `m/74'/2'/0'/0'` | Encryption key for external credentials | AES-256-GCM | Credential encryption (see [encryption.md](encryption.md)) | +| `m/44'/60'/0'/0/0` | Ethereum signing key | secp256k1 | Ethereum signing (feature-gated) | + +### Path namespace discipline + +The `74'` coin type is alknet's namespace. Sub-paths follow a convention: + +| Account (`/X'`) | Purpose | +|-----------------|---------| +| `0'` | Identity keys (node, devices, SSH) | +| `1'` | Deterministic passwords | +| `2'` | Encryption keys (external credentials) | + +New key purposes should allocate a new account index, not reuse an +existing one. This prevents cross-purpose key collisions. + +### The `74'` coin type is a one-way door + +The `74'` coin type is **committed** — once keys are derived at `m/74'/...`, +changing the coin type breaks every existing key. Every node's identity, +SSH host key, and encryption key is derived at a `74'`-rooted path. This is +effectively a one-way door per ADR-009: reversal requires re-deriving every +key from the seed at a new coin type and re-deploying all nodes. The +reservation is documented inline rather than in a separate ADR because it +is a single, self-evident commitment (the coin type is the alknet +namespace; there is no alternative to evaluate). The SLIP-0044 registry +lists `74'` as unallocated, so there is no collision risk with other +projects. + +## Key Types + +```rust +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum KeyType { + Ed25519, // SLIP-0010 derivation + Aes256Gcm, // Symmetric key (derived from seed, used for encryption) + Secp256k1, // BIP-0032 derivation (Ethereum, feature-gated) +} +``` + +`KeyType` tags `DerivedKey` (see [protocol.md](protocol.md)) and +`CachedKey` (see [service.md](service.md)) so consumers know what they +received without inspecting byte lengths. + +## Determinism + +Derivation is deterministic: the same mnemonic + passphrase + path +always produces the same key. This is verified by regression tests in +`tests/test_vectors.rs` against the BIP39 "abandon...about" test vector. + +### Passphrase sensitivity + +Different passphrases produce different seeds and therefore different +keys. The passphrase is a legitimate access-control mechanism: two +operators with the same mnemonic but different passphrases get different +keysets. The vault does not enforce a passphrase policy — that's an +assembly-layer concern. + +## Design Decisions + +| Decision | ADR | Summary | +|----------|-----|---------| +| 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 | +| `74'` coin type reserved for alknet | — | SLIP-0044 unallocated; alknet namespace | +| secp256k1 feature-gated | — | Heavy dep; only needed for Ethereum | +| Hardened-only for Ed25519 | SLIP-0010 | Ed25519 cannot do public derivation | + +## Open Questions + +See [open-questions.md](../../open-questions.md) for full details. + +- **OQ-20** (open): Salt/KDF Phase B — the `EncryptedData.salt` field is + reserved; v1 does not use it. See [encryption.md](encryption.md). + +## References + +- [BIP39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) — + mnemonic seed phrases +- [SLIP-0010](https://github.com/satoshilabs/slips/blob/master/slip-0010.md) — + Ed25519 HD derivation +- [BIP-0032](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) — + secp256k1 HD derivation +- [SLIP-0044](https://github.com/satoshilabs/slips/blob/master/slip-0044.md) — + registered coin types (74' is unallocated) +- Implementation: `crates/alknet-vault/src/mnemonic.rs`, + `crates/alknet-vault/src/derivation.rs`, `crates/alknet-vault/src/ethereum.rs` +- Test vectors: `crates/alknet-vault/tests/test_vectors.rs` \ No newline at end of file diff --git a/docs/architecture/crates/vault/protocol.md b/docs/architecture/crates/vault/protocol.md new file mode 100644 index 0000000..3abc7da --- /dev/null +++ b/docs/architecture/crates/vault/protocol.md @@ -0,0 +1,177 @@ +--- +status: draft +last_updated: 2026-06-19 +--- + +# Protocol + +The `VaultProtocol` irpc message enum, `DerivedKey` type, and serialization +behavior. + +## What + +The protocol layer defines the message enum that the irpc dispatch +infrastructure uses (ADR-005) and the `DerivedKey` type that derivation +methods return. This is the vault's internal dispatch protocol — not the +alknet call protocol (the vault has no ALPN, ADR-008). + +## VaultProtocol + +The irpc message enum. The `#[rpc_requests]` macro generates the +`VaultMessage` enum (with `WithChannels` wrappers), `Channels` impls, +`From` impls, and `Service`/`RemoteService` traits for remote dispatch. + +```rust +#[rpc_requests(message = VaultMessage, no_spans)] +#[derive(Debug, Serialize, Deserialize)] +pub enum VaultProtocol { + DeriveEd25519 { path: String }, + DeriveEncryptionKey { path: String }, + DeriveEthereumKey { path: String }, + DerivePassword { path: String, length: usize }, + Encrypt { plaintext: String, key_version: u32 }, + Decrypt { encrypted: EncryptedData }, + Lock, + Unlock { mnemonic: String, passphrase: Option }, +} +``` + +Each variant is a vault operation. The `tx` channel type for each variant +is `oneshot::Sender>`, where `T` is the +operation's return type (`DerivedKey`, `Vec`, `EncryptedData`, `String`, +or `()`). + +### State requirements + +All operations except `Unlock` require the vault to be **unlocked**. +Calling derive/encrypt/decrypt on a locked vault returns +`VaultServiceError::VaultLocked` (not a panic, not a channel close). + +### Dispatch + +The `VaultServiceActor` (see [service.md](service.md)) processes +`VaultMessage` variants and dispatches to `VaultServiceHandle` methods. +For local in-process use, prefer `VaultServiceHandle` directly — no +channel overhead. + +## DerivedKey + +The result of key derivation. Holds the key type, private key, and public +key. + +```rust +#[derive(Zeroize, Deserialize)] +#[zeroize(drop)] +pub struct DerivedKey { + #[zeroize(skip)] + pub key_type: KeyType, // not secret — tag only + #[zeroize] + pub private_key: Vec, // zeroized on drop + #[zeroize(skip)] + pub public_key: Vec, // not secret — public by definition +} +``` + +The `#[zeroize(skip)]` attributes on `key_type` and `public_key` mean only +the `private_key` is zeroized when the `DerivedKey` is dropped. The public +key and key type are not secret material — zeroizing them is unnecessary +and would require them to derive `Zeroize` (which `KeyType` does not). + +### Move-only, not Clone + +`DerivedKey` does **not** derive `Clone`. It is move-only. Consumers +receive it by value and zeroize it when done (handled automatically by +`#[zeroize(drop)]`). This prevents accidental duplication of secret +material — there is exactly one copy of the private key, and it is +zeroized when the `DerivedKey` is dropped. + +The assembly layer (CLI binary) extracts the bytes it needs (private key +for signing, public key for TLS identity) and constructs the alknet-core +types at the assembly boundary (ADR-018). The `DerivedKey` is then dropped +and zeroized. + +### Serialization redaction + +`DerivedKey` has a custom `Serialize` impl that redacts the private key in +human-readable formats: + +- **JSON** (human-readable): `private_key` serializes as `"[REDACTED]"`. + This is defense-in-depth — if a `DerivedKey` accidentally ends up in a + log or a JSON config, the private key is not exposed. +- **postcard** (binary, used by irpc): `private_key` serializes as the + actual bytes. This is required for in-cluster irpc dispatch to work — + the remote side needs the actual key bytes. +- **Deserialization**: always reads the full bytes, regardless of format. + A JSON-deserialized `DerivedKey` will have `"[REDACTED]"` as its + `private_key` string — this is expected; JSON round-tripping a + `DerivedKey` is not a supported use case (the private key is gone). + +The redaction is **not the primary control** for keeping private keys off +the wire. The primary control is architectural: `DerivedKey` never appears +in call protocol payloads (ADR-014). The redaction is a safety net for +logging accidents and debug output. + +### Debug redaction + +`DerivedKey`'s `Debug` impl also redacts the private key: + +```rust +impl fmt::Debug for DerivedKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DerivedKey") + .field("key_type", &self.key_type) + .field("private_key", &"[REDACTED]") + .field("public_key", &self.public_key) + .finish() + } +} +``` + +`{:?}` on a `DerivedKey` never exposes the private key. This makes it safe +to use in `tracing` spans and error messages. + +## KeyType + +```rust +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum KeyType { + Ed25519, // SLIP-0010 derivation (32-byte private + 32-byte public) + Aes256Gcm, // Symmetric key (32 bytes, used for encryption) + Secp256k1, // BIP-0032 derivation (32-byte private + 33-byte compressed public) +} +``` + +Tags `DerivedKey` and `CachedKey` so consumers know what they received. +`KeyType` is `Serialize`/`Deserialize` (it's part of the irpc protocol) and +`Clone` (it's not secret material — it's a tag). + +## Wire Format + +For local (in-process) calls, the protocol uses tokio channels directly — +no serialization. For remote (in-cluster) calls, the protocol is serialized +with postcard (binary, compact). For cross-node (call protocol) exposure, +the vault is wrapped in an operation that serializes to JSON — but **no +vault operations are exposed over the call protocol** (ADR-014). The JSON +serialization path exists only for the `DerivedKey` redaction safety net. + +## Design Decisions + +| Decision | ADR | Summary | +|----------|-----|---------| +| irpc for vault dispatch | [ADR-005](../../decisions/005-irpc-as-call-protocol-foundation.md) | In-process type-safe dispatch | +| `DerivedKey` is move-only | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Prevents accidental duplication of secret material | +| JSON redacts private key | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Defense-in-depth for logging accidents | +| postcard preserves private key | — | Required for in-cluster irpc dispatch | +| No vault operations on call protocol | [ADR-008](../../decisions/008-secret-service-integration.md), [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Master seed never crosses the network | + +## Open Questions + +None active for this document. + +## References + +- Implementation: `crates/alknet-vault/src/protocol.rs` +- Tests: `crates/alknet-vault/src/protocol.rs` (unit tests for redaction + and zeroize behavior) +- [service.md](service.md) — how the actor dispatches `VaultMessage` +- [mnemonic-derivation.md](mnemonic-derivation.md) — what `KeyType` means \ No newline at end of file diff --git a/docs/architecture/crates/vault/service.md b/docs/architecture/crates/vault/service.md new file mode 100644 index 0000000..23de4de --- /dev/null +++ b/docs/architecture/crates/vault/service.md @@ -0,0 +1,361 @@ +--- +status: draft +last_updated: 2026-06-19 +--- + +# Service + +The `VaultServiceHandle` runtime API: unlock/lock lifecycle, key +derivation, encryption, caching, and the actor dispatch path. + +## What + +The service layer wraps the vault's cryptographic primitives in a +stateful runtime with a clear lifecycle. It holds the master seed in +`Zeroize`-protected memory and provides methods for the unlock/lock +lifecycle, key derivation, and encryption/decryption. + +This is the API the assembly layer (CLI binary) calls. No other component +calls these methods directly (ADR-019). + +## VaultServiceHandle + +The primary API for local (in-process) use. Thread-safe via +`Arc>`. + +```rust +#[derive(Clone)] +pub struct VaultServiceHandle { + inner: Arc>, +} + +struct VaultServiceInner { + mnemonic: Option, // None if locked + seed: Option, // None if locked + unlocked: bool, + cache: KeyCache, // TTL + LRU, see Cache section +} +``` + +`VaultServiceHandle` is `Clone` — cloning shares the underlying state via +`Arc`. This is how the actor and the assembly layer share the same vault. + +## Lifecycle + +``` +Locked (initial state) + │ + │ unlock(phrase, passphrase) / unlock_new(word_count) + ▼ +Unlocked — derive, encrypt, decrypt available + │ + │ lock() + ▼ +Locked — seed and cache purged +``` + +### unlock(phrase, passphrase) + +```rust +pub fn unlock(&self, phrase: &str, passphrase: Option<&str>) -> Result<(), VaultServiceError>; +``` + +Unlock with an existing mnemonic phrase. Validates the phrase against the +BIP39 word list, derives the seed, and stores both in `VaultServiceInner`. +Returns `AlreadyUnlocked` if the vault is already unlocked. + +The passphrase is the BIP39 password extension (the "25th word"). `None` +means no passphrase (equivalent to empty string). Different passphrases +produce different seeds. + +### unlock_new(word_count) → phrase + +```rust +pub fn unlock_new(&self, word_count: usize) -> Result; +``` + +Generate a new random mnemonic, unlock with it, and return the phrase. +Store the returned phrase securely — it is the root of trust. Supported +word counts: 12, 15, 18, 21, 24. + +This is the "first run" path — a new node generates its mnemonic, writes +it down, and the vault is unlocked for the process lifetime. + +### lock() + +```rust +pub fn lock(&self); +``` + +Purge the seed, mnemonic, and all cached derived keys. Calls `zeroize()` +on all sensitive material. After locking, no derive/encrypt/decrypt +operations are possible until `unlock` is called again. + +`lock()` on an already-locked service is a no-op (not an error). + +### is_unlocked() + +```rust +pub fn is_unlocked(&self) -> bool; +``` + +Check whether the vault is currently unlocked. Cheap (read lock only). + +## Derive Methods + +All derive methods require an unlocked vault and return +`VaultServiceError::VaultLocked` if called while locked. + +### derive_ed25519(path) → DerivedKey + +```rust +pub fn derive_ed25519(&self, path: &str) -> Result; +``` + +Derive an Ed25519 keypair at the given SLIP-0010 path. Checks the cache +first; on a miss, derives from the seed and caches the result. Returns a +`DerivedKey` with `KeyType::Ed25519`. + +### derive_encryption_key(path) → DerivedKey + +```rust +pub fn derive_encryption_key(&self, path: &str) -> Result; +``` + +Derive an AES-256-GCM encryption key at the given path. Same cache +behavior as `derive_ed25519`. Returns a `DerivedKey` with +`KeyType::Aes256Gcm`. + +### derive_ethereum_key(path) → DerivedKey (feature-gated) + +```rust +pub fn derive_ethereum_key(&self, path: &str) -> Result; +``` + +Derive a secp256k1 keypair at the given BIP-0032 path. Returns +`UnsupportedKeyType` when the `secp256k1` feature is disabled. Returns a +`DerivedKey` with `KeyType::Secp256k1` (33-byte compressed public key). + +### derive_password(path, length) → Vec + +```rust +pub fn derive_password(&self, path: &str, length: usize) -> Result, VaultServiceError>; +pub fn derive_password_string(&self, path: &str, length: usize) -> Result; +``` + +Derive deterministic password bytes at the given path, truncated to +`length`. This is **not cached** — password derivation is cheap and +passwords are typically one-shot (derive, use, discard). The string +variant base64url-encodes the bytes (URL-safe, no padding). + +`derive_password` is the mechanism for per-site deterministic passwords: +the same seed + path always produces the same password. The path includes +a site hash (`site_password_path(site_hash)`) so different sites get +different passwords. + +## Encrypt and Decrypt + +### encrypt(plaintext, key_version) → EncryptedData + +```rust +pub fn encrypt(&self, plaintext: &str, key_version: u32) -> Result; +``` + +Encrypt plaintext using the encryption key derived at `PATHS::ENCRYPTION`. +Derives (and caches) the encryption key on first call, then uses the cache +for subsequent calls. See [encryption.md](encryption.md) for the +cryptographic details. + +### decrypt(encrypted) → String + +```rust +pub fn decrypt(&self, encrypted: &EncryptedData) -> Result; +``` + +Decrypt an `EncryptedData` blob. Derives (and caches) the encryption key at +`PATHS::ENCRYPTION` if not already cached. The `encrypted.key_version` is +stamped onto the `EncryptionKey` for forward compatibility but **does not +select a different derivation path in v1** — the same key (at +`m/74'/2'/0'/0'`) decrypts any version. Path-per-version routing is a Phase +B concern (OQ-22). See [encryption.md](encryption.md). + +## Cache + +Derived keys are cached for performance — HD derivation involves HMAC +operations that are not free. The cache is keyed by derivation path and +has TTL-based expiry and LRU eviction. + +```rust +pub struct KeyCache { + entries: HashMap, + order: Vec, // LRU ordering + config: CacheConfig, +} + +pub struct CacheConfig { + pub ttl: Duration, // default: 1 hour + pub max_entries: usize, // default: 64 +} +``` + +- **TTL**: entries expire after `ttl` (default 1 hour). Expired entries are + evicted lazily on access (`get` checks expiry) or via `evict_expired()`. +- **LRU**: when the cache exceeds `max_entries` (default 64), the least + recently used entry is evicted. Access (`get`) updates the LRU order. +- **Zeroized**: `CachedKey` derives `Zeroize` and `ZeroizeOnDrop`. Evicted + and cleared entries are zeroized — derived private keys do not linger in + freed heap memory. +- **Cleared on lock**: `lock()` calls `cache.clear()`, which removes and + zeroizes all entries. + +### What is and isn't cached + +| Operation | Cached? | Why | +|-----------|---------|-----| +| `derive_ed25519` | Yes | Derivation is expensive; keys are reused | +| `derive_encryption_key` | Yes | Same — encryption key reused across calls | +| `derive_ethereum_key` | Yes | Same | +| `derive_password` | No | Cheap derivation; passwords are one-shot | +| `encrypt` / `decrypt` | Key cached | The encryption key (at `PATHS::ENCRYPTION`) is cached; the plaintext is not | + +`derive_password` does not cache because it's a truncation of derived +bytes, not a keypair that's reused. Caching it would grow the cache with +unique paths (one per site hash) for no reuse benefit. + +## Actor Dispatch + +The `VaultServiceActor` processes `VaultMessage` variants from an mpsc +channel and dispatches to `VaultServiceHandle` methods. This is the irpc +dispatch mechanism (ADR-005) — the in-process actor pattern that irpc +services use. + +```rust +pub struct VaultServiceActor { + handle: VaultServiceHandle, +} + +impl VaultServiceActor { + pub fn new(handle: VaultServiceHandle) -> Self; + pub async fn run(mut self, mut rx: mpsc::Receiver); + pub fn spawn(handle: VaultServiceHandle) -> (Client, VaultServiceActor); +} +``` + +- `run(rx)`: Message loop. Each `VaultMessage` variant is dispatched to the + corresponding handle method, and the response is sent through the oneshot + channel embedded in the message. Consumes `self`. +- `spawn(handle)`: Spawn the actor as a `tokio::task` and return a + `Client` for sending messages. **Source bug: the current + `spawn` implementation returns a fresh, unspawned `VaultServiceActor` as + the second tuple element (the spawned actor is consumed by `run`). The + returned actor has no channel and is non-functional. This should be + corrected during implementation sync — either drop the second return + value (return only `Client`) or restructure the API so + the returned actor is the one that was spawned.** + +The actor pattern is the irpc dispatch mechanism (ADR-005). For local +in-process use, prefer `VaultServiceHandle` directly — no channel, no +serialization. The actor exists for irpc service dispatch, which is an +in-process pattern (the actor and the handle share state via `Arc`). + +### Dispatch paths + +| Path | Type | Serialization | Use case | +|------|------|---------------|----------| +| Direct (in-process) | `VaultServiceHandle` method calls | None | CLI binary at startup (the supported path) | +| Actor (in-process) | `VaultMessage` over mpsc | None (channel) | irpc service dispatch (in-process) | + +Remote (in-cluster) vault dispatch — where the vault runs as a sidecar +and other processes send `VaultMessage` over a network — is **not +supported** (ADR-019, OQ-21). The irpc `RemoteService` trait infrastructure +exists in the library, but exposing the vault over the network would +require its own ADR with an explicit threat model (the master seed must +never cross the network). The dispatch table above lists only the +supported paths. + +The assembly layer (CLI binary) uses the direct path. The actor path +exists for in-process irpc dispatch but is not used by the assembly layer +— it's available for test harnesses and future in-process service +patterns. Neither path is on the alknet call protocol (ADR-008, ADR-014). + +## Errors + +```rust +#[derive(Debug, thiserror::Error, Serialize, Deserialize)] +pub enum VaultServiceError { + VaultLocked, // called derive/encrypt/decrypt while locked + AlreadyUnlocked, // called unlock while already unlocked + Mnemonic(String), // mnemonic generation/validation failed + Derivation(String), // HD derivation failed (bad path, HMAC error) + Encryption(String), // AES-GCM encrypt/decrypt failed + InvalidPath(String), // derivation path is malformed + UnsupportedKeyType, // secp256k1 called without the feature +} +``` + +`VaultServiceError` is `Serialize`/`Deserialize` (for irpc dispatch) and +wraps sub-errors as strings. It does not implement `From` for alknet-core +error types — the CLI binary converts at the assembly boundary (ADR-018). + +## Design Decisions + +| Decision | ADR | Summary | +|----------|-----|---------| +| Assembly layer is the sole caller | [ADR-019](../../decisions/019-vault-assembly-layer-only.md) | Handlers never hold a vault reference | +| RwLock for thread safety | — | Multiple readers (derive), exclusive writer (unlock/lock) | +| TTL + LRU cache | — | Bounded memory, fresh keys, zeroized eviction | +| Actor for in-cluster dispatch | [ADR-005](../../decisions/005-irpc-as-call-protocol-foundation.md) | irpc message dispatch; not on the call protocol | +| `derive_password` not cached | — | One-shot; caching grows cache with no reuse | + +## Open Questions + +See [open-questions.md](../../open-questions.md) for full details. + +- **OQ-21** (deferred): Remote vault administration — network unlock is not + supported; needs an ADR if ever needed. + +## Security Constraints + +These are security-critical implementation requirements, not +architectural decisions. They are documented here so implementation agents +don't miss them. + +- **OsRng for IVs**: AES-GCM IVs and any cryptographic nonces must use + `OsRng` (or equivalent CSPRNG), not `rand::random()`. IV reuse under the + same key is catastrophic for GCM (authenticity breaks, two-time-pad on + plaintext). **The current source uses `rand::random()` for IV generation + in `encryption::encrypt()` — this is a known drift and must be corrected + during implementation sync.** +- **Zeroized drop**: `Seed`, `Mnemonic`, `CachedKey`, `EncryptionKey`, + `ExtendedPrivKey`, `Secp256k1ExtendedPrivKey`, and `DerivedKey` all + derive `Zeroize` and `ZeroizeOnDrop`. The cache must clear on drop, not + just on explicit `lock()`. **The current `KeyCache::clear()` removes + entries but relies on `CachedKey`'s `Drop` impl for zeroization — + verify that `HashMap::clear()` actually drops the values (it does, but + this is worth a test).** +- **No `unwrap()` or `expect()` outside tests**: poisoned lock recovery + uses `unwrap_or_else(|e| e.into_inner())` or explicit error propagation. + A panic in one vault operation must not brick the vault for all other + operations. **The current source uses `unwrap()` on every `RwLock` + acquisition in `VaultServiceHandle` (lines 142, 161, 182, 191, 196, 227, + 264, 307, 340, 367) — this is a known drift and must be corrected. A + poisoned lock should be recovered with `unwrap_or_else(|e| + e.into_inner())`, not panicked.** +- **`DerivedKey` is move-only, not `Clone`**: `DerivedKey` does not derive + `Clone`. It is move-only — consumers receive it by value and zeroize it + when done (handled by `#[zeroize(drop)]`). This prevents accidental + duplication of secret material. **The current source does not derive + `Clone` on `DerivedKey` — this is correct.** +- **Cache eviction zeroizes**: when the cache evicts an entry (LRU or + TTL), the `CachedKey` is dropped, which triggers `ZeroizeOnDrop`. Do not + replace `CachedKey` with a type that doesn't zeroize. + +## References + +- Implementation: `crates/alknet-vault/src/service.rs`, + `crates/alknet-vault/src/cache.rs` +- Tests: `crates/alknet-vault/tests/service_tests.rs`, + `crates/alknet-vault/src/service.rs` (unit tests), + `crates/alknet-vault/src/cache.rs` (unit tests) +- [protocol.md](protocol.md) — `VaultMessage` and `DerivedKey` +- [encryption.md](encryption.md) — `encrypt` / `decrypt` cryptographic details \ No newline at end of file diff --git a/docs/architecture/decisions/018-vault-standalone-crate.md b/docs/architecture/decisions/018-vault-standalone-crate.md new file mode 100644 index 0000000..a7cd8cf --- /dev/null +++ b/docs/architecture/decisions/018-vault-standalone-crate.md @@ -0,0 +1,162 @@ +# ADR-018: Vault as Standalone Crate + +## Status + +Accepted + +## Context + +alknet-vault provides BIP39 mnemonic generation, SLIP-0010 Ed25519 HD key +derivation, BIP-0032 secp256k1 derivation (feature-gated), and AES-256-GCM +encryption. It holds the master seed — the root of trust for all derived keys +and encrypted credentials in the alknet system. + +The question is: what does alknet-vault depend on? The candidates: + +1. **Depend on alknet-core** for shared types (errors, maybe Identity). This + pulls QUIC, quinn, iroh, rustls, and tokio runtime dependencies into the + vault's dependency tree. +2. **Stand alone** — zero alknet crate dependencies. The vault defines its own + types, its own error enum, its own irpc protocol. Other crates depend on + the vault; the vault depends on nothing in alknet. + +This is a one-way door. Once the vault depends on alknet-core, reversing it +requires removing that dependency from every type, error conversion, and +test — and the longer it stays, the more entangled it becomes. + +### Why standalone matters + +The vault is used in contexts where QUIC networking does not exist: + +- **CLI tools**: a key-derivation utility that derives an identity key from a + mnemonic without starting a network endpoint. +- **Test harnesses**: integration tests in other crates derive test keys + without spinning up a QUIC endpoint. +- **WASM key derivation**: a future WASM target that derives keys in a browser + (the BiStream trait in ADR-007 preserves this door at the transport layer; + the vault's independence preserves it at the secret layer). +- **Embedded assembly**: a binary that only needs the vault to decrypt a + config file at startup, with no networking at all. + +If the vault depends on alknet-core, all of these contexts pull in quinn, +iroh, rustls, and tokio — none of which they need. The vault's job is +cryptographic derivation and encryption. It has no networking concern. + +### What the vault provides without alknet-core + +The vault defines its own types and traits: + +- `Mnemonic`, `Seed` — BIP39 root material +- `ExtendedPrivKey` (Ed25519), `Secp256k1ExtendedPrivKey` (Ethereum) — + derived key material +- `DerivedKey`, `KeyType` — protocol-level key representation +- `EncryptedData`, `EncryptionKey` — AES-256-GCM blobs +- `VaultServiceHandle`, `VaultServiceActor` — runtime API +- `VaultProtocol` — irpc message enum (in-process dispatch) +- `VaultServiceError` — its own error enum (string-wrapped sub-errors; the + vault doesn't share an error type with alknet-core) + +The `VaultProtocol` uses irpc directly (see ADR-005), not through alknet-call. +This is consistent: irpc is a lightweight framing library, not an alknet +crate. The vault's irpc usage is an in-process dispatch mechanism, not a +network-exposed service. + +## Decision + +**alknet-vault has zero alknet crate dependencies.** It depends only on +external crates (`bip39`, `ed25519-bip32`, `aes-gcm`, `sha2`, `hmac`, +`secp256k1`, `irpc`, `tokio` for the actor's sync primitives, `serde`, +`zeroize`, `thiserror`, `base64`, `rand`). + +The vault does not depend on: +- `alknet-core` — no shared types, no `Identity`, no `AuthContext` +- `alknet-call` — no `OperationSpec`, no `OperationContext`, no call protocol +- `alknet-vault` does not implement `ProtocolHandler` — it has no ALPN (see + ADR-019) + +Dependency flow is strictly one-directional: + +``` +alknet-vault (standalone) + ↑ +alknet (CLI binary) — the only crate that depends on alknet-vault +``` + +No handler crate depends on alknet-vault directly. Handlers receive derived +material through capabilities injected by the assembly layer (ADR-014). The +CLI binary is the sole integration point (ADR-008, ADR-019). + +### Type independence + +The vault defines its own types and does not share types with alknet-core: + +- `VaultServiceError` is the vault's error enum. It is `Serialize`/`Deserialize` + (for irpc dispatch) and wraps sub-errors as strings. It does not implement + `From` for alknet-core error types — the CLI binary converts at the + assembly boundary. +- `DerivedKey` is the vault's key representation. It is not shared with + alknet-core's `Identity` type. The CLI binary extracts the bytes it needs + (private key for signing, public key for TLS identity) and constructs the + alknet-core types at the assembly layer. +- `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 + dependency — both crates must agree on the serialization format (see + [encryption.md](../crates/vault/encryption.md)). + +## Consequences + +**Positive:** +- The vault compiles and runs without QUIC, quinn, iroh, rustls, or a tokio + runtime (the `VaultServiceHandle` works with just `std::sync::RwLock`; the + actor uses `tokio::sync::mpsc` but that's a lightweight dependency). +- CLI tools, test harnesses, and future WASM targets can use the vault for key + derivation without pulling in networking crates. +- The vault's API surface is stable — changes to alknet-core types don't + force a vault recompile, and changes to vault types don't force a + handler recompile (the CLI is the only consumer). +- No circular dependency risk. The dependency graph is a strict DAG. +- The vault can be published and used independently of alknet — it's a + general-purpose local key vault, not an alknet-specific component. + +**Negative:** +- The vault cannot share types with alknet-core. If a type wants to be shared + (e.g., a future `Fingerprint` type), it must live in alknet-core and the + vault must define its own equivalent, or a new shared crate must be + created. This is a feature, not a bug — it forces explicit boundaries. +- The CLI binary must convert between vault types and alknet-core types at + the assembly boundary. This is a small amount of glue code (extract bytes + from `DerivedKey`, construct alknet-core types). See ADR-019. +- The vault's `VaultServiceError` is separate from alknet-core's + `HandlerError`. The CLI binary maps vault errors to handler errors or + startup failures. This is expected — the vault is a library, not a + handler. + +## Assumptions + +1. **The vault's API is consumed by one component (the CLI binary) in the + alknet system.** If a future use case requires multiple crates to depend + on the vault directly, the dependency flow still holds — they depend on + the vault, the vault depends on nothing. The standalone property is + preserved. + +2. **Shared types between the vault and other crates are agreed by type-level + compatibility, not by a crate dependency.** `EncryptedData` is the example: + both the vault and `alknet-storage` (future) must agree on the + serialization format. This is documented in the type's spec, not enforced + by the type system across crates. + +3. **The vault's error type does not need to integrate with alknet-core's + error handling.** The vault returns `VaultServiceError`; the CLI binary + handles it at the assembly boundary. If a future use case requires + propagating vault errors through alknet-core's error types, the CLI + converts at the boundary. + +## References + +- ADR-003: Crate decomposition (alknet-vault is standalone) +- ADR-005: irpc as call protocol foundation (vault uses irpc directly) +- ADR-008: Vault integration point (CLI-embedded, assembly-layer only) +- ADR-014: Secret material flow and capability injection +- ADR-019: Vault assembly-layer-only access +- [crates/vault/README.md](../crates/vault/README.md) +- Implementation: `crates/alknet-vault/` \ No newline at end of file diff --git a/docs/architecture/decisions/019-vault-assembly-layer-only.md b/docs/architecture/decisions/019-vault-assembly-layer-only.md new file mode 100644 index 0000000..c833924 --- /dev/null +++ b/docs/architecture/decisions/019-vault-assembly-layer-only.md @@ -0,0 +1,165 @@ +# ADR-019: Vault Assembly-Layer-Only Access + +## Status + +Accepted + +## Context + +ADR-008 established that the vault is a **capability source** — the CLI +binary unlocks it at startup, derives and decrypts the credentials each +handler needs, and injects the results into handler capabilities. ADR-014 +specified the injection mechanism (`Capabilities` on `OperationContext`) and +locked the constraint that no vault operations are registered in the call +protocol. + +These ADRs answer *how the vault integrates with the rest of alknet*. This +ADR answers a narrower question that the vault's own spec needs to be +explicit about: **what is the vault's access model from its own +perspective?** + +The vault provides a `VaultServiceHandle` with `unlock`, `lock`, +`derive_ed25519`, `derive_encryption_key`, `derive_ethereum_key`, +`derive_password`, `encrypt`, and `decrypt` methods. Who is allowed to call +these, and through what path? + +The candidates: + +1. **Handlers call the vault directly** — each handler holds a + `VaultServiceHandle` and derives keys at call time. This was the + pre-ADR-008 model and is rejected: it exposes the vault to every handler, + requires the vault to enforce per-handler path restrictions itself, and + means the master seed is reachable from every call path. + +2. **The call protocol exposes vault operations** — `vault/derive`, + `vault/decrypt`, `vault/unlock` registered as operations. This was the + contradiction ADR-014 resolved: the master seed and mnemonics would cross + the wire. + +3. **The assembly layer is the sole caller** — the CLI binary (or an + embedded assembly layer) holds the `VaultServiceHandle`, calls vault + methods at startup and (rarely) at call time through scoped capabilities, + and injects results into handlers. Handlers never hold a vault reference. + +## Decision + +**The assembly layer is the sole direct caller of the vault.** This +restates ADR-008/ADR-014 from the vault's perspective and makes the access +model explicit in the vault's own spec. + +### What the assembly layer does + +At startup: + +1. Constructs `VaultServiceHandle::new()` +2. Unlocks with a mnemonic (from a secure prompt, a file, or a hardware + token) and optional passphrase +3. Derives the keys each handler needs (identity, SSH host, TLS identity, + signing keys) +4. Decrypts the credentials each handler needs (LLM provider API keys, + OAuth tokens) +5. Constructs handlers with the derived/decrypted material injected into + their `Capabilities` +6. Registers the handlers in the `OperationRegistry` +7. Starts the endpoint + +After startup, the vault is typically not called again. The common case is +construction-time injection — a handler holds a static decrypted API key for +its lifetime. + +### What handlers do NOT do + +Handlers never: +- Hold a `VaultServiceHandle` reference +- Call `derive_*`, `encrypt`, or `decrypt` directly +- Receive the master seed or mnemonic +- Import `alknet_vault` as a dependency + +Handlers receive secret material through `OperationContext.capabilities` +(ADR-014). The `Capabilities` type holds non-serializable, zeroized secret +material that the assembly layer populated at construction time. + +### The scoped-capability exception + +The narrow exception is a handler that needs a child key at an +unpredictable path determined by call input (e.g., signing for a specific +GitHub repo). This handler receives a **scoped capability** — a restricted +handle that performs a specific derivation at a restricted path set and +returns the result in-process. The handler never sees the master seed and +never holds a full `VaultServiceHandle`. + +The scoped capability is still a capability (it lives on +`OperationContext.capabilities`), not a vault reference. Whether it is a +distinct type or a pre-derived key injected at construction is a two-way +door for the alknet-call and alknet-agent crate specs (ADR-014). + +### No vault operations on the wire + +The vault has no ALPN (ADR-003, ADR-008). No vault operation is registered +in the call protocol's `OperationRegistry` (ADR-014). The master seed, +mnemonics, and derived private keys never appear in `call.requested` +payloads, `call.responded` payloads, or `OperationContext.metadata` +(ADR-014). + +If a future use case requires exposing a vault operation over the call +protocol (e.g., a restricted `vault/public-key` operation that returns only +public key material for identity verification), it requires its own ADR +with an explicit threat model justification. This decision does not close +that door; it simply does not open it. + +## Consequences + +**Positive:** +- The master seed is reachable from exactly one place: the assembly layer. + The attack surface for the root of trust is a single process boundary, not + a distributed set of handlers. +- Handlers don't need to enforce path restrictions — they don't have the + vault. The scoped-capability mechanism enforces restrictions by + construction. +- The vault's API is consumed by one caller. This simplifies the vault's + threat model: it doesn't need per-caller authentication, rate limiting, or + path-based access control. The assembly layer is trusted. +- The vault can be tested in isolation — `VaultServiceHandle::new()` → + `unlock_new(24)` → `derive_*` is the test pattern, with no networking or + handler mockery. + +**Negative:** +- The assembly layer has more construction-time responsibility: it must + know which handlers need which credentials and wire them. This is expected + — the CLI assembles everything (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: + 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. If needed in the future, it requires a + separate, heavily restricted mechanism (admin scope, mTLS-only, never + expose the mnemonic over an unauthenticated channel) and its own ADR. + +## Assumptions + +1. **The assembly layer is trusted.** The CLI binary holds the vault handle + and is the trust boundary. If the assembly layer is compromised, all + handlers' capabilities are compromised. This is the same trust boundary + as ADR-008 and ADR-014. + +2. **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 covers it (the handler holds a scoped vault access), but the + surface area is larger. The assumption is that this case is rare. + +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. + +## References + +- ADR-003: Crate decomposition (alknet-vault is standalone) +- ADR-008: Vault integration point (CLI-embedded, capability source) +- ADR-014: Secret material flow and capability injection (the injection + mechanism this ADR relies on) +- ADR-018: Vault as standalone crate (the independence this ADR preserves) +- [crates/vault/service.md](../crates/vault/service.md) +- [crates/vault/README.md](../crates/vault/README.md) \ No newline at end of file diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index c28cb1d..0f9568c 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -236,4 +236,33 @@ These questions are acknowledged but not active. They will be promoted to open w Session-scoped operations run in a locked-down sandbox (no direct net/fs/env access), can only reach operations in the handler's scoped env, and their output should be validated against their declared schema before returning. The promotion path requires review — an agent with a `promote` scope (the architect role) performs the promotion; the writing agent (lower-privileged role) requests it. This is the role-based escalation pattern (ADR-015): privileges escalate through a chain of command, not through direct authority. The agent-specific mechanism (quickjs sandbox, session registry lifecycle, promotion workflow) belongs to the agent crate spec. The call protocol's job is to keep the `OperationEnv` trait composable and the visibility/ACL model consistent across tiers. -- **Cross-references**: OQ-04, ADR-014, ADR-015, ADR-016, [operation-registry.md](crates/call/operation-registry.md) \ No newline at end of file +- **Cross-references**: OQ-04, ADR-014, ADR-015, ADR-016, [operation-registry.md](crates/call/operation-registry.md) + +## Theme: alknet-vault + +### OQ-20: Salt/KDF Phase B + +- **Origin**: [encryption.md](crates/vault/encryption.md) +- **Status**: open +- **Door type**: Two-way +- **Priority**: low +- **Resolution**: The `EncryptedData.salt` field is reserved for future KDF-based key derivation. In v1, the encryption key is derived directly from the seed at path `m/74'/2'/0'/0'` without using the salt. The salt is generated and stored for forward compatibility but does not participate in key derivation. When KDF-based key derivation (HKDF or PBKDF2 using the salt) is implemented, the wire format does not need to change — the `salt` field is already present. The question of *when* to implement Phase B and *which* KDF to use is open but low-priority: v1's direct derivation is secure; the salt is a forward-compatibility hedge, not a gap. Two-way door — the salt is additive; implementing KDF usage doesn't break v1 data. +- **Cross-references**: [encryption.md](crates/vault/encryption.md) + +### OQ-21: Remote Vault Administration + +- **Origin**: [service.md](crates/vault/service.md), ADR-019 +- **Status**: deferred +- **Door type**: One-way (if implemented) +- **Priority**: low +- **Resolution**: Network unlock of a running node's vault is not supported (ADR-008, ADR-019). The vault is unlocked at startup by the CLI binary from a local mnemonic prompt or file. If a future use case requires remote vault administration (e.g., unlocking a headless node's vault over the network), it requires a separate, heavily restricted mechanism: admin scope (ADR-015), mTLS-only (never expose the mnemonic over an unauthenticated channel), and its own ADR with an explicit threat model. This decision does not close that door; it simply does not open it. Deferred because no current use case requires it. +- **Cross-references**: ADR-008, ADR-014, ADR-019, [service.md](crates/vault/service.md) + +### OQ-22: Key Rotation Mechanism + +- **Origin**: [encryption.md](crates/vault/encryption.md) +- **Status**: open +- **Door type**: Two-way +- **Priority**: low +- **Resolution**: Key versioning is in place (`EncryptedData.key_version`, `CURRENT_KEY_VERSION = 1`), but the rotation workflow — re-encrypt all existing data with a new key version, update storage — is not specced. The mechanism is straightforward (derive a new key at a new path or from a new seed, decrypt with v1, re-encrypt with v2), but the operational workflow (when to rotate, how to track which data is at which version, how to handle partially-rotated state) needs design. Low priority: keys don't rotate frequently, and v1 is stable. Two-way door — rotation is additive; a v2 key doesn't break v1 data. +- **Cross-references**: [encryption.md](crates/vault/encryption.md) \ No newline at end of file diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 175b999..251dc8c 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-06-18 +last_updated: 2026-06-19 --- # Alknet Overview @@ -208,6 +208,8 @@ All design decisions are documented as ADRs in [decisions/](decisions/). | [015](decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env | | [016](decisions/016-abort-cascade-for-nested-calls.md) | Abort Cascade for Nested Calls | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in | | [017](decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction | +| [018](decisions/018-vault-standalone-crate.md) | Vault as Standalone Crate | Zero alknet crate dependencies; vault defines own types and errors | +| [019](decisions/019-vault-assembly-layer-only.md) | Vault Assembly-Layer-Only Access | The assembly layer (CLI binary) is the sole direct caller; handlers never hold a vault reference | ## Open Questions @@ -217,8 +219,11 @@ Open questions are tracked in [open-questions.md](open-questions.md). Key questi - **OQ-02**: AuthContext resolution timing (resolved: hybrid — see ADR-004) - **OQ-03**: ALPN string naming convention (resolved: see ADR-006) - **OQ-04**: Dynamic handler registration at runtime vs static at startup (two-way door, defer to implementation) -- **OQ-08**: Vault integration point (resolved: CLI-embedded, assembly-layer only — see ADR-008, ADR-014) +- **OQ-08**: Vault integration point (resolved: CLI-embedded, assembly-layer only — see ADR-008, ADR-014, ADR-018, ADR-019) - **OQ-16**: Safe vault operations for call protocol exposure (resolved: none for now — see ADR-014) +- **OQ-20**: Salt/KDF Phase B (open: reserved field, v1 doesn't use it — see [encryption.md](crates/vault/encryption.md)) +- **OQ-21**: Remote vault administration (deferred: network unlock not supported — see ADR-019) +- **OQ-22**: Key rotation mechanism (open: versioning in place, workflow not specced — see [encryption.md](crates/vault/encryption.md)) ## Failure Modes