Files
2026-05-27 23:47:53 +01:00

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's lastModifiedAt, 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 to VAULT.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 offset MANIFEST_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 write is contracted to be atomic in DriveLike; the container layer relies on this for crash safety. A partial write would corrupt the blob; truthfully-atomic adapters (POSIX rename, S3 PutObject, IndexedDB transactions) all satisfy the contract.
  • serialiseState collects items as an array (sorted into Map order on the way out) and re-builds the Map on deserialiseState. 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 with VAULT.DECRYPT_FAILED.