Files
skills/docs/security-stack/security/vault/src/encryption/field-encryption.md
2026-05-29 08:20:56 +01:00

4.2 KiB

vault field encryption

(no submodules)

Purpose

field-encryption.ts implements per-item, per-field XChaCha20-Poly1305 AEAD encryption for vault items' concealed fields (passwords, PINs, secure notes). It is the defence-in-depth layer that lives inside the SQLite container: even if the drive-level AEAD is broken (which by construction it cannot be), every concealed field remains ciphertext until lazily decrypted on vault.items.get.

The module derives a per-item key from the vault key via @suaveplan/crypto-vault's deriveItemKey(vaultKey, itemId), encrypts the plaintext with XChaCha20-Poly1305, binds the AEAD AAD to the (itemId, fieldName) pair so a swapped envelope (e.g. moving a password between items) fails authentication, and zeroizes the per-item key and plaintext buffer immediately after the operation.

Features

  • encryptField(options) → EncryptedField — encrypt a single named field value.
  • decryptField(options) → string — decrypt a single envelope; throws VAULT.DECRYPT_FAILED on auth failure or malformed encoding.
  • encryptConcealedFields(vaultKey, itemId, concealed, rng?) → Record<string, EncryptedField> — bulk-encrypt every field in a record.
  • decryptConcealedFields(vaultKey, itemId, encrypted) → Record<string, string> — bulk-decrypt every envelope.
  • AAD-binding to (itemId, fieldName) prevents envelope-swapping attacks.
  • Per-item key + plaintext zeroization after every operation.

Usage: Basic Example

import { encryptField, decryptField } from "@suaveplan/vault/encryption/field-encryption";

const envelope = encryptField({
    vaultKey,
    itemId: "abc-123",
    fieldName: "password",
    value: "p@ssw0rd",
});
const recovered = decryptField({
    vaultKey,
    itemId: "abc-123",
    fieldName: "password",
    envelope,
});
// recovered === "p@ssw0rd"

Usage: Advanced Example

import {
    encryptConcealedFields,
    decryptConcealedFields,
} from "@suaveplan/vault/encryption/field-encryption";

// Encrypt every concealed field in one call before persisting.
const concealed = { password: "p@ssw0rd", pin: "1234" };
const envelopes = encryptConcealedFields(vaultKey, itemId, concealed);
// Persist `envelopes` as JSON inside the SQLite row.

// On read:
const decrypted = decryptConcealedFields(vaultKey, itemId, envelopes);
// decrypted.password === "p@ssw0rd"

API Reference

  • interface EncryptedField — { algorithm: "xchacha20-poly1305", nonce: base64, ciphertext: base64 }.
  • encryptField(options: EncryptFieldOptions): EncryptedField.
  • decryptField(options: DecryptFieldOptions): string.
    • throws VaultError(VAULT.DECRYPT_FAILED) on bad algorithm / encoding / authentication.
  • encryptConcealedFields(vaultKey, itemId, concealed, rng?): Record<string, EncryptedField>.
  • decryptConcealedFields(vaultKey, itemId, encrypted): Record<string, string>.

Implementation Notes

  • The AEAD AAD is "vault/v1/item/<itemId>/field/<fieldName>". The v1 literal prefix lets future versions bump without colliding with old envelopes; consumers that try to decrypt a v1 envelope under a hypothetical v2 AAD will get VAULT.DECRYPT_FAILED.
  • The 24-byte XChaCha20-Poly1305 nonce is sampled from the injected rng (or crypto.getRandomValues by default) per-encryption. The 192-bit nonce space lets nonce reuse remain birthday-bound at ~2⁸⁰ messages — well beyond any vault's lifetime.
  • Per-item keys are derived deterministically from (vaultKey, itemId). They are zeroized via zeroize(itemKey) in a finally block after every encrypt or decrypt — this minimises the wall-clock window during which a key is reachable in heap memory.
  • The plaintext buffer (TEXT_ENCODER.encode(value)) is also zeroized after encryption. Strings, by contrast, are immutable in JS and cannot be cleared from heap; callers that want full lifecycle hygiene must source plaintexts from Uint8Array and overwrite their own buffers.
  • A failure of xchacha20poly1305.decrypt is mapped to VAULT.DECRYPT_FAILED with an { reason: "auth" } context tag. Encoding failures (bad base64) are mapped to the same code with { reason: "encoding" } so callers can branch on the failure mode without exposing implementation details.