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; throwsVAULT.DECRYPT_FAILEDon 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.
- throws
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>". Thev1literal prefix lets future versions bump without colliding with old envelopes; consumers that try to decrypt a v1 envelope under a hypothetical v2 AAD will getVAULT.DECRYPT_FAILED. - The 24-byte XChaCha20-Poly1305 nonce is sampled from the injected
rng(orcrypto.getRandomValuesby 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 viazeroize(itemKey)in afinallyblock 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 fromUint8Arrayand overwrite their own buffers. - A failure of
xchacha20poly1305.decryptis mapped toVAULT.DECRYPT_FAILEDwith 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.