Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
# vault attachments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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
```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<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.