Files
skills/docs/security-stack/security/vault/src/attachments/attachments.md
2026-05-29 08:20:56 +01:00

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; 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

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> — throws ATTACHMENT.TOO_LARGE / ITEM.VALIDATION_FAILED / DRIVE.FAILURE.
    • download(id: string): Promise<Uint8Array> — throws ATTACHMENT.NOT_FOUND / DRIVE.FAILURE / DECRYPT_FAILED.
    • delete(id: string): Promise<void> — 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/<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 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.