4.6 KiB
vault container
(no submodules)
Purpose
container.ts is the drive-backed encrypted blob layer that sits between the orchestration Vault class and the underlying DriveLike adapter. It handles the on-disk format — [manifest header][AEAD nonce][AEAD ciphertext+tag] — by combining manifest/manifest.ts (header packing) with @suaveplan/crypto-cipher's XChaCha20-Poly1305 (body authentication).
The container is the single boundary at which vault state crosses between in-memory plaintext and on-disk ciphertext. loadContainer reads the blob, parses the manifest, and AEAD-decrypts the body using the manifest as AAD; saveContainer reverses the process and writes back atomically. Tampering with any byte of the manifest or ciphertext fails the AEAD tag check, raising VAULT.DECRYPT_FAILED.
Features
createEmptyContainer(input)— build a fresh state + manifest for first-time vault creation.loadContainer(drive, path, vaultKey)— read encrypted blob from drive and decrypt it.saveContainer(input)— refresh the manifest'slastModifiedAt, encrypt, and atomically write the blob.encryptContainer(input)/decryptContainer(blob, vaultKey)— pure byte-level helpers without drive I/O (used by tests and integration).serialiseState(state)/deserialiseState(bytes)— round-trip the in-memory item / history / ledger state through UTF-8 JSON.- All drive-side failures map to
VAULT.DRIVE.FAILURE; AEAD / encoding failures map toVAULT.DECRYPT_FAILED.
Usage: Basic Example
import { createEmptyContainer, encryptContainer, decryptContainer } from "@suaveplan/vault/container/container";
const { manifest, state } = createEmptyContainer({
vaultId: "personal",
argon2idMemoryKB: 65536,
argon2idIterations: 3,
argon2idParallelism: 4,
now: Date.now(),
});
const blob = encryptContainer({ manifest, state, vaultKey });
const round = decryptContainer(blob, vaultKey);
// round.manifest.formatVersion === manifest.formatVersion
// round.state.schemaVersion === state.schemaVersion
Usage: Advanced Example
import { loadContainer, saveContainer } from "@suaveplan/vault/container/container";
// On unlock: read + decrypt + return state.
const { manifest, state } = await loadContainer(drive, "/personal.vault", vaultKey);
// ... mutate state via items module ...
// On flush: refresh manifest + encrypt + atomic write.
const updated = await saveContainer({
drive,
path: "/personal.vault",
manifest,
state,
vaultKey,
now: Date.now(),
});
API Reference
createEmptyContainer(input): ContainerLoadResult— pristine state at head schema.serialiseState(state): Uint8Array/deserialiseState(bytes): VaultContainerState— JSON round-trip.encryptContainer({ manifest, state, vaultKey, rng? }): Uint8Array— pack header, encrypt body, concatenate.decryptContainer(blob, vaultKey): ContainerLoadResult— slice, unpack, decrypt, deserialise.loadContainer(drive, path, vaultKey): Promise<ContainerLoadResult>— drive read + decrypt.saveContainer({ drive, path, manifest, state, vaultKey, now, rng? }): Promise<VaultManifest>— refresh manifest, encrypt, drive write; returns the refreshed manifest.
Implementation Notes
- The blob layout is fixed: header at offset 0 (size
MANIFEST_BYTE_SIZE), nonce at offsetMANIFEST_BYTE_SIZE(24 bytes), ciphertext after that. Any change to the layout requires a manifest format-version bump. - The AEAD AAD is the packed manifest header bytes, not the parsed manifest object — this binds the ciphertext to the exact byte sequence the reader will use to choose the AEAD algorithm and KDF parameters. An attacker who flips a single bit in the header sees an authentication failure rather than a silent algorithm downgrade.
- Drive
writeis contracted to be atomic inDriveLike; the container layer relies on this for crash safety. A partial write would corrupt the blob; truthfully-atomic adapters (POSIXrename, S3PutObject, IndexedDB transactions) all satisfy the contract. serialiseStatecollects items as an array (sorted into Map order on the way out) and re-builds the Map ondeserialiseState. This keeps the on-disk JSON shape stable across runtimes that may not preserve Map iteration order through structured-clone.- The minimum blob length check in
decryptContainer(MANIFEST_BYTE_SIZE + NONCE_LENGTH + 16) is the smallest possible valid blob: header + nonce + 16-byte Poly1305 tag with empty plaintext. Anything shorter is malformed by definition and fails fast withVAULT.DECRYPT_FAILED.