# vault attachments _(no submodules)_ ## Purpose `attachments.ts` implements drive-backed AEAD-encrypted attachment storage. Each attachment is encrypted under a per-attachment key derived from the vault key + a synthetic `attachment/` derivation label, with the AEAD AAD bound to the owning `(itemId, filename)` so an attacker who reorders blobs in drive cannot pass authentication. The repository wires `DriveLike.read` / `write` / `delete` for blob I/O and keeps attachment metadata (id / itemId / filename / mimeType / size / createdAt) in a caller-provided in-memory `Map` that lives inside the vault's container state. Metadata mutations notify the orchestrator via `onMutation` so the next debounced flush re-encrypts the container blob and writes it back. ## Features - `upload(input) → Attachment` — Zod-validated metadata, AEAD-encrypted body, atomic drive write. - `download(id) → Uint8Array` — drive read + AEAD decryption; throws `ATTACHMENT.NOT_FOUND` on missing metadata. - `delete(id)` — drive delete + metadata removal; throws `ATTACHMENT.NOT_FOUND` on missing metadata. - `list()` — snapshot of every attachment metadata record. - Hard cap on plaintext size (default 100 MiB; configurable via `maxAttachmentBytes`); over-cap uploads throw `ATTACHMENT.TOO_LARGE`. - Drive failures throw `DRIVE.FAILURE` with the underlying error chained as `cause`. - Per-attachment key zeroization in a `finally` block after every encrypt / decrypt. - OTel histogram + counter per upload / download / delete with `op` and `status` labels. ## Usage: Basic Example ```ts import { VaultAttachments } from "@suaveplan/vault/attachments/attachments"; const attachments = new VaultAttachments({ drive, attachmentsPrefix: "/vaults/personal/attachments", vaultKey, clock: Date.now, rng: undefined, metadata: state.attachments, onMutation: () => scheduleFlush(), }); const meta = await attachments.upload({ itemId: "abc", filename: "passport.pdf", mimeType: "application/pdf", data: new Uint8Array(fileBytes), }); const recovered = await attachments.download(meta.id); ``` ## Usage: Advanced Example ```ts import { VaultAttachments, encodeAttachmentMetadata, decodeAttachmentMetadata, } from "@suaveplan/vault/attachments/attachments"; // Custom 1 MiB cap for a memory-constrained device. const small = new VaultAttachments({ drive, attachmentsPrefix: "/embedded", vaultKey, clock, rng, maxAttachmentBytes: 1024 * 1024, metadata, onMutation, }); // Persist attachment metadata in a sidecar list outside the container. const meta = await small.upload({ itemId, filename: "small.bin", mimeType: "application/octet-stream", data, }); const encoded = encodeAttachmentMetadata(meta); const decoded = decodeAttachmentMetadata(encoded); // `decoded` round-trips equal to `meta`. ``` ## API Reference - `class VaultAttachments`. - `constructor(options: AttachmentsOptions)`. - `upload(input: UploadAttachmentInput): Promise` — throws `ATTACHMENT.TOO_LARGE` / `ITEM.VALIDATION_FAILED` / `DRIVE.FAILURE`. - `download(id: string): Promise` — throws `ATTACHMENT.NOT_FOUND` / `DRIVE.FAILURE` / `DECRYPT_FAILED`. - `delete(id: string): Promise` — throws `ATTACHMENT.NOT_FOUND` / `DRIVE.FAILURE`. - `list(): Attachment[]`. - `interface AttachmentsOptions` — `{ drive, attachmentsPrefix, vaultKey, clock, rng, maxAttachmentBytes?, metadata, onMutation }`. - `interface UploadAttachmentInput` — `{ itemId, filename, mimeType, data }`. - `DEFAULT_MAX_ATTACHMENT_BYTES: 100 MiB`. - `encodeAttachmentMetadata(meta): string` — base64 JSON helper. - `decodeAttachmentMetadata(encoded): Attachment` — inverse, validates via `AttachmentSchema`. ## Implementation Notes - The blob layout is `[24-byte nonce][ciphertext + 16-byte Poly1305 tag]`. No per-attachment manifest header — the metadata record carries `(id, itemId, filename, mimeType, size)`, and the container manifest's vault-id binding still applies indirectly through the container blob's own AEAD authentication. - The per-attachment key is derived via `deriveItemKey(vaultKey, "attachment/")`. Using a synthetic key path prevents collisions with item keys derived from the same vault key under genuine item ids. - The AEAD AAD is `"vault/v1/attachment//"`. A renamed attachment (different filename) re-encrypted without updating the metadata would fail authentication on download — which is the desired behaviour: metadata and blob must stay in sync. - Drive `write` is contracted as atomic; a partial write would otherwise leave a half-encrypted blob that fails AEAD authentication on the next read. Adapters that cannot satisfy atomicity (e.g. unsynced filesystem writes) must implement their own staged-tmp + rename. - Over-cap uploads throw `ATTACHMENT.TOO_LARGE` BEFORE any drive I/O — this prevents an attacker from filling drive storage with rejected bytes. - The OTel `outcome` counter uses a fixed allowlist (`success | not_found | rejected`); other failure modes (e.g. AEAD auth) are surfaced through the thrown `VaultError` rather than as a counter status, to keep the metric cardinality bounded.