Initial Commit
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
# vault container
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## 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
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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`.
|
||||
Reference in New Issue
Block a user