first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
# vault field encryption
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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
```ts
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
```ts
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.