Files
alknet/docs/architecture/crates/vault/encryption.md
glm-5.2 323ee85d40 docs(vault): remove drift tracking artifacts, bump vault docs to stable
The vault spec-to-implementation sync is complete. Remove the drift
tracking tools that were only needed during sync:

- Remove the Known Source Drift table from vault/README.md
- Remove 'known drift' / 'current source uses X' prose from Security
  Constraints sections in vault/README.md, encryption.md, and service.md.
  The permanent constraint statements (OsRng for IVs, zeroized drop,
  no unwrap, etc.) are preserved.
- Remove the drift paragraph in encryption.md Key Versioning.
- Remove stale 'to be updated per ADR-025' / 'postcard tests to be
  removed' notes in protocol.md References.
- Bump status: draft -> stable in the frontmatter of all vault docs
  (README, mnemonic-derivation, encryption, service, protocol).
- Update architecture/README.md: vault doc status entries to stable,
  Current State paragraph reflects vault implementation complete (no
  'pending ADR-025/026 refactor' language).
2026-06-23 14:15:13 +00:00

13 KiB

status, last_updated
status last_updated
stable 2026-06-23

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.

Key Derivation: HD, Not PBKDF2

The encryption key is derived from the BIP39 seed via SLIP-0010 HD derivation at path m/74'/2'/0'/0' (PATHS::ENCRYPTION). This is a deliberate choice over the PBKDF2 approach used by the TypeScript predecessor (@alkdev/storage/src/graphs/crypto.ts). See ADR-020 for the full rationale.

Aspect TS predecessor (PBKDF2) Vault (HD derivation)
Secret input Password (user-provided) BIP39 seed (64 bytes)
Salt role Load-bearing — part of key derivation Unused — stored for wire-format compat
Derivation PBKDF2 (100k iterations) SLIP-0010 (a few HMACs)
Speed Intentionally slow Instant
Reproducible Only with exact password Deterministic from mnemonic
key_version 1 2

Data encrypted by the TS implementation (PBKDF2, key_version=1) cannot be decrypted by the vault — the keys are different even if the password equals the mnemonic. Migration is a one-time re-encryption (see ADR-020).

Encryption Key

The encryption key is derived from the seed at a version-indexed path (m/74'/2'/0'/{version-2}' per ADR-021; v2 is PATHS::ENCRYPTION):

/// AES-256-GCM encryption key. Not `Clone` — move-only, like `DerivedKey`.
/// Implements a custom redacting `Debug` (never prints key bytes).
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct EncryptionKey {
    key_bytes: [u8; 32],   // 32-byte AES-256 key
    key_version: u32,      // for rotation tracking
}

impl EncryptionKey {
    /// Construct from raw 32 bytes. Private — for internal use.
    fn new(key_bytes: [u8; 32], key_version: u32) -> Self;

    /// Take the first 32 bytes of derived key material (the private key
    /// bytes from SLIP-0010 derivation) and construct an `EncryptionKey`.
    /// This is the bridge from `DerivedKey` (SLIP-0010 output) to
    /// `EncryptionKey` (AES-256-GCM input). `VaultServiceHandle::encrypt`
    /// and `decrypt` call this on the cached `DerivedKey` to obtain the
    /// `EncryptionKey` for the crypto layer.
    pub fn from_derived_bytes(derived: &[u8], key_version: u32) -> Self;

    /// Return the key version (for rotation tracking).
    pub fn version(&self) -> u32;

    /// Return the key bytes (crate-internal — for `encrypt`/`decrypt`).
    pub(crate) fn key_bytes(&self) -> &[u8; 32];
}

impl fmt::Debug for EncryptionKey {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("EncryptionKey")
            .field("key_version", &self.key_version)
            .field("key_bytes", &"[REDACTED]")
            .finish()
    }
}

EncryptionKey implements Zeroize and ZeroizeOnDrop — the key bytes are zeroized before deallocation. It does not derive Clone (move-only, like DerivedKey) and does not derive Serialize (never crosses a wire). The Debug impl is custom and redacts key_bytes.

The key is derived once (on first encrypt/decrypt) and cached in the KeyCache as a CachedKey wrapping a DerivedKey (see service.md). encrypt/decrypt extract the EncryptionKey from the cached DerivedKey via EncryptionKey::from_derived_bytes on each call (the DerivedKey is the cached form; the EncryptionKey is a short-lived per-call value derived from it).

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.

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EncryptedData {
    pub key_version: u32,  // rotation tracking
    pub salt: String,      // base64, 32 bytes — unused in v2 (wire-format compat, see ADR-020)
    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 (unused in v2 — reserved for future KDF)

The salt field is unused for key derivation in v2 (HD derivation doesn't need a salt — the derivation path provides domain separation). The salt is generated randomly (32 bytes) and stored for wire-format compatibility with the TypeScript EncryptedDataSchema, but it plays no cryptographic role.

In the TypeScript predecessor, the salt was load-bearing — it was part of the PBKDF2 key derivation. The vault's HD derivation doesn't use it, but the field is kept in the wire format so the struct doesn't need to change if a future KDF-based derivation is added.

If KDF-based key derivation is ever implemented (using HKDF or PBKDF2 with the salt as input), it would be a new key_version and would not affect existing v2 data. This is additive — see OQ-22 (key rotation) and ADR-020 (HD derivation decision).

Encrypt and Decrypt

These are module-internal crypto helpers (in encryption.rs), not the public API. The public API is VaultServiceHandle::encrypt / VaultServiceHandle::decrypt (see service.md), which derive the key (from the cache or via derive_encryption_key_for_version), extract the EncryptionKey via EncryptionKey::from_derived_bytes, and call these helpers.

// Module-internal (encryption.rs). Not re-exported from the crate root.
// VaultServiceHandle::encrypt/decrypt call through to these.
pub(crate) fn encrypt(plaintext: &str, key: &EncryptionKey) -> Result<EncryptedData, EncryptionError>;
pub(crate) fn decrypt(encrypted: &EncryptedData, key: &EncryptionKey) -> Result<String, EncryptionError>;

encrypt:

  1. Generates a random 12-byte IV (must use OsRng — see Security Constraints)
  2. Generates a random 32-byte salt (stored for wire-format compat, unused in key derivation)
  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 2 (defined in encryption.rs, re-exported from the crate root). Version 1 is reserved for the TypeScript predecessor's PBKDF2-encrypted data (see ADR-020). Each version maps to a unique derivation path — the last hardened index is the version offset (see ADR-021):

v2: m/74'/2'/0'/0'    ← PATHS::ENCRYPTION (current)
v3: m/74'/2'/0'/1'
v4: m/74'/2'/0'/2'

encrypt stamps the version onto new blobs. decrypt derives the key at the path indicated by encrypted.key_version — each version has its own cryptographically independent key. Old version keys remain derivable (the seed doesn't change), so partial rotation is safe.

Rotation

Key rotation re-encrypts a blob from one version to another. The vault provides a VaultServiceHandle::rotate method (see service.md → rotate); the caller (assembly layer or migration tool) handles replacing the blob in storage. Rotation decrypts with the old version's key and re-encrypts with the new version's key. No new mnemonic needed — the same seed produces all version keys via different paths. See ADR-021 for the full mechanism.

Errors

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 },  // unused — see note below
}

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. ADR-021 implements key rotation via version-indexed derivation paths — decrypt derives the key at the path indicated by encrypted.key_version, so there is no version-mismatch to detect at the error level (every blob carries its own version, and every version has a derivable key). This variant predates ADR-021's rotation mechanism and is retained in the enum for source compatibility but is not emitted by any code path in v2. An implementer should not wire it up or expect it to fire. If a future use case requires enforcing version constraints (e.g., "refuse to decrypt blobs older than v3"), this variant could be repurposed — but that would be a new decision, not part of ADR-021's rotation scheme.

Design Decisions

Decision ADR Summary
AES-256-GCM for credential encryption ADR-026 Authenticated encryption, hardware-accelerated
HD derivation, not PBKDF2 ADR-020 Seed-derived key; no password; deterministic
Salt unused in v2 (wire-format compat) ADR-020 Kept for TS compat; not used in key derivation
Key derived at m/74'/2'/0'/0' ADR-026 Dedicated account for encryption keys
Version-indexed paths for rotation ADR-021 m/74'/2'/0'/{version-2}'
Key versioning (v1=TS PBKDF2, v2=vault HD) ADR-020 Distinguishes derivation methods
All fields base64-encoded JSON serialization compatibility
EncryptedData wire format frozen ADR-018 Fields, encoding, semantics locked; no removal without migration

Open Questions

See open-questions.md for full details.

  • OQ-20 (resolved by ADR-020): Salt/KDF — HD derivation is the method; the salt field is unused in v2 (wire-format compatibility only).
  • OQ-22 (resolved by ADR-021): Key rotation — version-indexed paths; rotate method decrypts old, re-encrypts new.

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. 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 — 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 — how the vault caches the encryption key