5.2 KiB
5.2 KiB
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/<id> 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<string, Attachment> 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; throwsATTACHMENT.NOT_FOUNDon missing metadata.delete(id)— drive delete + metadata removal; throwsATTACHMENT.NOT_FOUNDon missing metadata.list()— snapshot of every attachment metadata record.- Hard cap on plaintext size (default 100 MiB; configurable via
maxAttachmentBytes); over-cap uploads throwATTACHMENT.TOO_LARGE. - Drive failures throw
DRIVE.FAILUREwith the underlying error chained ascause. - Per-attachment key zeroization in a
finallyblock after every encrypt / decrypt. - OTel histogram + counter per upload / download / delete with
opandstatuslabels.
Usage: Basic Example
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
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<Attachment>— throwsATTACHMENT.TOO_LARGE/ITEM.VALIDATION_FAILED/DRIVE.FAILURE.download(id: string): Promise<Uint8Array>— throwsATTACHMENT.NOT_FOUND/DRIVE.FAILURE/DECRYPT_FAILED.delete(id: string): Promise<void>— throwsATTACHMENT.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 viaAttachmentSchema.
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/<id>"). 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/<itemId>/<filename>". 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
writeis 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_LARGEBEFORE any drive I/O — this prevents an attacker from filling drive storage with rejected bytes. - The OTel
outcomecounter uses a fixed allowlist (success | not_found | rejected); other failure modes (e.g. AEAD auth) are surfaced through the thrownVaultErrorrather than as a counter status, to keep the metric cardinality bounded.