Files
alknet/docs/architecture/crates/vault/protocol.md
glm-5.2 2e34590522 docs(architecture): resolve review #003 — type/API surface completeness
Review #003 found 11 critical, 14 warning, and 6 suggestion findings
after reviews #001 (governance/security) and #002 (cross-document
consistency/two-way-door audit) were resolved. The theme: types and
APIs that were *referenced* but never *defined*, and stale ADR sketches
that didn't match the now-updated spec docs.

Critical fixes (11):

- C1: DerivedKey #[derive(Deserialize)] contradicted the custom
  Deserialize that rejects "[REDACTED]" — dropped the derive, added
  explicit manual Serialize/Deserialize impls (protocol.md).
- C2: encrypt prose said "derived at PATHS::ENCRYPTION" but the
  signature takes key_version — updated to encryption_path_for_version
  (service.md).
- C3: derive_encryption_key returned DerivedKey, derive_encryption_key
  _for_version returned EncryptionKey (same cache) — unified on
  DerivedKey, defined CachedKey (service.md).
- C4: tokio vs std::sync::RwLock contradiction — specified
  std::sync::RwLock, dropped tokio from vault deps (ADR-018, ADR-025,
  service.md).
- C5: Missing drift rows in vault README — added #9 (key_version
  ignored) and #10 (rotate not implemented).
- C6: ADR-022 build_root_context and invoke() sketches omitted
  abort_policy (9 fields vs 10) — added the field to both sketches.
- C7: Capabilities type referenced 20+ times, never defined — added
  struct definition to core-types.md with Clone+Send+Sync, Zeroize,
  sealed builder API, immutability guard.
- C8: SessionOverlaySource on CallAdapter but never defined, crate
  violation (alknet-call can't depend on alknet-agent) — defined the
  trait in alknet-call (call-protocol.md), matching the IdentityProvider
  pattern.
- C9: CompositeOperationEnv dispatch fall-through was "a two-way door"
  — added contains() to OperationEnv trait, made the composite probe
  before dispatching, eliminating the sentinel ambiguity.
- C10: No API for Layer 2 (connection overlay) registration, CallConnection
  undefined — defined CallConnection struct + register_imported() API
  (call-protocol.md).
- C11: with_local signature diverged between two examples (4 args vs 5)
  — added capabilities as the 5th arg, made both examples consistent.

Warning fixes (14):

- W1: invoke_with_policy restructured as required method, invoke gets a
  default impl delegating to it — eliminates duplication across impls.
- W2: CachedKey defined (service.md).
- W3: EncryptionKey constructor/glue specified, added to re-export list.
- W4: Secp256k1ExtendedPrivKey defined, derive_ethereum_key glue shown.
- W5: encryption_path_for_version rejects version < 2 (v1 is TS PBKDF2).
- W6: Wire payload schemas for all event types + ResponseEnvelope →
  EventEnvelope conversion table (call-protocol.md).
- W7: Timeout section — deadline on OperationContext, composed calls
  inherit parent's deadline, CallAdapter::with_timeout().
- W8: Request ID generation spec — UUID v4 for composed calls, wire ID
  vs internal ID relationship for abort cascade.
- W9: unlock_new already-unlocked behavior specified (returns
  AlreadyUnlocked).
- W10: KeyType Serialize/Deserialize justification corrected (stale
  irpc reference removed).
- W11: OperationProvenance and CompositionAuthority defined inline in
  operation-registry.md (were only in ADR-022).
- W12: encrypt/decrypt free functions marked pub(crate), relationship
  to VaultServiceHandle methods stated.
- W13: rotate signature removed from encryption.md (it's a
  VaultServiceHandle method, not a free function).
- W14: CallAdapter::new() + with_session_source() + with_timeout()
  constructors shown.

Suggestion fixes (6): Seed: Clone note, VaultServiceInner invariant,
ExtendedPrivKey accessor signatures, CURRENT_KEY_VERSION location, ADR-018
stale actor text, derivation helpers re-export note.
2026-06-23 10:56:05 +00:00

11 KiB

status, last_updated
status last_updated
draft 2026-06-23

Protocol

The DerivedKey type, KeyType enum, and serialization behavior. The vault's "protocol" is the VaultServiceHandle method API (ADR-025) — there is no message enum, no irpc dispatch, and no wire format.

What

The vault's dispatch is direct method calls on VaultServiceHandle (ADR-025). The types defined here — DerivedKey, KeyType — are the return types from those methods. There is no VaultProtocol enum, no VaultMessage, no VaultServiceActor, and no remote dispatch capability.

The vault is local-only by construction. If remote vault access is ever needed, it requires a separate crate that wraps the vault and adds remote transport + auth (ADR-025, OQ-021).

DerivedKey

The result of key derivation. Holds the key type, private key, and public key.

#[derive(Zeroize)]
#[zeroize(drop)]
pub struct DerivedKey {
    #[zeroize(skip)]
    pub key_type: KeyType,          // not secret — tag only
    #[zeroize]
    pub private_key: Vec<u8>,       // zeroized on drop
    #[zeroize(skip)]
    pub public_key: Vec<u8>,        // not secret — public by definition
}

DerivedKey does not derive Deserialize via #[derive]. It has a custom Deserialize impl that rejects redacted payloads — see Serialization Redaction below. (A derived Deserialize would generate a default impl that conflicts with the manual one, and would not produce the explicit redaction-rejection error the spec requires.)

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 always redacts the private key, regardless of format:

  • JSON (and all human-readable formats): private_key serializes as "[REDACTED]". This is defense-in-depth — if a DerivedKey accidentally ends up in a log, a JSON config, or debug output, the private key is not exposed.
  • Deserialization: a custom Deserialize impl rejects private_key == "[REDACTED]" with a deserialization error (not a corrupted key). This resolves review #002 W8 (silent corruption on JSON-deserialized DerivedKey). The custom impl is required because #[derive(Deserialize)] would generate a default impl that conflicts and would only fail incidentally (serde type mismatch: string vs sequence), not with the explicit redaction-rejection error the spec requires.
  • No binary-format preservation path. ADR-025 dropped the postcard/remote dispatch path that previously preserved private key bytes in binary formats. DerivedKey is always used in-process (ADR-014: never appears in call protocol payloads). If a future remote-vault crate needs to send DerivedKey over the wire, it defines its own serialization for that context — the vault's DerivedKey stays redact-always.
// Custom Serialize — always redacts private_key
impl serde::Serialize for DerivedKey {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where S: serde::Serializer {
        use serde::SerializeStruct;
        let mut s = serializer.serialize_struct("DerivedKey", 3)?;
        s.serialize_field("key_type", &self.key_type)?;
        s.serialize_field("private_key", "[REDACTED]")?;  // never the real bytes
        s.serialize_field("public_key", &self.public_key)?;
        s.end()
    }
}

// Custom Deserialize — rejects "[REDACTED]" with an error
impl<'de> serde::Deserialize<'de> for DerivedKey {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where D: serde::Deserializer<'de> {
        #[derive(serde::Deserialize)]
        struct DerivedKeyHelper {
            key_type: KeyType,
            private_key: Vec<u8>,
            public_key: Vec<u8>,
        }
        let helper = DerivedKeyHelper::deserialize(deserializer)?;
        // Reject redacted payloads — a JSON-deserialized DerivedKey with a
        // redacted private key is invalid, not a corrupted key.
        if helper.private_key == b"[REDACTED]" {
            return Err(serde::de::Error::custom(
                "DerivedKey.private_key is \"[REDACTED]\" — redacted payloads \
                 cannot be deserialized. JSON round-tripping a DerivedKey is \
                 not supported (the private key is gone)."
            ));
        }
        Ok(DerivedKey {
            key_type: helper.key_type,
            private_key: helper.private_key,
            public_key: helper.public_key,
        })
    }
}

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:

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

#[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 (retained for EncryptedData interop and future use — ADR-025 removed the irpc dispatch path that previously justified these derives, but the type remains serializable for structured storage scenarios) and Clone (it's not secret material — it's a tag).

Wire Format

The vault has no wire format (ADR-025). Dispatch is direct method calls on VaultServiceHandle — no serialization, no channels, no network. The DerivedKey custom Serialize/Deserialize impls exist solely for logging safety (redaction) and defense-in-depth, not for wire transport.

EncryptedData has a stable wire format (shared with alknet-storage and the TypeScript consumer by type-level agreement — see encryption.md and ADR-018). That format is for stored encrypted data, not for vault dispatch — the vault's encrypt/decrypt methods operate on EncryptedData as a value type, not as a wire message.

Local-Only by Construction

The vault is local-only by construction (ADR-025). There is no RemoteService trait, no remote handler, no wire format for vault messages. The vault's API is VaultServiceHandle — direct method calls, nothing else.

If remote vault access is ever needed (e.g., the machine→worker pattern where a long-lived node exposes a restricted vault API to ephemeral workers), it requires a separate vault-server crate that:

  1. Depends on both alknet-core (for IdentityProvider, scopes, auth-wrapping) and alknet-vault (for VaultServiceHandle).
  2. Defines its own threat model, access policy, and operation filtering (Unlock/Lock must be local-only; other operations may be remote-capable depending on the policy).
  3. Adds the remote transport (iroh/QUIC or similar) and an auth-wrapping handler that checks caller identity before forwarding to the vault.
  4. Requires its own ADR (matching ADR-019's language: "requires its own ADR") defining the threat model and access policy.

This is a deliberate addition, not a flag flip on a default that was already loaded. The pre-ADR-025 design made the vault remote-capable by construction (irpc generated RemoteService by default), which was the default-insecure anti-pattern. ADR-025 inverts the default: local-only is the only mode, and remote access requires building something new.

Per-node vaults are the recommended pattern for multi-node deployments. Each node has its own vault and mnemonic. Credentials are encrypted for the receiving node's public key or derived at a shared path the receiving node can derive locally. This is end-to-end encryption between nodes, not a centralized decryption oracle. It matches ADR-008's "capability source" model — credentials are injected at the assembly layer, not fetched over the network at call time.

Design Decisions

Decision ADR Summary
Vault is standalone ADR-018 Zero alknet crate dependencies
Vault is local-only ADR-025 Direct method calls, no irpc, no remote dispatch capability
HD derivation (not stored keys) One seed, many keys, no key storage
DerivedKey is move-only ADR-014 Prevents accidental duplication of secret material
JSON redacts private key (always) ADR-014 Defense-in-depth for logging accidents
No vault operations on call protocol ADR-008, ADR-014 Master seed never crosses the network
No remote dispatch in vault crate ADR-025 Remote access requires a separate vault-server crate with its own ADR

Open Questions

None active for this document. OQ-21 (remote vault) is resolved — see ADR-025 and open-questions.md.

References

  • Implementation: crates/alknet-vault/src/protocol.rs (to be updated per ADR-025 — remove VaultProtocol enum and irpc usage)
  • Tests: crates/alknet-vault/src/protocol.rs (unit tests for redaction and zeroize behavior; postcard tests to be removed)
  • service.mdVaultServiceHandle runtime API
  • mnemonic-derivation.md — what KeyType means