Initial Commit
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user