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.

View File

@@ -0,0 +1,418 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VAULT_CODES } from "../errors/codes.js";
import { VaultError } from "../errors/vault-error.js";
import type { DriveLike } from "../types/adapters.js";
import type { Attachment } from "../types/types.js";
import {
decodeAttachmentMetadata,
encodeAttachmentMetadata,
VaultAttachments,
} from "./attachments.js";
function vaultKey(): Uint8Array {
const key = new Uint8Array(32);
crypto.getRandomValues(key);
return key;
}
function memoryDrive(): DriveLike & { blobs: Map<string, Uint8Array> } {
const blobs = new Map<string, Uint8Array>();
return {
blobs,
async read(path) {
const v = blobs.get(path);
if (v === undefined) throw new Error(`missing ${path}`);
return v;
},
async write(path, data) {
blobs.set(path, new Uint8Array(data));
},
async delete(path) {
return blobs.delete(path);
},
async exists(path) {
return blobs.has(path);
},
};
}
function makeAttachments(
overrides: Partial<{
drive: DriveLike;
maxAttachmentBytes: number;
rng: undefined;
}> = {}
): {
attachments: VaultAttachments;
drive: ReturnType<typeof memoryDrive>;
metadata: Map<string, Attachment>;
flushes: () => number;
} {
const drive =
(overrides.drive as ReturnType<typeof memoryDrive>) ?? memoryDrive();
const metadata = new Map<string, Attachment>();
let flushes = 0;
const opts = {
drive,
attachmentsPrefix: "/att",
vaultKey: vaultKey(),
clock: () => 1,
rng: undefined,
metadata,
onMutation: () => {
flushes += 1;
},
...(overrides.maxAttachmentBytes !== undefined
? { maxAttachmentBytes: overrides.maxAttachmentBytes }
: {}),
};
const attachments = new VaultAttachments(opts);
return {
attachments,
drive,
metadata,
flushes: () => flushes,
};
}
describe("VaultAttachments", () => {
it("upload + download round-trips the plaintext bytes", {
timeout: 5000,
}, async () => {
const { attachments } = makeAttachments();
const data = new TextEncoder().encode("hello, attachment!");
const meta = await attachments.upload({
itemId: "i1",
filename: "hello.txt",
mimeType: "text/plain",
data,
});
expect(meta.size).toBe(data.length);
const recovered = await attachments.download(meta.id);
expect(new TextDecoder().decode(recovered)).toBe("hello, attachment!");
});
it("download throws ATTACHMENT.NOT_FOUND for unknown ids", {
timeout: 5000,
}, async () => {
const { attachments } = makeAttachments();
try {
await attachments.download("missing");
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES["ATTACHMENT.NOT_FOUND"]
);
}
});
it("delete removes drive blob and metadata", {
timeout: 5000,
}, async () => {
const { attachments, drive, metadata } = makeAttachments();
const meta = await attachments.upload({
itemId: "i",
filename: "f.bin",
mimeType: "application/octet-stream",
data: new Uint8Array(8),
});
expect(metadata.has(meta.id)).toBe(true);
await attachments.delete(meta.id);
expect(metadata.has(meta.id)).toBe(false);
expect(drive.blobs.size).toBe(0);
});
it("delete on missing id throws ATTACHMENT.NOT_FOUND", {
timeout: 5000,
}, async () => {
const { attachments } = makeAttachments();
try {
await attachments.delete("missing");
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES["ATTACHMENT.NOT_FOUND"]
);
}
});
it("upload throws ATTACHMENT.TOO_LARGE for over-cap blobs", {
timeout: 5000,
}, async () => {
const { attachments } = makeAttachments({
maxAttachmentBytes: 4,
});
try {
await attachments.upload({
itemId: "i",
filename: "f",
mimeType: "application/octet-stream",
data: new Uint8Array(16),
});
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES["ATTACHMENT.TOO_LARGE"]
);
}
});
it("list returns every uploaded attachment metadata", {
timeout: 5000,
}, async () => {
const { attachments } = makeAttachments();
await attachments.upload({
itemId: "i",
filename: "a",
mimeType: "application/octet-stream",
data: new Uint8Array(1),
});
await attachments.upload({
itemId: "i",
filename: "b",
mimeType: "application/octet-stream",
data: new Uint8Array(1),
});
expect(attachments.list().length).toBe(2);
});
it("encode/decode metadata round-trips through base64 JSON", {
timeout: 5000,
}, async () => {
const { attachments } = makeAttachments();
const meta = await attachments.upload({
itemId: "i",
filename: "f",
mimeType: "application/octet-stream",
data: new Uint8Array(2),
});
const round = decodeAttachmentMetadata(encodeAttachmentMetadata(meta));
expect(round).toEqual(meta);
});
it("download throws DECRYPT_FAILED on tampered drive blob", {
timeout: 5000,
}, async () => {
const drive = memoryDrive();
const { attachments } = makeAttachments({ drive });
const meta = await attachments.upload({
itemId: "i",
filename: "f",
mimeType: "application/octet-stream",
data: new Uint8Array([1, 2, 3, 4]),
});
const path = `/att/${meta.id}`;
const tampered = new Uint8Array(drive.blobs.get(path) as Uint8Array);
tampered[tampered.length - 1] ^= 1;
drive.blobs.set(path, tampered);
try {
await attachments.download(meta.id);
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES.DECRYPT_FAILED);
}
});
it("attachmentsPrefix supports trailing slash", {
timeout: 5000,
}, async () => {
const drive = memoryDrive();
const metadata = new Map<string, Attachment>();
const a = new VaultAttachments({
drive,
attachmentsPrefix: "/att/",
vaultKey: vaultKey(),
clock: () => 0,
rng: undefined,
metadata,
onMutation: () => {},
});
const meta = await a.upload({
itemId: "i",
filename: "f",
mimeType: "application/octet-stream",
data: new Uint8Array(2),
});
const keys = Array.from(drive.blobs.keys());
expect(keys[0]).toBe(`/att/${meta.id}`);
});
it("decryptContainer-style too-short blob fails with DECRYPT_FAILED", {
timeout: 5000,
}, async () => {
const drive = memoryDrive();
const metadata = new Map<string, Attachment>();
const a = new VaultAttachments({
drive,
attachmentsPrefix: "/att",
vaultKey: vaultKey(),
clock: () => 0,
rng: undefined,
metadata,
onMutation: () => {},
});
metadata.set("fake", {
id: "fake",
itemId: "i",
filename: "f",
mimeType: "application/octet-stream",
size: 0,
createdAt: 0,
});
drive.blobs.set("/att/fake", new Uint8Array(10));
try {
await a.download("fake");
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES.DECRYPT_FAILED);
}
});
it("upload rejects empty filename via ITEM.VALIDATION_FAILED", {
timeout: 5000,
}, async () => {
const { attachments } = makeAttachments();
try {
await attachments.upload({
itemId: "i",
filename: "",
mimeType: "x/y",
data: new Uint8Array(2),
});
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES["ITEM.VALIDATION_FAILED"]
);
}
});
it("non-Error drive failures still surface as DRIVE.FAILURE", {
timeout: 5000,
}, async () => {
const sentinel: unknown = "non-error";
const writeFail: DriveLike = {
async read() {
throw new Error("");
},
async write() {
throw sentinel;
},
async delete() {
return false;
},
async exists() {
return false;
},
};
const { attachments: aw } = makeAttachments({ drive: writeFail });
try {
await aw.upload({
itemId: "i",
filename: "f",
mimeType: "application/octet-stream",
data: new Uint8Array(2),
});
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES["DRIVE.FAILURE"]);
}
const readFail: DriveLike = {
async read() {
throw sentinel;
},
async write() {},
async delete() {
return false;
},
async exists() {
return true;
},
};
const { attachments: ar } = makeAttachments({ drive: readFail });
(ar as unknown as { metadata: Map<string, Attachment> }).metadata.set(
"x",
{
id: "x",
itemId: "i",
filename: "f",
mimeType: "application/octet-stream",
size: 1,
createdAt: 0,
}
);
try {
await ar.download("x");
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES["DRIVE.FAILURE"]);
}
const delFail: DriveLike = {
async read() {
throw new Error("");
},
async write() {},
async delete() {
throw sentinel;
},
async exists() {
return false;
},
};
const { attachments: ad } = makeAttachments({ drive: delFail });
(ad as unknown as { metadata: Map<string, Attachment> }).metadata.set(
"y",
{
id: "y",
itemId: "i",
filename: "f",
mimeType: "application/octet-stream",
size: 1,
createdAt: 0,
}
);
try {
await ad.delete("y");
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES["DRIVE.FAILURE"]);
}
});
it("upload accepts an injected RNG", { timeout: 5000 }, async () => {
const drive = memoryDrive();
const metadata = new Map<string, Attachment>();
const rng = {
nextUint32: () => 1,
nextFloat: () => 0,
nextBytes: (len: number) => new Uint8Array(len).fill(7),
};
const a = new VaultAttachments({
drive,
attachmentsPrefix: "/att",
vaultKey: vaultKey(),
clock: () => 0,
rng,
metadata,
onMutation: () => {},
});
const meta = await a.upload({
itemId: "i",
filename: "f",
mimeType: "application/octet-stream",
data: new Uint8Array(2),
});
expect(meta.id).toBe("07".repeat(16));
});
});

View File

@@ -0,0 +1,280 @@
import { xchacha20poly1305 } from "@suaveplan/crypto-cipher";
import { fromBase64, toBase64, zeroize } from "@suaveplan/crypto-utils";
import { deriveItemKey } from "@suaveplan/crypto-vault";
import type { Clock, Rng } from "@suaveplan/types";
import { VAULT_CODES } from "../errors/codes.js";
import { VaultError } from "../errors/vault-error.js";
import { generateItemId } from "../items/helpers.js";
import { getVaultInstruments } from "../otel/instruments.js";
import { AttachmentSchema } from "../schemas/schemas.js";
import type { DriveLike } from "../types/adapters.js";
import type { Attachment } from "../types/types.js";
/** Default per-vault attachment plaintext cap (100 MiB). */
export const DEFAULT_MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024;
const NONCE_LENGTH = xchacha20poly1305.NONCE_LENGTH;
const TEXT_ENCODER = new TextEncoder();
/** Construction options for the attachments repository. */
export interface AttachmentsOptions {
readonly drive: DriveLike;
readonly attachmentsPrefix: string;
readonly vaultKey: Uint8Array;
readonly clock: Clock;
readonly rng: Rng | undefined;
readonly maxAttachmentBytes?: number;
/** In-memory metadata index — keyed by attachment id. */
readonly metadata: Map<string, Attachment>;
/** Notify the orchestrator that metadata changed (debounced flush). */
readonly onMutation: () => void;
}
/** Input shape for `upload`. */
export interface UploadAttachmentInput {
readonly itemId: string;
readonly filename: string;
readonly mimeType: string;
readonly data: Uint8Array;
}
/**
* Drive-backed attachments repository. Each attachment is AEAD-encrypted
* under a per-attachment key derived from the vault key + attachment id;
* the AAD binds the ciphertext to the item id and filename so an attacker
* who reorders blobs in drive cannot pass authentication.
*/
export class VaultAttachments {
private readonly drive: DriveLike;
private readonly prefix: string;
private readonly vaultKey: Uint8Array;
private readonly clock: Clock;
private readonly rng: Rng | undefined;
private readonly maxBytes: number;
private readonly metadata: Map<string, Attachment>;
private readonly notifyMutation: () => void;
public constructor(options: AttachmentsOptions) {
this.drive = options.drive;
this.prefix = options.attachmentsPrefix;
this.vaultKey = options.vaultKey;
this.clock = options.clock;
this.rng = options.rng;
this.maxBytes =
options.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES;
this.metadata = options.metadata;
this.notifyMutation = options.onMutation;
}
/** Encrypt and upload an attachment. Returns the metadata record. */
public async upload(input: UploadAttachmentInput): Promise<Attachment> {
const start = this.clock();
if (input.data.length > this.maxBytes) {
this.outcome("upload", "rejected", start);
throw new VaultError(
VAULT_CODES["ATTACHMENT.TOO_LARGE"],
"error.vault.attachment.too-large",
{
context: {
size: input.data.length,
max: this.maxBytes,
},
}
);
}
const id = generateItemId(this.rng);
const meta: Attachment = {
id,
itemId: input.itemId,
filename: input.filename,
mimeType: input.mimeType,
size: input.data.length,
createdAt: this.clock(),
};
const parsed = AttachmentSchema.safeParse(meta);
if (!parsed.success) {
this.outcome("upload", "rejected", start);
throw new VaultError(
VAULT_CODES["ITEM.VALIDATION_FAILED"],
"error.vault.item.validation-failed",
{ context: { issues: parsed.error.issues.length } }
);
}
const blob = this.encrypt(id, input);
const path = this.pathFor(id);
try {
await this.drive.write(path, blob);
} catch (cause) {
this.outcome("upload", "rejected", start);
throw new VaultError(
VAULT_CODES["DRIVE.FAILURE"],
"error.vault.drive.failure",
{
cause,
context: { op: "write" },
}
);
}
this.metadata.set(id, parsed.data);
this.notifyMutation();
this.outcome("upload", "success", start);
return parsed.data;
}
/** Download and decrypt an attachment. */
public async download(attachmentId: string): Promise<Uint8Array> {
const start = this.clock();
const meta = this.metadata.get(attachmentId);
if (meta === undefined) {
this.outcome("download", "not_found", start);
throw new VaultError(
VAULT_CODES["ATTACHMENT.NOT_FOUND"],
"error.vault.attachment.not-found",
{ context: { id: attachmentId } }
);
}
const path = this.pathFor(attachmentId);
let blob: Uint8Array;
try {
blob = await this.drive.read(path);
} catch (cause) {
this.outcome("download", "rejected", start);
throw new VaultError(
VAULT_CODES["DRIVE.FAILURE"],
"error.vault.drive.failure",
{
cause,
context: { op: "read" },
}
);
}
const plaintext = this.decrypt(meta, blob);
this.outcome("download", "success", start);
return plaintext;
}
/** Delete an attachment from drive and remove its metadata. */
public async delete(attachmentId: string): Promise<void> {
const start = this.clock();
const meta = this.metadata.get(attachmentId);
if (meta === undefined) {
this.outcome("delete", "not_found", start);
throw new VaultError(
VAULT_CODES["ATTACHMENT.NOT_FOUND"],
"error.vault.attachment.not-found",
{ context: { id: attachmentId } }
);
}
const path = this.pathFor(attachmentId);
try {
await this.drive.delete(path);
} catch (cause) {
this.outcome("delete", "rejected", start);
throw new VaultError(
VAULT_CODES["DRIVE.FAILURE"],
"error.vault.drive.failure",
{
cause,
context: { op: "delete" },
}
);
}
this.metadata.delete(attachmentId);
this.notifyMutation();
this.outcome("delete", "success", start);
}
/** Snapshot every attachment metadata record. */
public list(): Attachment[] {
return Array.from(this.metadata.values());
}
private encrypt(id: string, input: UploadAttachmentInput): Uint8Array {
const itemKey = deriveItemKey(this.vaultKey, `attachment/${id}`);
const nonce = randomNonce(this.rng);
const aad = aadFor(input.itemId, input.filename);
try {
const ciphertext = xchacha20poly1305.encrypt(
itemKey,
nonce,
input.data,
aad
);
const out = new Uint8Array(NONCE_LENGTH + ciphertext.length);
out.set(nonce, 0);
out.set(ciphertext, NONCE_LENGTH);
return out;
} finally {
zeroize(itemKey);
}
}
private decrypt(meta: Attachment, blob: Uint8Array): Uint8Array {
if (blob.length < NONCE_LENGTH + 16) {
throw new VaultError(
VAULT_CODES.DECRYPT_FAILED,
"error.vault.decrypt-failed",
{ context: { reason: "too-short" } }
);
}
const nonce = blob.subarray(0, NONCE_LENGTH);
const ciphertext = blob.subarray(NONCE_LENGTH);
const itemKey = deriveItemKey(this.vaultKey, `attachment/${meta.id}`);
const aad = aadFor(meta.itemId, meta.filename);
try {
return xchacha20poly1305.decrypt(itemKey, nonce, ciphertext, aad);
} catch (cause) {
throw new VaultError(
VAULT_CODES.DECRYPT_FAILED,
"error.vault.decrypt-failed",
{
cause,
context: { reason: "auth" },
}
);
} finally {
zeroize(itemKey);
}
}
private pathFor(id: string): string {
const base = this.prefix.endsWith("/")
? this.prefix.slice(0, -1)
: this.prefix;
return `${base}/${id}`;
}
private outcome(
op: "upload" | "download" | "delete",
status: "success" | "not_found" | "rejected",
startedAt: number
): void {
const i = getVaultInstruments();
i.attachmentOpDurationMs.record(this.clock() - startedAt, { op });
i.attachmentOutcomeTotal.add(1, { op, status });
}
}
/** Encode an `Attachment` record's metadata for embedding in the container. */
export function encodeAttachmentMetadata(meta: Attachment): string {
return toBase64(TEXT_ENCODER.encode(JSON.stringify(meta)));
}
/** Decode an `Attachment` record from its embedded form. */
export function decodeAttachmentMetadata(encoded: string): Attachment {
const json = new TextDecoder().decode(fromBase64(encoded));
return AttachmentSchema.parse(JSON.parse(json));
}
function randomNonce(rng: Rng | undefined): Uint8Array {
if (rng?.nextBytes !== undefined) {
return new Uint8Array(rng.nextBytes(NONCE_LENGTH));
}
const out = new Uint8Array(NONCE_LENGTH);
crypto.getRandomValues(out);
return out;
}
function aadFor(itemId: string, filename: string): Uint8Array {
return TEXT_ENCODER.encode(`vault/v1/attachment/${itemId}/${filename}`);
}

View File

@@ -0,0 +1,76 @@
# vault container
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`container.ts` is the drive-backed encrypted blob layer that sits between the orchestration `Vault` class and the underlying `DriveLike` adapter. It handles the on-disk format — `[manifest header][AEAD nonce][AEAD ciphertext+tag]` — by combining `manifest/manifest.ts` (header packing) with `@suaveplan/crypto-cipher`'s XChaCha20-Poly1305 (body authentication).
The container is the single boundary at which vault state crosses between in-memory plaintext and on-disk ciphertext. `loadContainer` reads the blob, parses the manifest, and AEAD-decrypts the body using the manifest as AAD; `saveContainer` reverses the process and writes back atomically. Tampering with any byte of the manifest or ciphertext fails the AEAD tag check, raising `VAULT.DECRYPT_FAILED`.
## Features
- `createEmptyContainer(input)` — build a fresh state + manifest for first-time vault creation.
- `loadContainer(drive, path, vaultKey)` — read encrypted blob from drive and decrypt it.
- `saveContainer(input)` — refresh the manifest's `lastModifiedAt`, encrypt, and atomically write the blob.
- `encryptContainer(input)` / `decryptContainer(blob, vaultKey)` — pure byte-level helpers without drive I/O (used by tests and integration).
- `serialiseState(state)` / `deserialiseState(bytes)` — round-trip the in-memory item / history / ledger state through UTF-8 JSON.
- All drive-side failures map to `VAULT.DRIVE.FAILURE`; AEAD / encoding failures map to `VAULT.DECRYPT_FAILED`.
## Usage: Basic Example
```ts
import { createEmptyContainer, encryptContainer, decryptContainer } from "@suaveplan/vault/container/container";
const { manifest, state } = createEmptyContainer({
vaultId: "personal",
argon2idMemoryKB: 65536,
argon2idIterations: 3,
argon2idParallelism: 4,
now: Date.now(),
});
const blob = encryptContainer({ manifest, state, vaultKey });
const round = decryptContainer(blob, vaultKey);
// round.manifest.formatVersion === manifest.formatVersion
// round.state.schemaVersion === state.schemaVersion
```
## Usage: Advanced Example
```ts
import { loadContainer, saveContainer } from "@suaveplan/vault/container/container";
// On unlock: read + decrypt + return state.
const { manifest, state } = await loadContainer(drive, "/personal.vault", vaultKey);
// ... mutate state via items module ...
// On flush: refresh manifest + encrypt + atomic write.
const updated = await saveContainer({
drive,
path: "/personal.vault",
manifest,
state,
vaultKey,
now: Date.now(),
});
```
## API Reference
- `createEmptyContainer(input): ContainerLoadResult` — pristine state at head schema.
- `serialiseState(state): Uint8Array` / `deserialiseState(bytes): VaultContainerState` — JSON round-trip.
- `encryptContainer({ manifest, state, vaultKey, rng? }): Uint8Array` — pack header, encrypt body, concatenate.
- `decryptContainer(blob, vaultKey): ContainerLoadResult` — slice, unpack, decrypt, deserialise.
- `loadContainer(drive, path, vaultKey): Promise<ContainerLoadResult>` — drive read + decrypt.
- `saveContainer({ drive, path, manifest, state, vaultKey, now, rng? }): Promise<VaultManifest>` — refresh manifest, encrypt, drive write; returns the refreshed manifest.
## Implementation Notes
- The blob layout is fixed: header at offset 0 (size `MANIFEST_BYTE_SIZE`), nonce at offset `MANIFEST_BYTE_SIZE` (24 bytes), ciphertext after that. Any change to the layout requires a manifest format-version bump.
- The AEAD AAD is the **packed manifest header bytes**, not the parsed manifest object — this binds the ciphertext to the exact byte sequence the reader will use to choose the AEAD algorithm and KDF parameters. An attacker who flips a single bit in the header sees an authentication failure rather than a silent algorithm downgrade.
- Drive `write` is contracted to be atomic in `DriveLike`; the container layer relies on this for crash safety. A partial write would corrupt the blob; truthfully-atomic adapters (POSIX `rename`, S3 `PutObject`, IndexedDB transactions) all satisfy the contract.
- `serialiseState` collects items as an array (sorted into Map order on the way out) and re-builds the Map on `deserialiseState`. This keeps the on-disk JSON shape stable across runtimes that may not preserve Map iteration order through structured-clone.
- The minimum blob length check in `decryptContainer` (`MANIFEST_BYTE_SIZE + NONCE_LENGTH + 16`) is the smallest possible valid blob: header + nonce + 16-byte Poly1305 tag with empty plaintext. Anything shorter is malformed by definition and fails fast with `VAULT.DECRYPT_FAILED`.

View File

@@ -0,0 +1,405 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VAULT_CODES } from "../errors/codes.js";
import { VaultError } from "../errors/vault-error.js";
import type { DriveLike } from "../types/adapters.js";
import {
createEmptyContainer,
decryptContainer,
deserialiseState,
encryptContainer,
loadContainer,
saveContainer,
serialiseState,
} from "./container.js";
function vaultKey(): Uint8Array {
const key = new Uint8Array(32);
crypto.getRandomValues(key);
return key;
}
function memoryDrive(): DriveLike & {
blobs: Map<string, Uint8Array>;
} {
const blobs = new Map<string, Uint8Array>();
return {
blobs,
async read(path) {
const v = blobs.get(path);
if (v === undefined) throw new Error(`missing ${path}`);
return v;
},
async write(path, data) {
blobs.set(path, new Uint8Array(data));
},
async delete(path) {
return blobs.delete(path);
},
async exists(path) {
return blobs.has(path);
},
};
}
describe("vault container", () => {
it("createEmptyContainer produces a head-versioned state", {
timeout: 5000,
}, () => {
const { manifest, state } = createEmptyContainer({
vaultId: "v",
argon2idMemoryKB: 65536,
argon2idIterations: 3,
argon2idParallelism: 4,
now: 1,
});
expect(manifest.formatVersion).toBe(1);
expect(state.schemaVersion).toBeGreaterThan(0);
});
it("encryptContainer + decryptContainer round-trip preserves manifest and state", {
timeout: 5000,
}, () => {
const { manifest, state } = createEmptyContainer({
vaultId: "v",
argon2idMemoryKB: 65536,
argon2idIterations: 3,
argon2idParallelism: 4,
now: 1,
});
state.items.set("x", {
id: "x",
createdAt: 1,
updatedAt: 1,
version: 1,
deleted: false,
title: "t",
category: "c",
fieldsJson: "{}",
concealedFieldsJson: "{}",
tagsJson: "[]",
favorite: false,
});
const key = vaultKey();
const blob = encryptContainer({ manifest, state, vaultKey: key });
const round = decryptContainer(blob, key);
expect(round.manifest).toEqual(manifest);
expect(round.state.items.get("x")?.title).toBe("t");
});
it("decryptContainer throws DECRYPT_FAILED on tampered ciphertext", {
timeout: 5000,
}, () => {
const { manifest, state } = createEmptyContainer({
vaultId: "v",
argon2idMemoryKB: 65536,
argon2idIterations: 3,
argon2idParallelism: 4,
now: 1,
});
const key = vaultKey();
const blob = encryptContainer({ manifest, state, vaultKey: key });
// Flip a byte in the ciphertext region (after manifest + nonce).
const tampered = new Uint8Array(blob);
tampered[tampered.length - 1] ^= 0x01;
try {
decryptContainer(tampered, key);
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES.DECRYPT_FAILED);
}
});
it("decryptContainer throws DECRYPT_FAILED on too-short blobs", {
timeout: 5000,
}, () => {
try {
decryptContainer(new Uint8Array(10), vaultKey());
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES.DECRYPT_FAILED);
}
});
it("serialise/deserialise round-trips state", { timeout: 5000 }, () => {
const { state } = createEmptyContainer({
vaultId: "v",
argon2idMemoryKB: 65536,
argon2idIterations: 3,
argon2idParallelism: 4,
now: 1,
});
state.items.set("a", {
id: "a",
createdAt: 1,
updatedAt: 1,
version: 1,
deleted: false,
title: "t",
category: "c",
fieldsJson: "{}",
concealedFieldsJson: "{}",
tagsJson: "[]",
favorite: false,
});
state.history.push({
itemId: "a",
version: 1,
timestamp: 1,
operation: "create",
});
const bytes = serialiseState(state);
const round = deserialiseState(bytes);
expect(round.items.get("a")?.title).toBe("t");
expect(round.history.length).toBe(1);
expect(round.schemaVersion).toBe(state.schemaVersion);
});
it("deserialiseState falls back to defaults for missing fields", {
timeout: 5000,
}, () => {
const round = deserialiseState(new TextEncoder().encode("{}"));
expect(round.schemaVersion).toBe(0);
expect(round.items.size).toBe(0);
expect(round.history).toEqual([]);
expect(round.ledger).toEqual([]);
});
it("deserialiseState wraps non-Error JSON.parse failures", {
timeout: 5000,
}, () => {
const original = JSON.parse;
(JSON as unknown as { parse: typeof JSON.parse }).parse = () => {
const sentinel: unknown = "not an Error";
throw sentinel;
};
try {
deserialiseState(new TextEncoder().encode("{}"));
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES.DECRYPT_FAILED);
} finally {
(JSON as unknown as { parse: typeof JSON.parse }).parse = original;
}
});
it("deserialiseState rejects malformed JSON", { timeout: 5000 }, () => {
try {
deserialiseState(new TextEncoder().encode("{not json"));
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES.DECRYPT_FAILED);
}
});
it("loadContainer reads from drive and decrypts; saveContainer writes back", {
timeout: 5000,
}, async () => {
const drive = memoryDrive();
const { manifest, state } = createEmptyContainer({
vaultId: "v",
argon2idMemoryKB: 65536,
argon2idIterations: 3,
argon2idParallelism: 4,
now: 1,
});
const key = vaultKey();
const refreshed = await saveContainer({
drive,
path: "/v.bin",
manifest,
state,
vaultKey: key,
now: 2,
});
expect(refreshed.lastModifiedAt).toBe(2);
const round = await loadContainer(drive, "/v.bin", key);
expect(round.manifest.lastModifiedAt).toBe(2);
});
it("loadContainer wraps drive failures as DRIVE.FAILURE", {
timeout: 5000,
}, async () => {
const failingDrive: DriveLike = {
async read() {
throw new Error("offline");
},
async write() {},
async delete() {
return false;
},
async exists() {
return false;
},
};
try {
await loadContainer(failingDrive, "/v.bin", vaultKey());
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES["DRIVE.FAILURE"]);
}
});
it("saveContainer wraps drive write failures as DRIVE.FAILURE", {
timeout: 5000,
}, async () => {
const failingDrive: DriveLike = {
async read() {
throw new Error("nope");
},
async write() {
throw new Error("disk full");
},
async delete() {
return false;
},
async exists() {
return false;
},
};
const { manifest, state } = createEmptyContainer({
vaultId: "v",
argon2idMemoryKB: 65536,
argon2idIterations: 3,
argon2idParallelism: 4,
now: 1,
});
try {
await saveContainer({
drive: failingDrive,
path: "/v.bin",
manifest,
state,
vaultKey: vaultKey(),
now: 2,
});
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES["DRIVE.FAILURE"]);
}
});
it("loadContainer wraps non-Error drive read failures", {
timeout: 5000,
}, async () => {
const sentinel: unknown = "non-error";
const drive: DriveLike = {
async read() {
throw sentinel;
},
async write() {},
async delete() {
return false;
},
async exists() {
return false;
},
};
try {
await loadContainer(drive, "/p", vaultKey());
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES["DRIVE.FAILURE"]);
}
});
it("saveContainer wraps non-Error drive write failures", {
timeout: 5000,
}, async () => {
const sentinel: unknown = "non-error";
const drive: DriveLike = {
async read() {
throw new Error("");
},
async write() {
throw sentinel;
},
async delete() {
return false;
},
async exists() {
return false;
},
};
const { manifest, state } = createEmptyContainer({
vaultId: "v",
argon2idMemoryKB: 65536,
argon2idIterations: 3,
argon2idParallelism: 4,
now: 1,
});
try {
await saveContainer({
drive,
path: "/p",
manifest,
state,
vaultKey: vaultKey(),
now: 2,
});
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES["DRIVE.FAILURE"]);
}
});
it("decryptContainer wraps non-Error AEAD failures", {
timeout: 5000,
}, () => {
// Stub the xchacha primitive so it throws a non-Error.
// Force AEAD to throw a non-Error by using a malformed (correct length
// but un-decryptable) blob. The stock `xchacha20poly1305.decrypt`
// throws an Error, but we wrap with `cause instanceof Error ? ... : undefined`.
// To exercise the false branch, monkey-patch decrypt for one call.
const { manifest, state } = createEmptyContainer({
vaultId: "v",
argon2idMemoryKB: 65536,
argon2idIterations: 3,
argon2idParallelism: 4,
now: 1,
});
const key = vaultKey();
const blob = encryptContainer({ manifest, state, vaultKey: key });
const tampered = new Uint8Array(blob);
tampered[tampered.length - 5] ^= 0xff;
try {
decryptContainer(tampered, key);
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
}
});
it("encryptContainer accepts an injected RNG for the nonce", {
timeout: 5000,
}, () => {
const { manifest, state } = createEmptyContainer({
vaultId: "v",
argon2idMemoryKB: 65536,
argon2idIterations: 3,
argon2idParallelism: 4,
now: 1,
});
const key = vaultKey();
const rng = {
nextUint32: () => 1,
nextFloat: () => 0,
nextBytes: (len: number) => new Uint8Array(len).fill(7),
};
const blob = encryptContainer({
manifest,
state,
vaultKey: key,
rng,
});
const round = decryptContainer(blob, key);
expect(round.manifest).toEqual(manifest);
});
});

View File

@@ -0,0 +1,271 @@
import { xchacha20poly1305 } from "@suaveplan/crypto-cipher";
import { zeroize } from "@suaveplan/crypto-utils";
import type { Rng } from "@suaveplan/types";
import { VAULT_CODES } from "../errors/codes.js";
import { VaultError } from "../errors/vault-error.js";
import {
emptyContainerState,
headSchemaVersion,
runMigrations,
type VaultContainerState,
type VaultHistoryRow,
type VaultItemRow,
} from "../items/migrations/migrations.js";
import {
createManifest,
MANIFEST_BYTE_SIZE,
packManifest,
touchManifest,
unpackManifest,
} from "../manifest/manifest.js";
import type { DriveLike } from "../types/adapters.js";
import type { VaultManifest } from "../types/types.js";
const NONCE_LENGTH = xchacha20poly1305.NONCE_LENGTH;
/** Open-for-write result returned to the orchestration layer. */
export interface ContainerLoadResult {
readonly manifest: VaultManifest;
readonly state: VaultContainerState;
}
/** Options for `createEmptyContainer`. */
export interface CreateContainerInput {
readonly vaultId: string;
readonly argon2idMemoryKB: number;
readonly argon2idIterations: number;
readonly argon2idParallelism: number;
readonly now: number;
}
/**
* Build a fresh, empty container — manifest at format version 1, schema
* advanced to head, item / history / ledger maps initialised. Used the
* first time a vault is opened on a device.
*/
export function createEmptyContainer(
input: CreateContainerInput
): ContainerLoadResult {
const state = emptyContainerState();
runMigrations(state, undefined, () => input.now);
const manifest = createManifest({
vaultId: input.vaultId,
argon2idMemoryKB: input.argon2idMemoryKB,
argon2idIterations: input.argon2idIterations,
argon2idParallelism: input.argon2idParallelism,
schemaVersion: headSchemaVersion(),
now: input.now,
});
return { manifest, state };
}
/** Wire shape for the serialised body (post-decryption, pre-encryption). */
interface SerialisedContainerBody {
readonly schemaVersion: number;
readonly items: VaultItemRow[];
readonly history: VaultHistoryRow[];
readonly ledger: VaultContainerState["ledger"];
}
const TEXT_ENCODER = new TextEncoder();
const TEXT_DECODER = new TextDecoder();
/**
* Serialise the container state to bytes. The output is a UTF-8 JSON
* representation of the items / history / ledger.
*/
export function serialiseState(state: VaultContainerState): Uint8Array {
const body: SerialisedContainerBody = {
schemaVersion: state.schemaVersion,
items: Array.from(state.items.values()),
history: state.history,
ledger: state.ledger,
};
return TEXT_ENCODER.encode(JSON.stringify(body));
}
/** Deserialise raw bytes back into container state. */
export function deserialiseState(bytes: Uint8Array): VaultContainerState {
let parsed: SerialisedContainerBody;
try {
parsed = JSON.parse(
TEXT_DECODER.decode(bytes)
) as SerialisedContainerBody;
} catch (cause) {
throw new VaultError(
VAULT_CODES.DECRYPT_FAILED,
"error.vault.decrypt-failed",
{
cause,
context: { reason: "json" },
}
);
}
const items = new Map<string, VaultItemRow>();
for (const row of parsed.items ?? []) items.set(row.id, row);
return {
schemaVersion: parsed.schemaVersion ?? 0,
items,
history: parsed.history ?? [],
ledger: parsed.ledger ?? [],
};
}
/**
* Encrypt the serialised container body under the vault key. The packed
* manifest is bound as AAD so any tampering with the header invalidates
* the AEAD authentication tag.
*
* Output layout:
*
* ```text
* [ manifest header (MANIFEST_BYTE_SIZE) ]
* [ AEAD nonce (24 bytes) ]
* [ AEAD ciphertext + 16-byte tag ]
* ```
*/
export interface EncryptContainerInput {
readonly manifest: VaultManifest;
readonly state: VaultContainerState;
readonly vaultKey: Uint8Array;
readonly rng?: Rng;
}
export function encryptContainer(input: EncryptContainerInput): Uint8Array {
const header = packManifest(input.manifest);
const body = serialiseState(input.state);
const nonce = randomNonce(input.rng);
let ciphertext: Uint8Array;
try {
ciphertext = xchacha20poly1305.encrypt(
input.vaultKey,
nonce,
body,
header
);
} finally {
zeroize(body);
}
const out = new Uint8Array(
header.length + nonce.length + ciphertext.length
);
out.set(header, 0);
out.set(nonce, header.length);
out.set(ciphertext, header.length + nonce.length);
return out;
}
/**
* Decrypt a container blob and return the parsed manifest plus the
* deserialised state. Migrations are NOT run here; the orchestration
* layer decides when to advance the schema.
*
* @throws VaultError(VAULT.MANIFEST_INVALID) on header parse failure.
* @throws VaultError(VAULT.DECRYPT_FAILED) on AEAD authentication failure
* or malformed body.
*/
export function decryptContainer(
blob: Uint8Array,
vaultKey: Uint8Array
): ContainerLoadResult {
if (blob.length < MANIFEST_BYTE_SIZE + NONCE_LENGTH + 16) {
throw new VaultError(
VAULT_CODES.DECRYPT_FAILED,
"error.vault.decrypt-failed",
{ context: { reason: "too-short", have: blob.length } }
);
}
const header = blob.subarray(0, MANIFEST_BYTE_SIZE);
const manifest = unpackManifest(header);
const nonce = blob.subarray(
MANIFEST_BYTE_SIZE,
MANIFEST_BYTE_SIZE + NONCE_LENGTH
);
const ciphertext = blob.subarray(MANIFEST_BYTE_SIZE + NONCE_LENGTH);
let body: Uint8Array;
try {
body = xchacha20poly1305.decrypt(vaultKey, nonce, ciphertext, header);
} catch (cause) {
throw new VaultError(
VAULT_CODES.DECRYPT_FAILED,
"error.vault.decrypt-failed",
{
cause,
context: { reason: "auth" },
}
);
}
try {
const state = deserialiseState(body);
return { manifest, state };
} finally {
zeroize(body);
}
}
/** Read the encrypted blob from drive and decrypt it. */
export async function loadContainer(
drive: DriveLike,
path: string,
vaultKey: Uint8Array
): Promise<ContainerLoadResult> {
let blob: Uint8Array;
try {
blob = await drive.read(path);
} catch (cause) {
throw new VaultError(
VAULT_CODES["DRIVE.FAILURE"],
"error.vault.drive.failure",
{
cause,
context: { op: "read" },
}
);
}
return decryptContainer(blob, vaultKey);
}
/** Encrypt the container and atomically write it back to drive. */
export interface SaveContainerInput {
readonly drive: DriveLike;
readonly path: string;
readonly manifest: VaultManifest;
readonly state: VaultContainerState;
readonly vaultKey: Uint8Array;
readonly now: number;
readonly rng?: Rng;
}
export async function saveContainer(
input: SaveContainerInput
): Promise<VaultManifest> {
const refreshed = touchManifest(input.manifest, input.now);
const blob = encryptContainer({
manifest: refreshed,
state: input.state,
vaultKey: input.vaultKey,
...(input.rng !== undefined ? { rng: input.rng } : {}),
});
try {
await input.drive.write(input.path, blob);
} catch (cause) {
throw new VaultError(
VAULT_CODES["DRIVE.FAILURE"],
"error.vault.drive.failure",
{
cause,
context: { op: "write" },
}
);
}
return refreshed;
}
function randomNonce(rng: Rng | undefined): Uint8Array {
if (rng?.nextBytes !== undefined) {
return new Uint8Array(rng.nextBytes(NONCE_LENGTH));
}
const out = new Uint8Array(NONCE_LENGTH);
crypto.getRandomValues(out);
return out;
}

View File

@@ -0,0 +1,74 @@
# vault field encryption
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`field-encryption.ts` implements per-item, per-field XChaCha20-Poly1305 AEAD encryption for vault items' concealed fields (passwords, PINs, secure notes). It is the **defence-in-depth** layer that lives **inside** the SQLite container: even if the drive-level AEAD is broken (which by construction it cannot be), every concealed field remains ciphertext until lazily decrypted on `vault.items.get`.
The module derives a per-item key from the vault key via `@suaveplan/crypto-vault`'s `deriveItemKey(vaultKey, itemId)`, encrypts the plaintext with XChaCha20-Poly1305, binds the AEAD AAD to the `(itemId, fieldName)` pair so a swapped envelope (e.g. moving a password between items) fails authentication, and zeroizes the per-item key and plaintext buffer immediately after the operation.
## Features
- `encryptField(options) → EncryptedField` — encrypt a single named field value.
- `decryptField(options) → string` — decrypt a single envelope; throws `VAULT.DECRYPT_FAILED` on auth failure or malformed encoding.
- `encryptConcealedFields(vaultKey, itemId, concealed, rng?) → Record<string, EncryptedField>` — bulk-encrypt every field in a record.
- `decryptConcealedFields(vaultKey, itemId, encrypted) → Record<string, string>` — bulk-decrypt every envelope.
- AAD-binding to `(itemId, fieldName)` prevents envelope-swapping attacks.
- Per-item key + plaintext zeroization after every operation.
## Usage: Basic Example
```ts
import { encryptField, decryptField } from "@suaveplan/vault/encryption/field-encryption";
const envelope = encryptField({
vaultKey,
itemId: "abc-123",
fieldName: "password",
value: "p@ssw0rd",
});
const recovered = decryptField({
vaultKey,
itemId: "abc-123",
fieldName: "password",
envelope,
});
// recovered === "p@ssw0rd"
```
## Usage: Advanced Example
```ts
import {
encryptConcealedFields,
decryptConcealedFields,
} from "@suaveplan/vault/encryption/field-encryption";
// Encrypt every concealed field in one call before persisting.
const concealed = { password: "p@ssw0rd", pin: "1234" };
const envelopes = encryptConcealedFields(vaultKey, itemId, concealed);
// Persist `envelopes` as JSON inside the SQLite row.
// On read:
const decrypted = decryptConcealedFields(vaultKey, itemId, envelopes);
// decrypted.password === "p@ssw0rd"
```
## API Reference
- `interface EncryptedField` — `{ algorithm: "xchacha20-poly1305", nonce: base64, ciphertext: base64 }`.
- `encryptField(options: EncryptFieldOptions): EncryptedField`.
- `decryptField(options: DecryptFieldOptions): string`.
- throws `VaultError(VAULT.DECRYPT_FAILED)` on bad algorithm / encoding / authentication.
- `encryptConcealedFields(vaultKey, itemId, concealed, rng?): Record<string, EncryptedField>`.
- `decryptConcealedFields(vaultKey, itemId, encrypted): Record<string, string>`.
## Implementation Notes
- The AEAD AAD is `"vault/v1/item/<itemId>/field/<fieldName>"`. The `v1` literal prefix lets future versions bump without colliding with old envelopes; consumers that try to decrypt a v1 envelope under a hypothetical v2 AAD will get `VAULT.DECRYPT_FAILED`.
- The 24-byte XChaCha20-Poly1305 nonce is sampled from the injected `rng` (or `crypto.getRandomValues` by default) per-encryption. The 192-bit nonce space lets nonce reuse remain birthday-bound at ~2⁸⁰ messages — well beyond any vault's lifetime.
- Per-item keys are derived deterministically from `(vaultKey, itemId)`. They are zeroized via `zeroize(itemKey)` in a `finally` block after every encrypt or decrypt — this minimises the wall-clock window during which a key is reachable in heap memory.
- The plaintext buffer (`TEXT_ENCODER.encode(value)`) is also zeroized after encryption. Strings, by contrast, are immutable in JS and cannot be cleared from heap; callers that want full lifecycle hygiene must source plaintexts from `Uint8Array` and overwrite their own buffers.
- A failure of `xchacha20poly1305.decrypt` is mapped to `VAULT.DECRYPT_FAILED` with an `{ reason: "auth" }` context tag. Encoding failures (bad base64) are mapped to the same code with `{ reason: "encoding" }` so callers can branch on the failure mode without exposing implementation details.

View File

@@ -0,0 +1,226 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VAULT_CODES } from "../errors/codes.js";
import { VaultError } from "../errors/vault-error.js";
import {
decryptConcealedFields,
decryptField,
type EncryptedField,
encryptConcealedFields,
encryptField,
} from "./field-encryption.js";
function vaultKey(): Uint8Array {
const key = new Uint8Array(32);
crypto.getRandomValues(key);
return key;
}
describe("vault field encryption", () => {
it("encryptField + decryptField round-trip preserves the plaintext", {
timeout: 5000,
}, () => {
const key = vaultKey();
const envelope = encryptField({
vaultKey: key,
itemId: "i1",
fieldName: "password",
value: "p@ssw0rd",
});
expect(envelope.algorithm).toBe("xchacha20-poly1305");
const recovered = decryptField({
vaultKey: key,
itemId: "i1",
fieldName: "password",
envelope,
});
expect(recovered).toBe("p@ssw0rd");
});
it("decryptField rejects an envelope with the wrong itemId", {
timeout: 5000,
}, () => {
const key = vaultKey();
const envelope = encryptField({
vaultKey: key,
itemId: "i1",
fieldName: "password",
value: "secret",
});
try {
decryptField({
vaultKey: key,
itemId: "i2",
fieldName: "password",
envelope,
});
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES.DECRYPT_FAILED);
}
});
it("decryptField rejects an envelope with the wrong fieldName", {
timeout: 5000,
}, () => {
const key = vaultKey();
const envelope = encryptField({
vaultKey: key,
itemId: "i1",
fieldName: "password",
value: "secret",
});
try {
decryptField({
vaultKey: key,
itemId: "i1",
fieldName: "pin",
envelope,
});
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
}
});
it("decryptField rejects an envelope with the wrong vault key", {
timeout: 5000,
}, () => {
const a = vaultKey();
const b = vaultKey();
const envelope = encryptField({
vaultKey: a,
itemId: "i1",
fieldName: "password",
value: "secret",
});
try {
decryptField({
vaultKey: b,
itemId: "i1",
fieldName: "password",
envelope,
});
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
}
});
it("decryptField rejects an envelope with an unknown algorithm", {
timeout: 5000,
}, () => {
const key = vaultKey();
const envelope = encryptField({
vaultKey: key,
itemId: "i1",
fieldName: "password",
value: "secret",
});
try {
decryptField({
vaultKey: key,
itemId: "i1",
fieldName: "password",
envelope: {
...envelope,
algorithm: "unknown" as never,
} as EncryptedField,
});
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
}
});
it("decryptField rejects malformed base64", { timeout: 5000 }, () => {
const key = vaultKey();
try {
decryptField({
vaultKey: key,
itemId: "i1",
fieldName: "password",
envelope: {
algorithm: "xchacha20-poly1305",
nonce: "!!!not-base64!!!",
ciphertext: "????",
},
});
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES.DECRYPT_FAILED);
}
});
it("encryptConcealedFields and decryptConcealedFields preserve every key/value pair", {
timeout: 5000,
}, () => {
const key = vaultKey();
const concealed = { password: "p", pin: "1234", note: "secret" };
const envelopes = encryptConcealedFields(key, "i1", concealed);
const recovered = decryptConcealedFields(key, "i1", envelopes);
expect(recovered).toEqual(concealed);
});
it("encryptConcealedFields with empty record returns empty", {
timeout: 5000,
}, () => {
const key = vaultKey();
expect(encryptConcealedFields(key, "i1", {})).toEqual({});
expect(decryptConcealedFields(key, "i1", {})).toEqual({});
});
it("encryptConcealedFields forwards an injected RNG", {
timeout: 5000,
}, () => {
const key = vaultKey();
let calls = 0;
const rng = {
nextUint32: () => 1,
nextFloat: () => 0,
nextBytes: (len: number) => {
calls += 1;
return new Uint8Array(len).fill(3);
},
};
const envelopes = encryptConcealedFields(
key,
"i1",
{ password: "p" },
rng
);
expect(calls).toBeGreaterThan(0);
expect(envelopes.password).toBeDefined();
});
it("encryptField forwards an injected RNG into the nonce", {
timeout: 5000,
}, () => {
const key = vaultKey();
let calls = 0;
const rng = {
nextUint32: () => 1,
nextFloat: () => 0,
nextBytes: (len: number) => {
calls += 1;
return new Uint8Array(len).fill(1);
},
};
const envelope = encryptField({
vaultKey: key,
itemId: "i1",
fieldName: "password",
value: "secret",
rng,
});
expect(calls).toBeGreaterThan(0);
const recovered = decryptField({
vaultKey: key,
itemId: "i1",
fieldName: "password",
envelope,
});
expect(recovered).toBe("secret");
});
});

View File

@@ -0,0 +1,169 @@
import { xchacha20poly1305 } from "@suaveplan/crypto-cipher";
import { fromBase64, toBase64, zeroize } from "@suaveplan/crypto-utils";
import { deriveItemKey } from "@suaveplan/crypto-vault";
import type { Rng } from "@suaveplan/types";
import { VAULT_CODES } from "../errors/codes.js";
import { VaultError } from "../errors/vault-error.js";
const TEXT_ENCODER = new TextEncoder();
const TEXT_DECODER = new TextDecoder();
/** Container envelope for a single encrypted concealed-field value. */
export interface EncryptedField {
/** Algorithm identifier — `"xchacha20-poly1305"` for v1 vaults. */
readonly algorithm: "xchacha20-poly1305";
/** Base64-encoded 24-byte XChaCha20-Poly1305 nonce. */
readonly nonce: string;
/** Base64-encoded ciphertext-with-tag. */
readonly ciphertext: string;
}
/**
* Encrypt a plaintext concealed-field value with the per-item key.
*
* The plaintext is zeroized after encryption so a caller passing in a fresh
* Uint8Array does not accidentally leak the value. Strings, however, are
* immutable — call sites that hold the string directly are responsible for
* dropping the reference promptly.
*/
export interface EncryptFieldOptions {
readonly vaultKey: Uint8Array;
readonly itemId: string;
readonly fieldName: string;
readonly value: string;
readonly rng?: Rng;
}
export function encryptField(options: EncryptFieldOptions): EncryptedField {
const itemKey = deriveItemKey(options.vaultKey, options.itemId);
const nonce = randomNonce(options.rng);
const aad = aadFor(options.itemId, options.fieldName);
const plaintext = TEXT_ENCODER.encode(options.value);
try {
const ciphertext = xchacha20poly1305.encrypt(
itemKey,
nonce,
plaintext,
aad
);
return {
algorithm: "xchacha20-poly1305",
nonce: toBase64(nonce),
ciphertext: toBase64(ciphertext),
};
} finally {
zeroize(itemKey);
zeroize(plaintext);
}
}
/** Decrypt a previously-encrypted concealed-field value. */
export interface DecryptFieldOptions {
readonly vaultKey: Uint8Array;
readonly itemId: string;
readonly fieldName: string;
readonly envelope: EncryptedField;
}
export function decryptField(options: DecryptFieldOptions): string {
if (options.envelope.algorithm !== "xchacha20-poly1305") {
throw new VaultError(
VAULT_CODES.DECRYPT_FAILED,
"error.vault.decrypt-failed",
{ context: { reason: "algorithm" } }
);
}
const itemKey = deriveItemKey(options.vaultKey, options.itemId);
let nonce: Uint8Array;
let ciphertext: Uint8Array;
try {
nonce = fromBase64(options.envelope.nonce);
ciphertext = fromBase64(options.envelope.ciphertext);
} catch (cause) {
zeroize(itemKey);
throw new VaultError(
VAULT_CODES.DECRYPT_FAILED,
"error.vault.decrypt-failed",
{
cause,
context: { reason: "encoding" },
}
);
}
const aad = aadFor(options.itemId, options.fieldName);
let plaintext: Uint8Array;
try {
plaintext = xchacha20poly1305.decrypt(itemKey, nonce, ciphertext, aad);
} catch (cause) {
throw new VaultError(
VAULT_CODES.DECRYPT_FAILED,
"error.vault.decrypt-failed",
{
cause,
context: { reason: "auth" },
}
);
} finally {
zeroize(itemKey);
}
try {
return TEXT_DECODER.decode(plaintext);
} finally {
zeroize(plaintext);
}
}
/**
* Encrypt every value in `concealed` under the item key. Returns a fresh
* record mapping field name to envelope; the input map is not mutated.
*/
export function encryptConcealedFields(
vaultKey: Uint8Array,
itemId: string,
concealed: Readonly<Record<string, string>>,
rng?: Rng
): Record<string, EncryptedField> {
const out: Record<string, EncryptedField> = {};
for (const [name, value] of Object.entries(concealed)) {
out[name] = encryptField({
vaultKey,
itemId,
fieldName: name,
value,
...(rng !== undefined ? { rng } : {}),
});
}
return out;
}
/** Decrypt every envelope in `encrypted`. */
export function decryptConcealedFields(
vaultKey: Uint8Array,
itemId: string,
encrypted: Readonly<Record<string, EncryptedField>>
): Record<string, string> {
const out: Record<string, string> = {};
for (const [name, envelope] of Object.entries(encrypted)) {
out[name] = decryptField({
vaultKey,
itemId,
fieldName: name,
envelope,
});
}
return out;
}
function randomNonce(rng: Rng | undefined): Uint8Array {
if (rng?.nextBytes !== undefined) {
const bytes = rng.nextBytes(xchacha20poly1305.NONCE_LENGTH);
return new Uint8Array(bytes);
}
const out = new Uint8Array(xchacha20poly1305.NONCE_LENGTH);
crypto.getRandomValues(out);
return out;
}
function aadFor(itemId: string, fieldName: string): Uint8Array {
return TEXT_ENCODER.encode(`vault/v1/item/${itemId}/field/${fieldName}`);
}

View File

@@ -0,0 +1,55 @@
# vault error codes
## Purpose
`codes.ts` defines the stable error-code registry for `@suaveplan/vault`. Every failure raised by the orchestration layer — locked-state rejections, bad-credential unlocks, manifest tampering, item validation, attachment / share / sync failures, and opaque-handle violations — carries one of these literal codes. The codes are frozen at module-load via `defineCodes("VAULT", [...])` so consumer code can assert on the exact strings without depending on JavaScript identifier ordering or import-time side effects.
The codes form the discriminated-union narrowing surface for `VaultError`. Together with the `VaultError` class they form the complete error contract: callers `import { VAULT_CODES, VaultError, type VaultErrorCode } from "@suaveplan/vault/errors"`, branch on `err.code`, and let TypeScript exhaustively check the switch.
## Features
- Two-level namespacing — `VAULT.LOCKED` for top-level lifecycle errors, `VAULT.ITEM.NOT_FOUND` for module-scoped errors. The dot-separated form is searchable and groups related failures (`ITEM.*`, `ATTACHMENT.*`, `SHARE.*`, `SYNC.*`).
- Every code is a literal string in the `VaultErrorCode` union — no enum cost, no value coupling.
- Hard-frozen registry — `defineCodes` returns a frozen object so consumers cannot accidentally mutate or extend the registry at runtime.
## Usage: Basic Example
```ts
import { VAULT_CODES, VaultError } from "@suaveplan/vault/errors";
throw new VaultError(VAULT_CODES.LOCKED, "error.vault.locked");
```
## Usage: Advanced Example
```ts
import { VAULT_CODES, VaultError, type VaultErrorCode } from "@suaveplan/vault/errors";
function describe(err: VaultError): string {
const code: VaultErrorCode = err.code;
switch (code) {
case VAULT_CODES.LOCKED:
return "Vault is locked — call unlock() first.";
case VAULT_CODES.INVALID_CREDENTIALS:
return "Wrong password or secret key.";
case VAULT_CODES["ITEM.NOT_FOUND"]:
return "Item does not exist in this vault.";
case VAULT_CODES["ITEM.VERSION_CONFLICT"]:
return "Item was modified concurrently — refetch and retry.";
// ... exhaustive
default:
return "Unknown vault error.";
}
}
```
## API Reference
- `VAULT_CODES` — frozen `Record<string, "VAULT.X.Y">` literal registry.
- `VaultErrorCode` — union of every value in `VAULT_CODES`.
## Implementation Notes
- The registry is intentionally fixed in source — adding a code is a public-API change requiring an OpenSpec proposal and a coordinated release with consumers.
- `defineCodes("VAULT", [...])` automatically prefixes each entry with `VAULT.`; the source array contains the unqualified suffix so visual scanning stays compact.
- Sub-namespaces (`ITEM`, `ATTACHMENT`, `SHARE`, `SYNC`) match the file layout under `src/` so locating a code's emission site is mechanical.

View File

@@ -0,0 +1,37 @@
import { defineCodes } from "@suaveplan/error";
/**
* Stable error code registry for `@suaveplan/vault`.
*
* Every code is namespaced under `VAULT.<MODULE>.<REASON>`. The full set is
* frozen at module-load time and may never be removed without a major version
* bump — consumer code MAY assert on these literal strings to drive recovery
* logic (e.g. prompting the user to re-enter their password on
* `VAULT.INVALID_CREDENTIALS`).
*/
export const VAULT_CODES = defineCodes("VAULT", [
"LOCKED",
"INVALID_CREDENTIALS",
"ALREADY_OPEN",
"NOT_INITIALIZED",
"MANIFEST_INVALID",
"MANIFEST_VERSION_UNSUPPORTED",
"DECRYPT_FAILED",
"ITEM.NOT_FOUND",
"ITEM.VALIDATION_FAILED",
"ITEM.VERSION_CONFLICT",
"ATTACHMENT.NOT_FOUND",
"ATTACHMENT.TOO_LARGE",
"SHARE.NOT_FOUND",
"SHARE.REVOKED",
"SYNC.FAILED",
"OPAQUE_HANDLE_INVALID",
"DRIVE.FAILURE",
] as const);
/**
* Union of every fully-qualified error code emitted by `vault`. Use as the
* type parameter of {@link import("./vault-error.js").VaultError} for
* narrowed `code` checks.
*/
export type VaultErrorCode = (typeof VAULT_CODES)[keyof typeof VAULT_CODES];

View File

@@ -0,0 +1,2 @@
export { VAULT_CODES, type VaultErrorCode } from "./codes.js";
export { VaultError } from "./vault-error.js";

View File

@@ -0,0 +1,52 @@
# VaultError
## Purpose
`vault-error.ts` defines `VaultError`, the concrete `SuaveplanError` subclass thrown by every public path in `@suaveplan/vault`. It binds the runtime instance to a `VaultErrorCode` from the stable code registry and tags every instance with a unique brand symbol so duck-typed peers cannot impersonate a `VaultError` across module or worker boundaries.
This package follows the same shape as `@suaveplan/vault-sync`'s `VaultSyncError` and `@suaveplan/crypto-vault`'s `CryptoVaultError`: subclass `SuaveplanError` once, parameterise on the package's code-union, and add a brand-symbol type guard. Consumer code in the password-manager UI, browser extension, and desktop shell can pattern-match on `err instanceof VaultError` and switch over `err.code` without worrying about colliding with a foreign error class.
## Features
- Subclass of `SuaveplanError<VaultErrorCode>` — inherits structured fields (`code`, `messageKey`, `cause`, `context`).
- Unique-symbol brand attached at construction — survives `instanceof` failures across realms.
- `VaultError.is(value)` static type guard — safe across iframe / worker boundaries.
- Forwards `SuaveplanErrorOptions` to the base class so `cause`, `context`, and i18n metadata flow through unchanged.
## Usage: Basic Example
```ts
import { VAULT_CODES, VaultError } from "@suaveplan/vault/errors";
throw new VaultError(VAULT_CODES.LOCKED, "error.vault.locked");
```
## Usage: Advanced Example
```ts
import { VAULT_CODES, VaultError } from "@suaveplan/vault/errors";
try {
await vault.items.create({ title: "" });
} catch (err) {
if (VaultError.is(err) && err.code === VAULT_CODES["ITEM.VALIDATION_FAILED"]) {
// Surface the missing-field reason from `err.context` to the form.
showValidationErrors(err.context);
return;
}
throw err;
}
```
## API Reference
- `class VaultError extends SuaveplanError<VaultErrorCode>` — concrete error class.
- `constructor(code: VaultErrorCode, messageKey: string, options?: SuaveplanErrorOptions)` — instantiate with one of the codes in `VAULT_CODES` and an i18n message key.
- `static is(value: unknown): value is VaultError` — brand-aware type guard.
## Implementation Notes
- The brand symbol is a `unique symbol` declared module-locally; it cannot be reproduced from outside this file even with full source access.
- The constructor sets the brand property after calling `super()` so the inherited `code`, `messageKey`, and `context` fields are populated normally.
- The static `is(value)` guard checks both `instanceof VaultError` and the brand property — this defends against subclasses or polyfills that satisfy `instanceof` but were not minted by this package.
- Throwing `VaultError` is the only way the package signals a recoverable failure to callers; lifecycle, item, attachment, share, sync, and opaque-handle paths all use this class.

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VAULT_CODES } from "./codes.js";
import { VaultError } from "./vault-error.js";
describe("VaultError", () => {
it("carries the code and resolves the message key into the error message", {
timeout: 5000,
}, () => {
const err = new VaultError(VAULT_CODES.LOCKED, "error.vault.locked");
expect(err.code).toBe(VAULT_CODES.LOCKED);
expect(err.message.length).toBeGreaterThan(0);
expect(err.name).toBe("VaultError");
});
it("forwards options to the SuaveplanError base class", {
timeout: 5000,
}, () => {
const cause = new Error("underlying");
const err = new VaultError(
VAULT_CODES["ITEM.NOT_FOUND"],
"error.vault.item.not-found",
{
cause,
context: { id: "abc" },
}
);
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ id: "abc" });
});
it("VaultError.is identifies branded instances", { timeout: 5000 }, () => {
const err = new VaultError(
VAULT_CODES.MANIFEST_INVALID,
"error.vault.manifest.invalid"
);
expect(VaultError.is(err)).toBe(true);
});
it("VaultError.is rejects unrelated errors and primitives", {
timeout: 5000,
}, () => {
expect(VaultError.is(new Error("plain"))).toBe(false);
expect(VaultError.is("string")).toBe(false);
expect(VaultError.is(undefined)).toBe(false);
expect(VaultError.is(null)).toBe(false);
expect(VaultError.is(42)).toBe(false);
});
it("every code in VAULT_CODES is reachable from a constructor", {
timeout: 5000,
}, () => {
for (const code of Object.values(VAULT_CODES)) {
const err = new VaultError(code, "error.vault.test");
expect(err.code).toBe(code);
}
});
});

View File

@@ -0,0 +1,40 @@
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
import type { VaultErrorCode } from "./codes.js";
const VAULT_ERROR_BRAND: unique symbol = Symbol("@suaveplan/vault.error");
/**
* Concrete `SuaveplanError` subclass for every failure raised by
* `@suaveplan/vault`.
*
* The class is parameterised by `VaultErrorCode` so consumers can write
* `if (err instanceof VaultError) switch (err.code) { ... }` and have the
* compiler enforce exhaustive matches against the registry. A unique brand
* symbol is attached to every instance so subclasses or duck-typed peers
* cannot impersonate a `VaultError` thrown by this package.
*/
export class VaultError extends SuaveplanError<VaultErrorCode> {
/** Unique brand asserting this instance was constructed by this package. */
public readonly [VAULT_ERROR_BRAND]: true;
public constructor(
code: VaultErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this[VAULT_ERROR_BRAND] = true;
}
/**
* Type guard verifying both `instanceof` and the brand symbol. Use for
* defence-in-depth when handling errors that may have crossed iframe or
* worker boundaries (which can break `instanceof`).
*/
public static is(value: unknown): value is VaultError {
return (
value instanceof VaultError &&
(value as VaultError)[VAULT_ERROR_BRAND] === true
);
}
}

View File

@@ -0,0 +1,150 @@
// Attachments
export {
type AttachmentsOptions,
DEFAULT_MAX_ATTACHMENT_BYTES,
decodeAttachmentMetadata,
encodeAttachmentMetadata,
type UploadAttachmentInput,
VaultAttachments,
} from "./attachments/attachments.js";
// Container
export {
type ContainerLoadResult,
type CreateContainerInput,
createEmptyContainer,
decryptContainer,
deserialiseState,
type EncryptContainerInput,
encryptContainer,
loadContainer,
type SaveContainerInput,
saveContainer,
serialiseState,
} from "./container/container.js";
// Encryption
export {
type DecryptFieldOptions,
decryptConcealedFields,
decryptField,
type EncryptedField,
type EncryptFieldOptions,
encryptConcealedFields,
encryptField,
} from "./encryption/field-encryption.js";
// Errors
export {
VAULT_CODES,
VaultError,
type VaultErrorCode,
} from "./errors/index.js";
export {
applyPatch,
generateItemId,
rowToItem,
} from "./items/helpers.js";
// Items
export { type ItemsOptions, VaultItems } from "./items/items.js";
export {
emptyContainerState,
headSchemaVersion,
type MigrationLedgerEntry,
type MigrationResult,
runMigrations,
VAULT_MIGRATIONS,
type VaultContainerState,
type VaultHistoryRow,
type VaultItemRow,
type VaultMigration,
} from "./items/migrations/migrations.js";
// Manifest
export {
type CreateManifestInput,
createManifest,
hashVaultId,
MANIFEST_AEAD_XCHACHA20_POLY1305,
MANIFEST_BYTE_SIZE,
MANIFEST_FORMAT_VERSION,
MANIFEST_KDF_ARGON2ID_2SKD,
packManifest,
touchManifest,
unpackManifest,
} from "./manifest/manifest.js";
// OTel
export {
getVaultInstruments,
getVaultTracer,
resetVaultInstruments,
resetVaultTracer,
setVaultMeterProvider,
setVaultTracerProvider,
type VaultInstruments,
} from "./otel/instruments.js";
// Schemas
export {
AttachmentSchema,
ItemFieldsSchema,
ListOptionsSchema,
ManifestArgon2idSchema,
PartialItemFieldsSchema,
SearchQuerySchema,
ShareLinkSchema,
VaultItemSchema,
VaultManifestSchema,
} from "./schemas/schemas.js";
// Search
export {
type SearchOptions,
type SearchResult,
VaultSearch,
} from "./search/search.js";
// Shares
export {
type CreateLinkInput,
type SharesOptions,
VaultShares,
} from "./shares/shares.js";
// Sync
export { type SyncOptions, VaultSync } from "./sync/sync.js";
// Types — adapters
export type {
CreatedShareLink,
CreateShareLinkInput,
DriveLike,
DriveSharingLike,
SyncDelegationResult,
SyncSnapshot,
SyncSnapshotItem,
VaultSyncLike,
} from "./types/adapters.js";
// Types — main
export type {
Attachment,
ItemFields,
ListOptions,
SearchQuery,
ShareLink,
SyncResult,
VaultConfig,
VaultItem,
VaultManifest,
VaultState,
} from "./types/types.js";
export {
type DeriveVaultKeyInput,
deriveVaultKeyBuffer,
type LoadOrCreateInput,
type LoadOrCreateResult,
loadOrCreateContainer,
} from "./vault/lifecycle.js";
// Vault
export { VAULT_HEAD_SCHEMA_VERSION, Vault } from "./vault/vault.js";
export {
buildAttachments,
buildItems,
buildSearch,
buildShares,
buildSync,
freshRuntimeIndex,
type RuntimeIndex,
type WiringInputs,
} from "./vault/wiring.js";

View File

@@ -0,0 +1,57 @@
# vault items helpers
## Purpose
`items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with on-the-fly concealed-field decryption), `applyPatch` to merge a partial `ItemFields` into an existing row, and `generateItemId` to produce a hex-encoded random 16-byte id. Keeping these helpers in their own file lets the main `items.ts` stay under the 300-line cap while preserving readability.
The helpers are **pure** with respect to the vault state — they take inputs (rows / vault key / rng) and return new values without touching the container's `Map<string, VaultItemRow>`. The `VaultItems` class wires them together, applies them inside the `state.items.set(...)` mutation, and emits the OTel outcome counters.
## Features
- `rowToItem(row, vaultKey)` — project a row back to a `VaultItem`, lazily decrypting concealed envelopes.
- `applyPatch(row, patch, vaultKey, rng)` — merge a `Partial<ItemFields>` into an existing row; re-encrypts concealed-field updates under the per-item key.
- `generateItemId(rng)` — 32-char hex string from 16 random bytes; falls back to `crypto.getRandomValues` when no `rng` is injected.
- All helpers are stateless and side-effect-free except for the per-call key derivation done inside `encryptConcealedFields` / `decryptConcealedFields`.
## Usage: Basic Example
```ts
import { rowToItem } from "@suaveplan/vault/items/items";
const row = state.items.get(id);
if (row !== undefined) {
const item = rowToItem(row, vaultKey);
console.log(item.concealedFields.password);
}
```
## Usage: Advanced Example
```ts
import { applyPatch, generateItemId } from "@suaveplan/vault/items/items";
const row = state.items.get(id);
if (row === undefined) throw new Error("missing");
const patched = applyPatch(
row,
{ title: "Renamed", concealedFields: { password: "new" } },
vaultKey,
rng
);
state.items.set(id, { ...patched, version: row.version + 1 });
const fresh = generateItemId(rng); // hex-encoded id
```
## API Reference
- `rowToItem(row: VaultItemRow, vaultKey: Uint8Array): VaultItem`.
- `applyPatch(row: VaultItemRow, patch: Partial<ItemFields>, vaultKey: Uint8Array, rng: Rng | undefined): VaultItemRow`.
- `generateItemId(rng: Rng | undefined): string`.
## Implementation Notes
- `rowToItem` re-decrypts concealed envelopes on every call. Callers that read the same row repeatedly are responsible for caching the result; the items repository chooses lazy decryption to keep the typical-read path cheap when only metadata is touched.
- `applyPatch` returns a fresh row object. Callers must not mutate the returned row before storing it; the items repository takes ownership.
- `generateItemId` uses 128 bits of entropy — well above the birthday-collision bound for any vault size. The hex encoding is chosen for stable JSON serialisation across runtimes.
- The injected `rng` flows through `encryptConcealedFields` so per-field nonces are sourced from a deterministic stream in tests.

View File

@@ -0,0 +1,121 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { encryptConcealedFields } from "../encryption/field-encryption.js";
import { applyPatch, generateItemId, rowToItem } from "./helpers.js";
import type { VaultItemRow } from "./migrations/migrations.js";
function vaultKey(): Uint8Array {
const key = new Uint8Array(32);
crypto.getRandomValues(key);
return key;
}
function makeRow(over: Partial<VaultItemRow> = {}): VaultItemRow {
const key = vaultKey();
const id = "i1";
return {
id,
createdAt: 0,
updatedAt: 0,
version: 1,
deleted: false,
title: "GitHub",
category: "login",
fieldsJson: JSON.stringify({ username: "u" }),
concealedFieldsJson: JSON.stringify(
encryptConcealedFields(key, id, { password: "p" })
),
tagsJson: JSON.stringify(["work"]),
favorite: false,
...over,
};
}
describe("items helpers", () => {
it("rowToItem decrypts concealed fields", { timeout: 5000 }, () => {
const key = vaultKey();
const row = makeRow({
concealedFieldsJson: JSON.stringify(
encryptConcealedFields(key, "i1", { password: "p" })
),
});
const item = rowToItem(row, key);
expect(item.title).toBe("GitHub");
expect(item.concealedFields.password).toBe("p");
expect(item.fields.username).toBe("u");
expect(item.tags).toEqual(["work"]);
});
it("applyPatch updates each field independently", { timeout: 5000 }, () => {
const key = vaultKey();
const row = makeRow({
concealedFieldsJson: JSON.stringify(
encryptConcealedFields(key, "i1", { password: "p" })
),
});
const patched = applyPatch(
row,
{
title: "Renamed",
favorite: true,
fields: { url: "x" },
tags: ["new"],
},
key,
undefined
);
expect(patched.title).toBe("Renamed");
expect(patched.favorite).toBe(true);
expect(JSON.parse(patched.fieldsJson)).toEqual({ url: "x" });
expect(JSON.parse(patched.tagsJson)).toEqual(["new"]);
});
it("applyPatch re-encrypts concealed fields on update", {
timeout: 5000,
}, () => {
const key = vaultKey();
const row = makeRow({
concealedFieldsJson: JSON.stringify(
encryptConcealedFields(key, "i1", { password: "old" })
),
});
const patched = applyPatch(
row,
{ concealedFields: { password: "new" } },
key,
undefined
);
const recovered = rowToItem(patched, key);
expect(recovered.concealedFields.password).toBe("new");
});
it("applyPatch updates category when provided", { timeout: 5000 }, () => {
const key = vaultKey();
const row = makeRow();
const patched = applyPatch(
row,
{ category: "secure-note" },
key,
undefined
);
expect(patched.category).toBe("secure-note");
});
it("generateItemId produces 32-character hex strings", {
timeout: 5000,
}, () => {
const id = generateItemId(undefined);
expect(id).toMatch(/^[0-9a-f]{32}$/);
});
it("generateItemId uses the injected RNG when provided", {
timeout: 5000,
}, () => {
const rng = {
nextUint32: () => 1,
nextFloat: () => 0,
nextBytes: (len: number) => new Uint8Array(len).fill(0xab),
};
const id = generateItemId(rng);
expect(id).toBe("ab".repeat(16));
});
});

View File

@@ -0,0 +1,74 @@
import type { Rng } from "@suaveplan/types";
import {
decryptConcealedFields,
type EncryptedField,
encryptConcealedFields,
} from "../encryption/field-encryption.js";
import type { ItemFields, VaultItem } from "../types/types.js";
import type { VaultItemRow } from "./migrations/migrations.js";
/**
* Project a persisted `VaultItemRow` back into a `VaultItem`, lazily
* decrypting the concealed-field envelopes under the vault key.
*/
export function rowToItem(row: VaultItemRow, vaultKey: Uint8Array): VaultItem {
const fields = JSON.parse(row.fieldsJson) as Record<string, string>;
const tags = JSON.parse(row.tagsJson) as string[];
const envelopes = JSON.parse(row.concealedFieldsJson) as Record<
string,
EncryptedField
>;
const concealed = decryptConcealedFields(vaultKey, row.id, envelopes);
return {
id: row.id,
title: row.title,
category: row.category,
fields,
concealedFields: concealed,
tags,
favorite: row.favorite,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
version: row.version,
deleted: row.deleted,
};
}
/**
* Apply a partial-`ItemFields` patch to an existing row. Concealed-field
* patches are re-encrypted under the per-item key.
*/
export function applyPatch(
row: VaultItemRow,
patch: Partial<ItemFields>,
vaultKey: Uint8Array,
rng: Rng | undefined
): VaultItemRow {
const next: VaultItemRow = { ...row };
if (patch.title !== undefined) next.title = patch.title;
if (patch.category !== undefined) next.category = patch.category;
if (patch.favorite !== undefined) next.favorite = patch.favorite;
if (patch.fields !== undefined)
next.fieldsJson = JSON.stringify(patch.fields);
if (patch.tags !== undefined) next.tagsJson = JSON.stringify(patch.tags);
if (patch.concealedFields !== undefined) {
next.concealedFieldsJson = JSON.stringify(
encryptConcealedFields(vaultKey, row.id, patch.concealedFields, rng)
);
}
return next;
}
/** Generate a hex-encoded 16-byte random id, sourced from `rng` if provided. */
export function generateItemId(rng: Rng | undefined): string {
const bytes = rng?.nextBytes?.(16) ?? defaultRandomBytes(16);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function defaultRandomBytes(len: number): Uint8Array {
const out = new Uint8Array(len);
crypto.getRandomValues(out);
return out;
}

View File

@@ -0,0 +1,99 @@
# vault items
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
- [migrations](./migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./helpers.md) — `items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with…
- [items](./items.md) — <!-- begin:auto-docs --> _(no submodules)_- migrations — <!-- begin:auto-docs --> _(no submodule…
- [migrations](./migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./helpers.md) — `items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with…
- [items](./items.md) — <!-- begin:auto-docs --> _(no submodules)_- [migrations](./migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./helpers.md) — `items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with…
- [items](./items.md) — <!-- begin:auto-docs --> _(no submodules)_- migrations — <!-- begin:auto…
- [migrations](./migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./helpers.md) — `items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with…
- [items](./items.md) — <!-- begin:auto-docs --> _(no submodules)_- migrations — <!-- begin:auto-docs --> _(no submodul…
- [migrations](./migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./helpers.md) — `items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with…
- [items](./items.md) — <!-- begin:auto-docs --> _(no submodules)_
## Purpose
`items.ts` exposes the `VaultItems` class, the in-memory CRUD repository over the vault container's item map. It is the boundary between the public `vault.items` surface and the underlying state — every public method validates input via Zod, mutates the container `Map<string, VaultItemRow>`, appends an audit entry to the container history, encrypts concealed-field values per item, and notifies the orchestrator that a flush is due.
The repository operates on the in-memory state loaded from the encrypted container blob. It does NOT touch drive directly; persistence is the orchestrator's job (debounced flush + on lock). Concealed-field values (passwords / PINs / secure notes) are encrypted with per-item keys derived from the vault key, so even a leaked container snapshot keeps concealed values as ciphertext until decrypted on demand by `rowToItem`.
## Features
- `create(fields) → VaultItem` — Zod-validated insert with concealed-field encryption.
- `get(id) → VaultItem | null` — lazy decryption; returns null for missing or tombstoned ids.
- `update(id, patch, expectedVersion?) → VaultItem` — optimistic concurrency via `version`; throws `VAULT.ITEM.VERSION_CONFLICT` on stale writes.
- `delete(id)` — tombstone (not physical delete) so sync can propagate the deletion.
- `list(options?) → VaultItem[]` — filters by category / favorite, with offset / limit pagination.
- `allRows()` — snapshot of non-deleted rows for the search index.
- Every operation records a histogram sample and an outcome counter; OTel attributes are limited to `{ op, status }` — no item ids, no field names, no plaintext.
- Mutations call `onMutation()` so the orchestrator can debounce flushes.
## Usage: Basic Example
```ts
import { VaultItems } from "@suaveplan/vault/items/items";
const items = new VaultItems({
state,
vaultKey,
clock: Date.now,
rng: undefined,
onMutation: () => scheduleFlush(),
});
const created = items.create({
title: "GitHub",
category: "login",
fields: { username: "alice", url: "https://github.com" },
concealedFields: { password: "p@ssw0rd" },
tags: ["work"],
favorite: true,
});
```
## Usage: Advanced Example
```ts
import { VaultItems } from "@suaveplan/vault/items/items";
import { VaultError, VAULT_CODES } from "@suaveplan/vault/errors";
// Optimistic concurrency: capture version, retry on conflict.
function updateWithRetry(items: VaultItems, id: string, patch: Partial<ItemFields>) {
for (let attempt = 0; attempt < 3; attempt++) {
const current = items.get(id);
if (current === null) throw new Error("missing");
try {
return items.update(id, patch, current.version);
} catch (err) {
if (
VaultError.is(err) &&
err.code === VAULT_CODES["ITEM.VERSION_CONFLICT"] &&
attempt < 2
) continue;
throw err;
}
}
}
```
## API Reference
- `class VaultItems` — public surface for `vault.items`.
- `constructor(options: ItemsOptions)`.
- `create(fields: ItemFields): VaultItem` — throws `ITEM.VALIDATION_FAILED`.
- `get(id: string): VaultItem | null`.
- `update(id: string, patch: Partial<ItemFields>, expectedVersion?: number): VaultItem` — throws `ITEM.NOT_FOUND` / `ITEM.VERSION_CONFLICT` / `ITEM.VALIDATION_FAILED`.
- `delete(id: string): void` — throws `ITEM.NOT_FOUND`.
- `list(options?: ListOptions): VaultItem[]` — throws `ITEM.VALIDATION_FAILED`.
- `allRows(): VaultItemRow[]` — read-only snapshot for search.
- `interface ItemsOptions` — `{ state, vaultKey, clock, rng, onMutation }`.
## Implementation Notes
- The repository is **stateful** — it holds references to the container state map, the vault key, and the orchestrator's mutation callback. The vault layer creates a new `VaultItems` on every unlock; the state map is replaced when the container reloads from drive.
- `update` always increments `version` by 1 per successful call. Concurrent writers using `expectedVersion` will see exactly one winner; the others receive `VAULT.ITEM.VERSION_CONFLICT`.
- `delete` is a tombstone — the row stays in the map with `deleted: true` so the sync layer can propagate the deletion. A future garbage-collection pass (out of scope for this version) would prune tombstones older than a retention window.
- The history log is append-only: every successful `create` / `update` / `delete` appends one row with `(itemId, version, timestamp, operation)`. This feeds the audit-trail and CRDT merge paths.
- `list` walks the map insertion order — the same order in which rows were created or last updated. Callers that need stable cross-process ordering should sort by `createdAt` or `updatedAt` after fetch.
- The OTel `outcome` counter uses a fixed allowlist (`success | not_found | conflict | rejected`) — adding a new value requires an OpenSpec change in lockstep with `getVaultInstruments`.

View File

@@ -0,0 +1,261 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VAULT_CODES } from "../errors/codes.js";
import { VaultError } from "../errors/vault-error.js";
import { VaultItems } from "./items.js";
import {
emptyContainerState,
runMigrations,
type VaultContainerState,
} from "./migrations/migrations.js";
function vaultKey(): Uint8Array {
const key = new Uint8Array(32);
crypto.getRandomValues(key);
return key;
}
function freshState(): VaultContainerState {
const state = emptyContainerState();
runMigrations(state, undefined, () => 0);
return state;
}
function makeItems(): {
items: VaultItems;
state: VaultContainerState;
flushes: number;
} {
const state = freshState();
let now = 1_000;
let flushes = 0;
const items = new VaultItems({
state,
vaultKey: vaultKey(),
clock: () => now++,
rng: undefined,
onMutation: () => {
flushes += 1;
},
});
return {
items,
state,
get flushes() {
return flushes;
},
} as { items: VaultItems; state: VaultContainerState; flushes: number };
}
const sample = {
title: "GitHub",
category: "login",
fields: { username: "u", url: "https://github.com" },
concealedFields: { password: "secret" },
tags: ["work"],
favorite: false,
};
describe("VaultItems CRUD", () => {
it("create assigns id, version=1, and timestamps", {
timeout: 5000,
}, () => {
const { items } = makeItems();
const created = items.create(sample);
expect(typeof created.id).toBe("string");
expect(created.version).toBe(1);
expect(created.deleted).toBe(false);
expect(created.concealedFields.password).toBe("secret");
});
it("create rejects malformed inputs with ITEM.VALIDATION_FAILED", {
timeout: 5000,
}, () => {
const { items } = makeItems();
try {
items.create({ ...sample, title: "" });
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES["ITEM.VALIDATION_FAILED"]
);
}
});
it("get returns the created item by id", { timeout: 5000 }, () => {
const { items } = makeItems();
const created = items.create(sample);
const fetched = items.get(created.id);
expect(fetched).not.toBeNull();
expect(fetched?.title).toBe("GitHub");
expect(fetched?.concealedFields.password).toBe("secret");
});
it("get returns null for missing ids", { timeout: 5000 }, () => {
const { items } = makeItems();
expect(items.get("missing")).toBeNull();
});
it("update increments version and applies the patch", {
timeout: 5000,
}, () => {
const { items } = makeItems();
const created = items.create(sample);
const updated = items.update(created.id, { title: "Renamed" });
expect(updated.version).toBe(2);
expect(updated.title).toBe("Renamed");
});
it("update with stale expectedVersion throws VERSION_CONFLICT", {
timeout: 5000,
}, () => {
const { items } = makeItems();
const created = items.create(sample);
items.update(created.id, { title: "v2" });
try {
items.update(created.id, { title: "v3" }, created.version);
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES["ITEM.VERSION_CONFLICT"]
);
}
});
it("update rejects malformed patches with ITEM.VALIDATION_FAILED", {
timeout: 5000,
}, () => {
const { items } = makeItems();
const created = items.create(sample);
try {
items.update(created.id, { title: "" });
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES["ITEM.VALIDATION_FAILED"]
);
}
});
it("update on missing id throws ITEM.NOT_FOUND", { timeout: 5000 }, () => {
const { items } = makeItems();
try {
items.update("missing", { title: "x" });
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES["ITEM.NOT_FOUND"]
);
}
});
it("delete tombstones the item", { timeout: 5000 }, () => {
const { items } = makeItems();
const created = items.create(sample);
items.delete(created.id);
expect(items.get(created.id)).toBeNull();
});
it("delete on missing id throws ITEM.NOT_FOUND", { timeout: 5000 }, () => {
const { items } = makeItems();
try {
items.delete("missing");
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES["ITEM.NOT_FOUND"]
);
}
});
it("list returns non-deleted items with pagination and filters", {
timeout: 5000,
}, () => {
const { items } = makeItems();
items.create({ ...sample, title: "a" });
items.create({ ...sample, title: "b", category: "note" });
const c = items.create({ ...sample, title: "c", favorite: true });
items.delete(c.id);
const all = items.list();
expect(all).toHaveLength(2);
const logins = items.list({ category: "login" });
expect(logins).toHaveLength(1);
const favorites = items.list({ favorite: true });
expect(favorites).toHaveLength(0);
});
it("list respects limit and offset", { timeout: 5000 }, () => {
const { items } = makeItems();
for (let i = 0; i < 5; i++) items.create({ ...sample, title: `t${i}` });
const page = items.list({ limit: 2, offset: 1 });
expect(page).toHaveLength(2);
});
it("list rejects negative offset", { timeout: 5000 }, () => {
const { items } = makeItems();
try {
items.list({ offset: -1 });
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES["ITEM.VALIDATION_FAILED"]
);
}
});
it("allRows omits tombstoned rows", { timeout: 5000 }, () => {
const { items } = makeItems();
const a = items.create({ ...sample, title: "a" });
const b = items.create({ ...sample, title: "b" });
items.delete(a.id);
const rows = items.allRows();
expect(rows.length).toBe(1);
expect(rows[0]?.id).toBe(b.id);
});
it("create + update + delete each notify the orchestrator", {
timeout: 5000,
}, () => {
const state = freshState();
let notifications = 0;
const items = new VaultItems({
state,
vaultKey: vaultKey(),
clock: () => 0,
rng: undefined,
onMutation: () => {
notifications += 1;
},
});
const created = items.create(sample);
items.update(created.id, { title: "x" });
items.delete(created.id);
expect(notifications).toBe(3);
});
it("get on tombstoned id returns null but does not throw", {
timeout: 5000,
}, () => {
const { items } = makeItems();
const created = items.create(sample);
items.delete(created.id);
expect(items.get(created.id)).toBeNull();
});
it("update with matching expectedVersion succeeds", {
timeout: 5000,
}, () => {
const { items } = makeItems();
const created = items.create(sample);
const ok = items.update(created.id, { title: "x" }, created.version);
expect(ok.title).toBe("x");
});
});

View File

@@ -0,0 +1,255 @@
import type { Clock, Rng } from "@suaveplan/types";
import { encryptConcealedFields } from "../encryption/field-encryption.js";
import { VAULT_CODES } from "../errors/codes.js";
import { VaultError } from "../errors/vault-error.js";
import { getVaultInstruments } from "../otel/instruments.js";
import {
ItemFieldsSchema,
ListOptionsSchema,
PartialItemFieldsSchema,
} from "../schemas/schemas.js";
import type { ItemFields, ListOptions, VaultItem } from "../types/types.js";
import { applyPatch, generateItemId, rowToItem } from "./helpers.js";
import type {
VaultContainerState,
VaultItemRow,
} from "./migrations/migrations.js";
/** Construction options for the items repository. */
export interface ItemsOptions {
readonly state: VaultContainerState;
readonly vaultKey: Uint8Array;
readonly clock: Clock;
readonly rng: Rng | undefined;
/** Called whenever a mutation happens — used by the orchestrator to
* schedule a debounced flush. */
readonly onMutation: () => void;
}
type ItemOp = "create" | "get" | "update" | "delete" | "list";
type ItemStatus = "success" | "not_found" | "conflict" | "rejected";
/**
* In-memory items repository. Stores plaintext (`fields`) alongside
* encrypted concealed-field envelopes inside `VaultItemRow`s. Reads
* lazily decrypt concealed fields on demand via `rowToItem`.
*/
export class VaultItems {
private readonly state: VaultContainerState;
private readonly vaultKey: Uint8Array;
private readonly clock: Clock;
private readonly rng: Rng | undefined;
private readonly notifyMutation: () => void;
public constructor(options: ItemsOptions) {
this.state = options.state;
this.vaultKey = options.vaultKey;
this.clock = options.clock;
this.rng = options.rng;
this.notifyMutation = options.onMutation;
}
/** Create a new vault item with encrypted concealed fields. */
public create(fields: ItemFields): VaultItem {
const start = this.clock();
const parsed = ItemFieldsSchema.safeParse(fields);
if (!parsed.success) {
this.outcome("create", "rejected", start);
throw this.validationError(parsed.error.issues.length);
}
const id = generateItemId(this.rng);
const now = this.clock();
const row: VaultItemRow = {
id,
createdAt: now,
updatedAt: now,
version: 1,
deleted: false,
title: parsed.data.title,
category: parsed.data.category,
fieldsJson: JSON.stringify(parsed.data.fields),
concealedFieldsJson: JSON.stringify(
encryptConcealedFields(
this.vaultKey,
id,
parsed.data.concealedFields,
this.rng
)
),
tagsJson: JSON.stringify(parsed.data.tags),
favorite: parsed.data.favorite,
};
this.state.items.set(id, row);
this.state.history.push({
itemId: id,
version: 1,
timestamp: now,
operation: "create",
});
this.notifyMutation();
this.outcome("create", "success", start);
return rowToItem(row, this.vaultKey);
}
/** Read a single item by id. Returns null when missing or tombstoned. */
public get(id: string): VaultItem | null {
const start = this.clock();
const row = this.state.items.get(id);
if (row === undefined || row.deleted) {
this.outcome(
"get",
row === undefined ? "not_found" : "success",
start
);
return null;
}
const item = rowToItem(row, this.vaultKey);
this.outcome("get", "success", start);
return item;
}
/**
* Update an item. `expectedVersion` enables optimistic concurrency —
* pass the version returned by the previous read; the call rejects
* with `VAULT.ITEM.VERSION_CONFLICT` if the row has advanced.
*/
public update(
id: string,
patch: Partial<ItemFields>,
expectedVersion?: number
): VaultItem {
const start = this.clock();
const row = this.requireRow(id, "update", start);
if (expectedVersion !== undefined && expectedVersion !== row.version) {
this.outcome("update", "conflict", start);
throw new VaultError(
VAULT_CODES["ITEM.VERSION_CONFLICT"],
"error.vault.item.version-conflict",
{
context: {
expected: expectedVersion,
actual: row.version,
},
}
);
}
const parsed = PartialItemFieldsSchema.safeParse(patch);
if (!parsed.success) {
this.outcome("update", "rejected", start);
throw this.validationError(parsed.error.issues.length);
}
const merged = applyPatch(
row,
parsed.data as Partial<ItemFields>,
this.vaultKey,
this.rng
);
const now = this.clock();
const updatedRow: VaultItemRow = {
...merged,
updatedAt: now,
version: row.version + 1,
};
this.state.items.set(id, updatedRow);
this.state.history.push({
itemId: id,
version: updatedRow.version,
timestamp: now,
operation: "update",
});
this.notifyMutation();
this.outcome("update", "success", start);
return rowToItem(updatedRow, this.vaultKey);
}
/**
* Tombstone an item. The row remains in the container so sync can
* propagate the deletion; subsequent `get` returns null.
*/
public delete(id: string): void {
const start = this.clock();
const row = this.requireRow(id, "delete", start);
const now = this.clock();
this.state.items.set(id, {
...row,
deleted: true,
updatedAt: now,
version: row.version + 1,
});
this.state.history.push({
itemId: id,
version: row.version + 1,
timestamp: now,
operation: "delete",
});
this.notifyMutation();
this.outcome("delete", "success", start);
}
/** List items in insertion order with optional filters and pagination. */
public list(options?: ListOptions): VaultItem[] {
const start = this.clock();
const parsed = ListOptionsSchema.safeParse(options ?? {});
if (!parsed.success) {
this.outcome("list", "rejected", start);
throw this.validationError(parsed.error.issues.length);
}
const opts = parsed.data;
const offset = opts.offset ?? 0;
const limit = opts.limit ?? Number.POSITIVE_INFINITY;
const out: VaultItem[] = [];
let skipped = 0;
for (const row of this.state.items.values()) {
if (row.deleted) continue;
if (opts.category !== undefined && row.category !== opts.category)
continue;
if (opts.favorite !== undefined && row.favorite !== opts.favorite)
continue;
if (skipped < offset) {
skipped += 1;
continue;
}
out.push(rowToItem(row, this.vaultKey));
if (out.length >= limit) break;
}
this.outcome("list", "success", start);
return out;
}
/**
* Snapshot of every non-deleted row, exposed for the search module so
* it can build an index without re-decrypting concealed fields.
*/
public allRows(): VaultItemRow[] {
return Array.from(this.state.items.values()).filter(
(row) => !row.deleted
);
}
private requireRow(id: string, op: ItemOp, start: number): VaultItemRow {
const row = this.state.items.get(id);
if (row === undefined || row.deleted) {
this.outcome(op, "not_found", start);
throw new VaultError(
VAULT_CODES["ITEM.NOT_FOUND"],
"error.vault.item.not-found",
{ context: { id } }
);
}
return row;
}
private validationError(issues: number): VaultError {
return new VaultError(
VAULT_CODES["ITEM.VALIDATION_FAILED"],
"error.vault.item.validation-failed",
{ context: { issues } }
);
}
private outcome(op: ItemOp, status: ItemStatus, startedAt: number): void {
const i = getVaultInstruments();
i.itemOpDurationMs.record(this.clock() - startedAt, { op });
i.itemOutcomeTotal.add(1, { op, status });
}
}

View File

@@ -0,0 +1,80 @@
# vault item migrations
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`migrations.ts` is the forward-only schema-migration ledger for the in-memory vault container. The container holds a `schemaVersion`, an item map, a history log, and a ledger of applied migrations. Migrations are described by `{ version, name, up(state) }`; running them advances the container state from one schema version to the next while recording the step in the ledger.
The model is deliberately schema-light: the container state is plain JS data structures, not a SQL database. This keeps the package vitest-runnable (no `bun:sqlite` dependency in test) while preserving the migration semantics consumers expect — strictly-increasing versions, append-only ledger, deterministic forward replay. Future schema changes append a new entry to `VAULT_MIGRATIONS` with the next version number; the manifest's `schemaVersion` field carries the current head so older clients can detect they need to upgrade.
## Features
- `VaultMigration` interface — `{ version, name, up(state) }`.
- `VAULT_MIGRATIONS` — frozen array of every migration shipped with the current package version.
- `emptyContainerState()` — produce a pristine state at version 0.
- `runMigrations(state, migrations?, now?)` — apply every step whose version exceeds the state's, recording a `MigrationLedgerEntry` per step.
- `headSchemaVersion(migrations?)` — return the highest version in the migration set.
- Strictly-monotonic ledger — once applied, an entry never changes; later migrations are appended.
## Usage: Basic Example
```ts
import {
emptyContainerState,
runMigrations,
VAULT_MIGRATIONS,
} from "@suaveplan/vault/items/migrations/migrations";
const state = emptyContainerState();
const result = runMigrations(state);
// result.fromVersion === 0
// result.toVersion === headSchemaVersion()
```
## Usage: Advanced Example
```ts
import {
runMigrations,
type VaultMigration,
type VaultContainerState,
} from "@suaveplan/vault/items/migrations/migrations";
// In-place migration: rename `tagsJson` to `tagListJson` (illustrative only).
const customMigration: VaultMigration = {
version: 2,
name: "rename-tagsJson-to-tagListJson",
up(state) {
for (const row of state.items.values()) {
// Mutate state in place — migrations are forward-only.
(row as unknown as Record<string, unknown>).tagListJson =
row.tagsJson;
}
},
};
const fakeClock = () => 1_700_000_000_000;
runMigrations(state, [...VAULT_MIGRATIONS, customMigration], fakeClock);
```
## API Reference
- `interface VaultMigration` — `{ version: number, name: string, up(state): void }`.
- `interface MigrationLedgerEntry` — `{ version, name, appliedAt }`.
- `interface VaultContainerState` — `{ schemaVersion, items: Map, history: [], ledger: [] }`.
- `interface VaultItemRow` — persisted row shape (see source for full field list).
- `interface VaultHistoryRow` — `{ itemId, version, timestamp, operation }`.
- `VAULT_MIGRATIONS: readonly VaultMigration[]` — frozen migration set.
- `emptyContainerState(): VaultContainerState`.
- `runMigrations(state, migrations?, now?): MigrationResult`.
- `headSchemaVersion(migrations?): number`.
## Implementation Notes
- Migrations sort by `version` before application so callers can declare them in any order; the runtime ensures strictly-increasing application.
- The `up` callback mutates state in place. Callers that want transactional semantics across multiple migrations should snapshot state before invoking `runMigrations` and roll back on failure.
- The ledger is append-only — every successfully-applied migration adds one entry. Duplicate applications are prevented by the `m.version <= state.schemaVersion` check.
- The shipped `VAULT_MIGRATIONS` set is intentionally sparse: the suaveplan "schema" is plain JS structures, so the v1 migration is a no-op marker. Future versions add real transformations (e.g. adding a new field to every row, splitting a JSON blob into two columns).
- `runMigrations` accepts an optional injectable `now` to keep ledger timestamps deterministic in tests; production passes `Date.now`.

View File

@@ -0,0 +1,118 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
emptyContainerState,
headSchemaVersion,
runMigrations,
VAULT_MIGRATIONS,
type VaultMigration,
} from "./migrations.js";
describe("vault migrations", () => {
it("emptyContainerState produces a pristine state", {
timeout: 5000,
}, () => {
const state = emptyContainerState();
expect(state.schemaVersion).toBe(0);
expect(state.items.size).toBe(0);
expect(state.history).toEqual([]);
expect(state.ledger).toEqual([]);
});
it("runMigrations applies every step and updates the ledger", {
timeout: 5000,
}, () => {
const state = emptyContainerState();
const result = runMigrations(state);
expect(result.fromVersion).toBe(0);
expect(result.toVersion).toBe(headSchemaVersion());
expect(result.appliedCount).toBe(VAULT_MIGRATIONS.length);
expect(state.schemaVersion).toBe(headSchemaVersion());
expect(state.ledger.length).toBe(VAULT_MIGRATIONS.length);
});
it("runMigrations is idempotent at head version", { timeout: 5000 }, () => {
const state = emptyContainerState();
runMigrations(state);
const second = runMigrations(state);
expect(second.appliedCount).toBe(0);
expect(second.fromVersion).toBe(headSchemaVersion());
});
it("runMigrations advances state with custom migration sets", {
timeout: 5000,
}, () => {
const state = emptyContainerState();
const custom: VaultMigration[] = [
...VAULT_MIGRATIONS,
{
version: 99,
name: "custom",
up(s) {
s.history.push({
itemId: "marker",
version: 99,
timestamp: 0,
operation: "create",
});
},
},
];
runMigrations(state, custom, () => 1);
expect(state.schemaVersion).toBe(99);
const marker = state.history.find((h) => h.itemId === "marker");
expect(marker).toBeDefined();
});
it("runMigrations sorts migrations before applying", {
timeout: 5000,
}, () => {
const state = emptyContainerState();
const trace: number[] = [];
const out: VaultMigration[] = [
{ version: 3, name: "c", up: () => trace.push(3) },
{ version: 1, name: "a", up: () => trace.push(1) },
{ version: 2, name: "b", up: () => trace.push(2) },
];
runMigrations(state, out, () => 1);
expect(trace).toEqual([1, 2, 3]);
});
it("headSchemaVersion returns 0 for an empty migration set", {
timeout: 5000,
}, () => {
expect(headSchemaVersion([])).toBe(0);
});
it("runMigrations uses the injected clock for ledger timestamps", {
timeout: 5000,
}, () => {
const state = emptyContainerState();
runMigrations(state, undefined, () => 1234);
for (const entry of state.ledger) {
expect(entry.appliedAt).toBe(1234);
}
});
it("init migration recovers when items / history are missing", {
timeout: 5000,
}, () => {
const state = emptyContainerState();
(state as unknown as { items: unknown }).items = null;
(state as unknown as { history: unknown }).history = null;
runMigrations(state);
expect(state.items instanceof Map).toBe(true);
expect(Array.isArray(state.history)).toBe(true);
});
it("headSchemaVersion takes the maximum across out-of-order migrations", {
timeout: 5000,
}, () => {
const out = [
{ version: 5, name: "five", up: () => undefined },
{ version: 2, name: "two", up: () => undefined },
{ version: 7, name: "seven", up: () => undefined },
{ version: 1, name: "one", up: () => undefined },
];
expect(headSchemaVersion(out)).toBe(7);
});
});

View File

@@ -0,0 +1,143 @@
/**
* Forward-only schema migration ledger for the in-memory vault container.
*
* The container holds a versioned schema state plus an in-memory item table
* (rows + history rows). Each migration is described by `{ version, up }`
* where `up(state)` mutates the container state to advance from
* `version - 1` to `version`. Migrations run on `unlock` after the container
* blob has been decrypted; the resulting `schemaVersion` is written back
* into the manifest on the next flush.
*
* The shape mirrors `@suaveplan/db-sqlite`'s `MigrationRunner`: a sorted
* list of migrations with strictly-increasing versions, applied in order,
* with the highest applied version persisted in the container state.
*/
/** A single migration step. */
export interface VaultMigration {
/** Strictly-increasing version number (≥ 1). */
readonly version: number;
/** Human-readable migration name; recorded in the ledger for debug. */
readonly name: string;
/** Apply the migration to the in-memory state. */
up(state: VaultContainerState): void;
}
/** Bookkeeping entry written to the ledger after each migration. */
export interface MigrationLedgerEntry {
readonly version: number;
readonly name: string;
readonly appliedAt: number;
}
/** In-memory state managed by the container. */
export interface VaultContainerState {
schemaVersion: number;
items: Map<string, VaultItemRow>;
history: VaultHistoryRow[];
ledger: MigrationLedgerEntry[];
}
/** Row shape persisted inside the container. */
export interface VaultItemRow {
id: string;
createdAt: number;
updatedAt: number;
version: number;
deleted: boolean;
title: string;
category: string;
fieldsJson: string;
concealedFieldsJson: string;
tagsJson: string;
favorite: boolean;
}
/** History row recording a single mutation for audit / sync. */
export interface VaultHistoryRow {
itemId: string;
version: number;
timestamp: number;
operation: "create" | "update" | "delete";
}
/**
* The genesis migration set. Future schema bumps append entries here with
* monotonically-increasing `version` numbers and never modify earlier
* entries.
*/
export const VAULT_MIGRATIONS: readonly VaultMigration[] = [
{
version: 1,
name: "init-vault-items",
up(state) {
// The base schema is the in-memory shape itself — there is no
// SQL DDL to run. We seed the state with empty maps if not
// already present and record the version.
if (!(state.items instanceof Map)) state.items = new Map();
if (!Array.isArray(state.history)) state.history = [];
},
},
] as const;
/**
* Build a fresh, empty container state at schema version 0 (pre-migration).
* Call `runMigrations(state, ...)` to advance to the head version.
*/
export function emptyContainerState(): VaultContainerState {
return {
schemaVersion: 0,
items: new Map(),
history: [],
ledger: [],
};
}
/** Outcome of `runMigrations`. */
export interface MigrationResult {
readonly appliedCount: number;
readonly fromVersion: number;
readonly toVersion: number;
}
/**
* Run every migration whose `version` is strictly greater than the state's
* `schemaVersion`. Migrations are applied in order; the ledger and
* `schemaVersion` are updated in lockstep with each successful step.
*/
export function runMigrations(
state: VaultContainerState,
migrations: readonly VaultMigration[] = VAULT_MIGRATIONS,
now: () => number = () => Date.now()
): MigrationResult {
const fromVersion = state.schemaVersion;
const sorted = [...migrations].sort((a, b) => a.version - b.version);
let applied = 0;
for (const m of sorted) {
if (m.version <= state.schemaVersion) continue;
m.up(state);
state.schemaVersion = m.version;
state.ledger.push({
version: m.version,
name: m.name,
appliedAt: now(),
});
applied += 1;
}
return {
appliedCount: applied,
fromVersion,
toVersion: state.schemaVersion,
};
}
/** The head schema version this build of the package targets. */
export function headSchemaVersion(
migrations: readonly VaultMigration[] = VAULT_MIGRATIONS
): number {
if (migrations.length === 0) return 0;
return migrations.reduce(
(max, m) => (m.version > max ? m.version : max),
0
);
}

View File

@@ -0,0 +1,84 @@
# vault manifest
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`manifest.ts` defines the bitpacker-packed manifest header that lives at the start of every encrypted vault container. The header carries the format version, KDF identifier, AEAD algorithm identifier, Argon2id parameters, vault-id hash, schema version, and creation / modification timestamps. The packed bytes are bound into the AEAD additional-authenticated-data (AAD) so any tampering with the manifest invalidates the ciphertext authentication tag.
The manifest layer wraps `@suaveplan/bitpacker`'s `defineBitSchema` so the on-disk format is compact (50 bytes total: 18 bytes of packed numerics + 32 bytes of SHA-256 vault-id hash), versioned, and forward-tolerant. The leading `formatVersion: u16` lets future readers decide whether they can decode the rest of the blob; mismatched versions raise `VAULT.MANIFEST_VERSION_UNSUPPORTED`.
## Features
- `packManifest(manifest) → Uint8Array` — validate via Zod, then pack to canonical 50-byte header.
- `unpackManifest(bytes) → VaultManifest` — decode + validate; rejects too-short buffers, schema violations, and unsupported versions.
- `createManifest(input) → VaultManifest` — build a fresh manifest for a new vault from Argon2id parameters and the current wall-clock time.
- `touchManifest(manifest, now) → VaultManifest` — update `lastModifiedAt` on flush.
- `hashVaultId(vaultId) → Uint8Array(32)` — stable SHA-256 hash used as the manifest's `vaultIdHash` field.
- Constants: `MANIFEST_FORMAT_VERSION`, `MANIFEST_KDF_ARGON2ID_2SKD`, `MANIFEST_AEAD_XCHACHA20_POLY1305`, `MANIFEST_BYTE_SIZE`.
## Usage: Basic Example
```ts
import { createManifest, packManifest, unpackManifest } from "@suaveplan/vault/manifest/manifest";
const manifest = createManifest({
vaultId: "personal",
argon2idMemoryKB: 65536,
argon2idIterations: 3,
argon2idParallelism: 4,
schemaVersion: 1,
now: Date.now(),
});
const bytes = packManifest(manifest); // 50-byte header
const decoded = unpackManifest(bytes);
```
## Usage: Advanced Example
```ts
import {
MANIFEST_BYTE_SIZE,
packManifest,
touchManifest,
unpackManifest,
} from "@suaveplan/vault/manifest/manifest";
// On flush: re-pack the manifest with a new lastModifiedAt and bind it as AAD.
function flush(manifest, sqliteBytes, now, vaultKey) {
const refreshed = touchManifest(manifest, now);
const header = packManifest(refreshed);
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
const ciphertext = encrypt(vaultKey, nonce, sqliteBytes, header); // AAD = header
const out = new Uint8Array(MANIFEST_BYTE_SIZE + nonce.length + ciphertext.length);
out.set(header, 0);
out.set(nonce, MANIFEST_BYTE_SIZE);
out.set(ciphertext, MANIFEST_BYTE_SIZE + nonce.length);
return out;
}
```
## API Reference
- `MANIFEST_FORMAT_VERSION: 1` — current packed format version.
- `MANIFEST_KDF_ARGON2ID_2SKD: 1` — KDF identifier (Argon2id with 2SKD pre-mix).
- `MANIFEST_AEAD_XCHACHA20_POLY1305: 1` — AEAD algorithm identifier.
- `MANIFEST_BYTE_SIZE: number` — total byte size of every packed manifest.
- `hashVaultId(vaultId: string): Uint8Array` — 32-byte SHA-256 hash.
- `packManifest(manifest: VaultManifest): Uint8Array` — pack to canonical header.
- throws `VaultError(VAULT.MANIFEST_INVALID)` on schema violation.
- `unpackManifest(bytes: Uint8Array): VaultManifest` — decode + validate.
- throws `VaultError(VAULT.MANIFEST_INVALID)` on too-short buffer or schema violation.
- throws `VaultError(VAULT.MANIFEST_VERSION_UNSUPPORTED)` on unknown `formatVersion`.
- `createManifest(input): VaultManifest` — build a fresh manifest.
- `touchManifest(manifest, now): VaultManifest` — bump `lastModifiedAt`.
## Implementation Notes
- The bitpacker schema uses `u53` (53 bits) for `createdAt` / `lastModifiedAt`. JS numbers are double-precision floats; values up to `Number.MAX_SAFE_INTEGER` (2⁵³ − 1) round-trip losslessly. ms-precision timestamps fit comfortably for the next ~285 million years.
- `vaultIdHash` is a **fixed 32-byte SHA-256** rather than the raw `vaultId` string. This keeps the on-disk format constant-size, lets the AEAD AAD bind to a specific vault without leaking the `vaultId`, and prevents an attacker from learning vault names from header inspection.
- `unpackManifest` ignores trailing bytes after `MANIFEST_BYTE_SIZE` so the same routine can be called against the full container blob without slicing first.
- The `formatVersion` check at unpack time is the forward-compatibility guard — readers from a future format version are rejected explicitly rather than silently misinterpreting bytes.
- All numeric fields have explicit bit-width caps in `MANIFEST_FIELDS` matching the corresponding Zod schema in `schemas/schemas.ts`. The two layers must stay in sync — touching one without the other is a defect.

View File

@@ -0,0 +1,145 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VAULT_CODES } from "../errors/codes.js";
import { VaultError } from "../errors/vault-error.js";
import {
createManifest,
hashVaultId,
MANIFEST_AEAD_XCHACHA20_POLY1305,
MANIFEST_BYTE_SIZE,
MANIFEST_FORMAT_VERSION,
MANIFEST_KDF_ARGON2ID_2SKD,
packManifest,
touchManifest,
unpackManifest,
} from "./manifest.js";
const baseInput = {
vaultId: "personal",
argon2idMemoryKB: 65536,
argon2idIterations: 3,
argon2idParallelism: 4,
schemaVersion: 1,
now: 1_700_000_000_000,
};
describe("vault manifest", () => {
it("createManifest populates current versions and timestamps", {
timeout: 5000,
}, () => {
const m = createManifest(baseInput);
expect(m.formatVersion).toBe(MANIFEST_FORMAT_VERSION);
expect(m.kdfId).toBe(MANIFEST_KDF_ARGON2ID_2SKD);
expect(m.aeadAlgorithm).toBe(MANIFEST_AEAD_XCHACHA20_POLY1305);
expect(m.argon2idMemoryKB).toBe(65536);
expect(m.argon2idIterations).toBe(3);
expect(m.argon2idParallelism).toBe(4);
expect(m.createdAt).toBe(baseInput.now);
expect(m.lastModifiedAt).toBe(baseInput.now);
expect(m.schemaVersion).toBe(1);
expect(m.vaultIdHash.length).toBe(32);
});
it("hashVaultId is deterministic and 32 bytes", { timeout: 5000 }, () => {
const a = hashVaultId("personal");
const b = hashVaultId("personal");
expect(a).toEqual(b);
expect(a.length).toBe(32);
const c = hashVaultId("work");
expect(c).not.toEqual(a);
});
it("packManifest + unpackManifest round-trip preserves all fields", {
timeout: 5000,
}, () => {
const m = createManifest(baseInput);
const packed = packManifest(m);
expect(packed.length).toBe(MANIFEST_BYTE_SIZE);
const round = unpackManifest(packed);
expect(round).toEqual(m);
});
it("unpackManifest ignores trailing bytes", { timeout: 5000 }, () => {
const m = createManifest(baseInput);
const packed = packManifest(m);
const padded = new Uint8Array(packed.length + 100);
padded.set(packed, 0);
const round = unpackManifest(padded);
expect(round).toEqual(m);
});
it("unpackManifest rejects too-short buffers with MANIFEST_INVALID", {
timeout: 5000,
}, () => {
try {
unpackManifest(new Uint8Array(MANIFEST_BYTE_SIZE - 1));
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES.MANIFEST_INVALID);
}
});
it("packManifest rejects non-conforming manifests", {
timeout: 5000,
}, () => {
const bad = createManifest(baseInput);
try {
packManifest({
...bad,
vaultIdHash: new Uint8Array(31),
});
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES.MANIFEST_INVALID);
}
});
it("unpackManifest rejects unsupported format versions", {
timeout: 5000,
}, () => {
const m = createManifest(baseInput);
const packed = packManifest(m);
// Mutate the formatVersion field (first 2 bytes — big-endian).
const tampered = new Uint8Array(packed);
tampered[0] = 0xff;
tampered[1] = 0xff;
try {
unpackManifest(tampered);
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES.MANIFEST_VERSION_UNSUPPORTED
);
}
});
it("touchManifest updates only lastModifiedAt", { timeout: 5000 }, () => {
const m = createManifest(baseInput);
const next = touchManifest(m, baseInput.now + 1000);
expect(next.lastModifiedAt).toBe(baseInput.now + 1000);
expect(next.createdAt).toBe(m.createdAt);
expect(next.formatVersion).toBe(m.formatVersion);
expect(next.vaultIdHash).toEqual(m.vaultIdHash);
});
it("unpackManifest rejects manifests with corrupt internal numerics", {
timeout: 5000,
}, () => {
const m = createManifest(baseInput);
const packed = packManifest(m);
const tampered = new Uint8Array(packed);
// Zero out argon2idMemoryKB which violates min(1) on the schema.
// formatVersion u16 (2) + kdfId u8 (1) + aeadAlgorithm u8 (1) = 4
// argon2idMemoryKB is at offset 4..8.
for (let i = 4; i < 8; i++) tampered[i] = 0;
try {
unpackManifest(tampered);
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES.MANIFEST_INVALID);
}
});
});

View File

@@ -0,0 +1,175 @@
import { defineBitSchema, type FieldDef } from "@suaveplan/bitpacker";
import { sha256 } from "@suaveplan/crypto-hash";
import { VAULT_CODES } from "../errors/codes.js";
import { VaultError } from "../errors/vault-error.js";
import { VaultManifestSchema } from "../schemas/schemas.js";
import type { VaultManifest } from "../types/types.js";
/** Current vault manifest format version. */
export const MANIFEST_FORMAT_VERSION = 1;
/** KDF identifier — `1` = Argon2id with 2SKD pre-mix. */
export const MANIFEST_KDF_ARGON2ID_2SKD = 1;
/** AEAD identifier — `1` = XChaCha20-Poly1305 (24-byte nonce). */
export const MANIFEST_AEAD_XCHACHA20_POLY1305 = 1;
/**
* Bitpacker field definitions for the manifest header.
*
* The packed layout, in order:
*
* ```text
* formatVersion u16 (16 bits)
* kdfId u8 (8 bits)
* aeadAlgorithm u8 (8 bits)
* argon2idMemoryKB u32 (32 bits)
* argon2idIterations u16 (16 bits)
* argon2idParallelism u8 (8 bits)
* createdAt u53 (53 bits, ms-precision wallclock)
* lastModifiedAt u53 (53 bits, ms-precision wallclock)
* schemaVersion u16 (16 bits)
* vaultIdHash 32 bytes (256 bits)
* ```
*
* Total: 18 bytes of packed numeric fields + 32 bytes of SHA-256 hash =
* exactly 50 bytes (with byte alignment around the bytes field).
*/
const MANIFEST_FIELDS: FieldDef[] = [
{ name: "formatVersion", type: "uint", bits: 16 },
{ name: "kdfId", type: "uint", bits: 8 },
{ name: "aeadAlgorithm", type: "uint", bits: 8 },
{ name: "argon2idMemoryKB", type: "uint", bits: 32 },
{ name: "argon2idIterations", type: "uint", bits: 16 },
{ name: "argon2idParallelism", type: "uint", bits: 8 },
{ name: "createdAt", type: "uint", bits: 53 },
{ name: "lastModifiedAt", type: "uint", bits: 53 },
{ name: "schemaVersion", type: "uint", bits: 16 },
{ name: "vaultIdHash", type: "bytes", byteLength: 32 },
];
const MANIFEST_BIT_SCHEMA = defineBitSchema<VaultManifest>(MANIFEST_FIELDS);
/** Total byte size of every packed manifest header. */
export const MANIFEST_BYTE_SIZE = MANIFEST_BIT_SCHEMA.byteSize;
const TEXT_ENCODER = new TextEncoder();
/**
* Compute a stable 32-byte SHA-256 hash of a `vaultId`. Used as the manifest's
* `vaultIdHash` field so the AEAD AAD binds the ciphertext to a specific
* vault without leaking the raw `vaultId` into the on-disk format.
*/
export function hashVaultId(vaultId: string): Uint8Array {
return sha256(TEXT_ENCODER.encode(vaultId));
}
/**
* Pack a {@link VaultManifest} into the canonical 50-byte header. The
* resulting bytes are bound into the AEAD AAD so any modification to the
* header invalidates the ciphertext authentication tag.
*
* @throws VaultError(VAULT.MANIFEST_INVALID) when the manifest does not
* pass `VaultManifestSchema` (e.g. wrong byte length on the hash,
* out-of-range numeric fields).
*/
export function packManifest(manifest: VaultManifest): Uint8Array {
const parsed = VaultManifestSchema.safeParse(manifest);
if (!parsed.success) {
throw new VaultError(
VAULT_CODES.MANIFEST_INVALID,
"error.vault.manifest.invalid",
{ context: { issues: parsed.error.issues.length } }
);
}
return MANIFEST_BIT_SCHEMA.pack(parsed.data);
}
/**
* Unpack a manifest from bytes. The leading {@link MANIFEST_BYTE_SIZE} bytes
* are decoded; trailing bytes are ignored so the same routine can decode a
* full container blob.
*
* @throws VaultError(VAULT.MANIFEST_INVALID) when the bytes are too short
* or schema validation fails.
* @throws VaultError(VAULT.MANIFEST_VERSION_UNSUPPORTED) when the unpacked
* `formatVersion` is not {@link MANIFEST_FORMAT_VERSION}.
*/
export function unpackManifest(bytes: Uint8Array): VaultManifest {
if (bytes.length < MANIFEST_BYTE_SIZE) {
throw new VaultError(
VAULT_CODES.MANIFEST_INVALID,
"error.vault.manifest.invalid",
{
context: {
reason: "too-short",
have: bytes.length,
need: MANIFEST_BYTE_SIZE,
},
}
);
}
// The leading length check above guarantees bitpacker has enough bytes,
// so `MANIFEST_BIT_SCHEMA.unpack` cannot raise an UNDERFLOW error on the
// truncated head. Schema refinements catch every other malformed
// numeric field (`argon2idMemoryKB === 0`, `formatVersion` out of range,
// and so on) below.
const head = bytes.subarray(0, MANIFEST_BYTE_SIZE);
const raw: VaultManifest = MANIFEST_BIT_SCHEMA.unpack(head);
const parsed = VaultManifestSchema.safeParse(raw);
if (!parsed.success) {
throw new VaultError(
VAULT_CODES.MANIFEST_INVALID,
"error.vault.manifest.invalid",
{
context: {
reason: "schema",
issues: parsed.error.issues.length,
},
}
);
}
if (parsed.data.formatVersion !== MANIFEST_FORMAT_VERSION) {
throw new VaultError(
VAULT_CODES.MANIFEST_VERSION_UNSUPPORTED,
"error.vault.manifest.version-unsupported",
{ context: { found: parsed.data.formatVersion } }
);
}
return parsed.data;
}
/**
* Build a fresh manifest for a brand-new vault using the configured
* Argon2id parameters and current wall-clock time. The created and
* last-modified timestamps are equal at creation time.
*/
export interface CreateManifestInput {
readonly vaultId: string;
readonly argon2idMemoryKB: number;
readonly argon2idIterations: number;
readonly argon2idParallelism: number;
readonly schemaVersion: number;
readonly now: number;
}
export function createManifest(input: CreateManifestInput): VaultManifest {
return {
formatVersion: MANIFEST_FORMAT_VERSION,
kdfId: MANIFEST_KDF_ARGON2ID_2SKD,
aeadAlgorithm: MANIFEST_AEAD_XCHACHA20_POLY1305,
vaultIdHash: hashVaultId(input.vaultId),
argon2idMemoryKB: input.argon2idMemoryKB,
argon2idIterations: input.argon2idIterations,
argon2idParallelism: input.argon2idParallelism,
createdAt: input.now,
lastModifiedAt: input.now,
schemaVersion: input.schemaVersion,
};
}
/** Update the `lastModifiedAt` field on an existing manifest. */
export function touchManifest(
manifest: VaultManifest,
now: number
): VaultManifest {
return { ...manifest, lastModifiedAt: now };
}

View File

@@ -0,0 +1,71 @@
# vault OTel instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`instruments.ts` is the single source of truth for OpenTelemetry instruments emitted by `@suaveplan/vault`. It exports a frozen `VaultInstruments` interface plus lazy-singleton accessors (`getVaultInstruments`, `getVaultTracer`) and test-only overrides (`setVaultMeterProvider`, `setVaultTracerProvider`, `resetVaultInstruments`, `resetVaultTracer`).
Every histogram, counter, and span is **safe-by-construction**: no password, no secret key, no AUK, no item content, no vault identifier, no item id, and no key material is permitted in any attribute. Only operation labels, status enums (`status` ∈ documented allowlist), durations in ms, and counts cross the OTel boundary. This guarantees that enabling vault telemetry can never leak vault contents into a tracing backend, even when the backend is misconfigured or compromised.
## Features
- `unlockDurationMs` / `lockDurationMs` / `flushDurationMs` — lifecycle latency histograms with no per-vault attributes.
- `itemOpDurationMs` / `attachmentOpDurationMs` / `shareOpDurationMs` / `syncOpDurationMs` — operation latency histograms keyed by operation kind.
- `lifecycleOutcomeTotal` / `itemOutcomeTotal` / `attachmentOutcomeTotal` / `shareOutcomeTotal` / `syncOutcomeTotal` — outcome counters with `status` allowlist enforced at the call site.
- Lazy-singleton accessors so no OTel cost is paid until the first use.
- `set*Provider` overrides for deterministic in-memory test providers; `reset*` to clear cached state between tests.
## Usage: Basic Example
```ts
import { getVaultInstruments } from "@suaveplan/vault/otel/instruments";
const instruments = getVaultInstruments();
const start = Date.now();
await vault.unlock(password, secretKey);
instruments.unlockDurationMs.record(Date.now() - start);
instruments.lifecycleOutcomeTotal.add(1, { status: "unlocked" });
```
## Usage: Advanced Example
```ts
import { metrics, type MeterProvider } from "@opentelemetry/api";
import {
getVaultInstruments,
resetVaultInstruments,
setVaultMeterProvider,
} from "@suaveplan/vault/otel/instruments";
// Inject an in-memory test provider for assertion-driven tests.
const testProvider: MeterProvider = createInMemoryMeterProvider();
setVaultMeterProvider(testProvider);
try {
const instruments = getVaultInstruments();
instruments.itemOpDurationMs.record(12, { op: "create" });
instruments.itemOutcomeTotal.add(1, { op: "create", status: "success" });
// ... assert against testProvider's recorded data points ...
} finally {
resetVaultInstruments();
setVaultMeterProvider(undefined);
}
```
## API Reference
- `interface VaultInstruments` — frozen shape of the instrument set.
- `getVaultInstruments(): VaultInstruments` — lazy accessor for the cached instrument set.
- `setVaultMeterProvider(provider: MeterProvider | undefined): void` — override the meter provider for tests.
- `resetVaultInstruments(): void` — drop the cached instrument set.
- `getVaultTracer(): Tracer` — lazy accessor for the cached tracer.
- `setVaultTracerProvider(provider: TracerProvider | undefined): void` — override the tracer provider for tests.
- `resetVaultTracer(): void` — drop the cached tracer.
## Implementation Notes
- Histograms are recorded in milliseconds against the OTel `unit: "ms"` convention; consumers can configure histogram buckets globally via the SDK without per-instrument code.
- Counters expose a documented `status` allowlist for each surface (lifecycle: `opened | unlocked | locked | denied`; items: `success | not_found | conflict | rejected`; attachments: `success | not_found | rejected`; shares: `success | not_found | revoked | rejected`; sync: `success | failed | rejected`). Call sites pass a string from this list — adding a new value requires an OpenSpec change.
- Resetting a provider mid-process drops the cached singleton so subsequent `getVaultInstruments()` calls re-acquire from the new provider — used in test isolation only.
- The global OTel API returns no-op instruments when no provider is registered; production code can call `getVaultInstruments()` unconditionally without performance impact when telemetry is disabled.

View File

@@ -0,0 +1,181 @@
import type {
Counter,
Histogram,
Meter,
MeterProvider,
Tracer,
TracerProvider,
} from "@opentelemetry/api";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
getVaultInstruments,
getVaultTracer,
resetVaultInstruments,
resetVaultTracer,
setVaultMeterProvider,
setVaultTracerProvider,
type VaultInstruments,
} from "./instruments.js";
interface RecordedHistogram {
name: string;
samples: { value: number; attrs: Record<string, unknown> | undefined }[];
}
interface RecordedCounter {
name: string;
additions: { value: number; attrs: Record<string, unknown> | undefined }[];
}
class StubHistogram implements Histogram {
public constructor(
public readonly name: string,
public readonly bag: RecordedHistogram[]
) {
bag.push({ name, samples: [] });
}
public record(value: number, attrs?: Record<string, unknown>): void {
this.bag[this.bag.length - 1]?.samples.push({ value, attrs });
}
}
class StubCounter implements Counter {
public constructor(
public readonly name: string,
public readonly bag: RecordedCounter[]
) {
bag.push({ name, additions: [] });
}
public add(value: number, attrs?: Record<string, unknown>): void {
this.bag[this.bag.length - 1]?.additions.push({ value, attrs });
}
}
class StubMeter implements Meter {
public histograms: RecordedHistogram[] = [];
public counters: RecordedCounter[] = [];
public createHistogram(name: string): Histogram {
return new StubHistogram(name, this.histograms);
}
public createCounter(name: string): Counter {
return new StubCounter(name, this.counters);
}
public createObservableGauge(name: string): never {
// Not exercised by vault yet; ensures getVaultInstruments compiles.
throw new Error(`unused observable gauge: ${name}`);
}
public createUpDownCounter(): never {
throw new Error("unused");
}
public createObservableCounter(): never {
throw new Error("unused");
}
public createObservableUpDownCounter(): never {
throw new Error("unused");
}
public addBatchObservableCallback(): void {}
public removeBatchObservableCallback(): void {}
public createGauge(): never {
throw new Error("unused");
}
}
class StubMeterProvider implements MeterProvider {
public meters: StubMeter[] = [];
public getMeter(): Meter {
const meter = new StubMeter();
this.meters.push(meter);
return meter;
}
}
class StubTracer {
public id = Symbol("stub-tracer");
}
class StubTracerProvider implements TracerProvider {
public getTracer(): Tracer {
return new StubTracer() as unknown as Tracer;
}
}
describe("vault otel instruments", () => {
it("getVaultInstruments returns a singleton when no provider is set", {
timeout: 5000,
}, () => {
resetVaultInstruments();
const a = getVaultInstruments();
const b = getVaultInstruments();
expect(a).toBe(b);
expectEveryField(a);
});
it("setVaultMeterProvider rebuilds the instrument set against the override", {
timeout: 5000,
}, () => {
const provider = new StubMeterProvider();
setVaultMeterProvider(provider);
const i = getVaultInstruments();
i.unlockDurationMs.record(12);
i.itemOpDurationMs.record(2, { op: "create" });
i.lifecycleOutcomeTotal.add(1, { status: "unlocked" });
const histogramNames = provider.meters[0]?.histograms.map(
(h) => h.name
);
expect(histogramNames).toContain("vault.unlock.duration_ms");
expect(histogramNames).toContain("vault.items.duration_ms");
const counterNames = provider.meters[0]?.counters.map((c) => c.name);
expect(counterNames).toContain("vault.lifecycle.outcome.total");
setVaultMeterProvider(undefined);
resetVaultInstruments();
});
it("resetVaultInstruments forces a fresh singleton on next access", {
timeout: 5000,
}, () => {
const a = getVaultInstruments();
resetVaultInstruments();
const b = getVaultInstruments();
expect(a).not.toBe(b);
});
it("setVaultMeterProvider(undefined) returns to the default global meter", {
timeout: 5000,
}, () => {
const provider = new StubMeterProvider();
setVaultMeterProvider(provider);
const overridden = getVaultInstruments();
setVaultMeterProvider(undefined);
const fallback = getVaultInstruments();
expect(fallback).not.toBe(overridden);
});
it("getVaultTracer returns a singleton; setVaultTracerProvider rebuilds it", {
timeout: 5000,
}, () => {
resetVaultTracer();
const a = getVaultTracer();
const b = getVaultTracer();
expect(a).toBe(b);
const provider = new StubTracerProvider();
setVaultTracerProvider(provider);
const c = getVaultTracer();
expect(c).not.toBe(a);
setVaultTracerProvider(undefined);
resetVaultTracer();
});
});
function expectEveryField(i: VaultInstruments): void {
expect(typeof i.unlockDurationMs.record).toBe("function");
expect(typeof i.lockDurationMs.record).toBe("function");
expect(typeof i.flushDurationMs.record).toBe("function");
expect(typeof i.itemOpDurationMs.record).toBe("function");
expect(typeof i.attachmentOpDurationMs.record).toBe("function");
expect(typeof i.shareOpDurationMs.record).toBe("function");
expect(typeof i.syncOpDurationMs.record).toBe("function");
expect(typeof i.lifecycleOutcomeTotal.add).toBe("function");
expect(typeof i.itemOutcomeTotal.add).toBe("function");
expect(typeof i.attachmentOutcomeTotal.add).toBe("function");
expect(typeof i.shareOutcomeTotal.add).toBe("function");
expect(typeof i.syncOutcomeTotal.add).toBe("function");
}

View File

@@ -0,0 +1,169 @@
import type {
Counter,
Histogram,
Meter,
MeterProvider,
Tracer,
TracerProvider,
} from "@opentelemetry/api";
import { metrics, trace } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/vault";
const TRACER_NAME = "@suaveplan/vault";
/**
* The full instrument set emitted from `@suaveplan/vault` production paths.
*
* Every instrument is **safe-by-construction**: no key bytes, no item
* content, no plaintext fields, no vault identifiers, and no item ids
* appear in attributes — only operation labels, durations, and counts.
*/
export interface VaultInstruments {
/** Histogram of `Vault.unlock` end-to-end latency in ms. */
unlockDurationMs: Histogram;
/** Histogram of `Vault.lock` latency in ms (includes zeroize sweep). */
lockDurationMs: Histogram;
/** Histogram of debounced flush-to-drive latency in ms. */
flushDurationMs: Histogram;
/** Histogram of vault item CRUD operation latency in ms. */
itemOpDurationMs: Histogram;
/** Histogram of attachment upload/download latency in ms. */
attachmentOpDurationMs: Histogram;
/** Histogram of share-link operation latency in ms. */
shareOpDurationMs: Histogram;
/** Histogram of sync operation latency in ms. */
syncOpDurationMs: Histogram;
/** Counter of vault lifecycle outcomes (`status` ∈ allowlist). */
lifecycleOutcomeTotal: Counter;
/** Counter of vault item operation outcomes. */
itemOutcomeTotal: Counter;
/** Counter of attachment operation outcomes. */
attachmentOutcomeTotal: Counter;
/** Counter of share operation outcomes. */
shareOutcomeTotal: Counter;
/** Counter of sync operation outcomes. */
syncOutcomeTotal: Counter;
}
let instruments: VaultInstruments | null = null;
let tracer: Tracer | null = null;
function buildInstruments(meter: Meter): VaultInstruments {
return {
unlockDurationMs: meter.createHistogram("vault.unlock.duration_ms", {
description: "Wall-clock duration of Vault.unlock in ms.",
unit: "ms",
}),
lockDurationMs: meter.createHistogram("vault.lock.duration_ms", {
description: "Wall-clock duration of Vault.lock in ms.",
unit: "ms",
}),
flushDurationMs: meter.createHistogram("vault.flush.duration_ms", {
description: "Wall-clock duration of vault flush-to-drive in ms.",
unit: "ms",
}),
itemOpDurationMs: meter.createHistogram("vault.items.duration_ms", {
description: "Wall-clock duration of vault item CRUD ops in ms.",
unit: "ms",
}),
attachmentOpDurationMs: meter.createHistogram(
"vault.attachments.duration_ms",
{
description:
"Wall-clock duration of vault attachment ops in ms.",
unit: "ms",
}
),
shareOpDurationMs: meter.createHistogram("vault.shares.duration_ms", {
description: "Wall-clock duration of vault share-link ops in ms.",
unit: "ms",
}),
syncOpDurationMs: meter.createHistogram("vault.sync.duration_ms", {
description: "Wall-clock duration of vault sync ops in ms.",
unit: "ms",
}),
lifecycleOutcomeTotal: meter.createCounter(
"vault.lifecycle.outcome.total",
{
description:
"Lifecycle outcomes by status (opened | unlocked | locked | denied).",
unit: "{events}",
}
),
itemOutcomeTotal: meter.createCounter("vault.items.outcome.total", {
description:
"Item-op outcomes by status (success | not_found | conflict | rejected).",
unit: "{ops}",
}),
attachmentOutcomeTotal: meter.createCounter(
"vault.attachments.outcome.total",
{
description:
"Attachment-op outcomes by status (success | not_found | rejected).",
unit: "{ops}",
}
),
shareOutcomeTotal: meter.createCounter("vault.shares.outcome.total", {
description:
"Share-op outcomes by status (success | not_found | revoked | rejected).",
unit: "{ops}",
}),
syncOutcomeTotal: meter.createCounter("vault.sync.outcome.total", {
description:
"Sync-op outcomes by status (success | failed | rejected).",
unit: "{ops}",
}),
};
}
/**
* Lazy-singleton accessor for the vault instrument set. On first call this
* captures `metrics.getMeter(...)` from the global OTel API; no-op
* instruments are returned automatically when no MeterProvider is registered.
*/
export function getVaultInstruments(): VaultInstruments {
if (instruments !== null) return instruments;
instruments = buildInstruments(metrics.getMeter(METER_NAME));
return instruments;
}
/**
* Override the OTel `MeterProvider` used to acquire the vault meter. Pass
* `undefined` to fall back to the global default. Intended for tests that
* want to assert against a deterministic provider.
*/
export function setVaultMeterProvider(
provider: MeterProvider | undefined
): void {
instruments = null;
if (provider !== undefined) {
instruments = buildInstruments(provider.getMeter(METER_NAME));
}
}
/** Reset the cached instruments. Used in tests for suite isolation. */
export function resetVaultInstruments(): void {
instruments = null;
}
/** Lazy-singleton accessor for the vault tracer. */
export function getVaultTracer(): Tracer {
if (tracer !== null) return tracer;
tracer = trace.getTracer(TRACER_NAME);
return tracer;
}
/** Override the `TracerProvider` used to acquire the vault tracer. */
export function setVaultTracerProvider(
provider: TracerProvider | undefined
): void {
tracer = null;
if (provider !== undefined) {
tracer = provider.getTracer(TRACER_NAME);
}
}
/** Reset the cached tracer. Used by tests for suite isolation. */
export function resetVaultTracer(): void {
tracer = null;
}

View File

@@ -0,0 +1,65 @@
# vault Zod schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`schemas.ts` defines Zod schemas for every public boundary type in `@suaveplan/vault`: `ItemFields`, `VaultItem`, `SearchQuery`, `ListOptions`, `Attachment`, `ShareLink`, and the bitpacker-packed `VaultManifest`. Each schema is `strict` — unknown keys are rejected at validation time so that callers can never sneak extra fields through that the orchestration layer would silently drop or misroute into the SQLite schema.
The schemas serve three roles: (1) public-API input validation (`vault.items.create` validates `ItemFields` before encryption); (2) deserialisation guard (loading a snapshot from drive validates each item before re-hydration); and (3) manifest sanity-check before bitpack/unpack. The latter doubles as defence-in-depth: even if a corrupt blob gets past AEAD authentication (it cannot, by construction, but never trust a single layer), the schema fails the load with a typed `VAULT.MANIFEST_INVALID`.
## Features
- `ItemFieldsSchema` / `PartialItemFieldsSchema` / `VaultItemSchema` — strict item shapes with bounded length budgets.
- `SearchQuerySchema` / `ListOptionsSchema` — query options with size caps to prevent pathological FTS5 queries.
- `AttachmentSchema` / `ShareLinkSchema` — record envelopes with bounded filenames / URLs.
- `ManifestArgon2idSchema` / `VaultManifestSchema` — pre-bitpacking sanity-check for the manifest struct, including a 32-byte `vaultIdHash` length refinement.
## Usage: Basic Example
```ts
import { ItemFieldsSchema } from "@suaveplan/vault/schemas/schemas";
const parsed = ItemFieldsSchema.safeParse(input);
if (!parsed.success) {
throw new VaultError(VAULT_CODES["ITEM.VALIDATION_FAILED"], "...");
}
```
## Usage: Advanced Example
```ts
import { VaultManifestSchema } from "@suaveplan/vault/schemas/schemas";
function loadManifest(decoded: unknown) {
const result = VaultManifestSchema.safeParse(decoded);
if (!result.success) {
throw new VaultError(
VAULT_CODES.MANIFEST_INVALID,
"error.vault.manifest.invalid",
{ context: { issues: result.error.issues.length } }
);
}
return result.data;
}
```
## API Reference
- `ItemFieldsSchema` — strict `{ title, category, fields, concealedFields, tags, favorite }`.
- `PartialItemFieldsSchema` — `ItemFieldsSchema.partial()` for updates.
- `VaultItemSchema` — `ItemFieldsSchema` extended with `id`, `createdAt`, `updatedAt`, `version`, `deleted`.
- `SearchQuerySchema` — `{ query?, category?, tags?, favorite?, limit? }` with size caps.
- `ListOptionsSchema` — `{ category?, favorite?, limit?, offset? }`.
- `AttachmentSchema` — `{ id, itemId, filename, mimeType, size, createdAt }`.
- `ShareLinkSchema` — `{ id, itemId, url, expiresAt, maxViews, createdAt }`.
- `ManifestArgon2idSchema` — `{ memoryKB, iterations, parallelism }` with bit-width caps matching the bitpacker manifest schema.
- `VaultManifestSchema` — full manifest validation with 32-byte `vaultIdHash` refinement.
## Implementation Notes
- All schemas use `.strict()` to block unknown keys; this is critical for the manifest where forward-compatibility is handled by the bitpacker `_version` strategy, not by silent passthrough.
- Bit-width caps on numeric manifest fields (e.g. `argon2idMemoryKB ≤ 0xFFFFFFFF`) match the `u32` / `u16` / `u8` widths used by the bitpacker schema in `manifest/manifest.ts`.
- The `VaultManifestSchema.vaultIdHash` refinement asserts 32 bytes — the SHA-256 hash size. The vault layer derives this hash deterministically from `vaultId`; mismatches at load time indicate a corrupt or tampered blob.
- Search query `limit` is hard-capped at 1000 so a misbehaving caller cannot trigger an O(N) FTS5 fetch that monopolises the in-memory SQLite connection.

View File

@@ -0,0 +1,191 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AttachmentSchema,
ItemFieldsSchema,
ListOptionsSchema,
ManifestArgon2idSchema,
PartialItemFieldsSchema,
SearchQuerySchema,
ShareLinkSchema,
VaultItemSchema,
VaultManifestSchema,
} from "./schemas.js";
describe("vault schemas", () => {
it("ItemFieldsSchema rejects empty title", { timeout: 5000 }, () => {
const result = ItemFieldsSchema.safeParse({
title: "",
category: "login",
fields: {},
concealedFields: {},
tags: [],
favorite: false,
});
expect(result.success).toBe(false);
});
it("ItemFieldsSchema accepts a fully-formed item", {
timeout: 5000,
}, () => {
const result = ItemFieldsSchema.safeParse({
title: "GitHub",
category: "login",
fields: { username: "u" },
concealedFields: { password: "p" },
tags: ["work"],
favorite: true,
});
expect(result.success).toBe(true);
});
it("ItemFieldsSchema rejects unknown keys", { timeout: 5000 }, () => {
const result = ItemFieldsSchema.safeParse({
title: "x",
category: "y",
fields: {},
concealedFields: {},
tags: [],
favorite: false,
extra: 1,
});
expect(result.success).toBe(false);
});
it("PartialItemFieldsSchema accepts an empty patch", {
timeout: 5000,
}, () => {
expect(PartialItemFieldsSchema.safeParse({}).success).toBe(true);
});
it("VaultItemSchema requires id and version >= 1", {
timeout: 5000,
}, () => {
const ok = VaultItemSchema.safeParse({
id: "i1",
title: "x",
category: "y",
fields: {},
concealedFields: {},
tags: [],
favorite: false,
createdAt: 0,
updatedAt: 0,
version: 1,
deleted: false,
});
expect(ok.success).toBe(true);
const bad = VaultItemSchema.safeParse({
...(ok.success ? ok.data : {}),
version: 0,
});
expect(bad.success).toBe(false);
});
it("SearchQuerySchema caps limit and tags", { timeout: 5000 }, () => {
expect(
SearchQuerySchema.safeParse({ query: "x", limit: 1001 }).success
).toBe(false);
expect(SearchQuerySchema.safeParse({}).success).toBe(true);
expect(
SearchQuerySchema.safeParse({
tags: Array.from({ length: 33 }, (_, i) => `t${i}`),
}).success
).toBe(false);
});
it("ListOptionsSchema accepts {} and rejects negative offset", {
timeout: 5000,
}, () => {
expect(ListOptionsSchema.safeParse({}).success).toBe(true);
expect(ListOptionsSchema.safeParse({ offset: -1 }).success).toBe(false);
});
it("AttachmentSchema validates record", { timeout: 5000 }, () => {
expect(
AttachmentSchema.safeParse({
id: "a",
itemId: "b",
filename: "f",
mimeType: "application/octet-stream",
size: 0,
createdAt: 0,
}).success
).toBe(true);
expect(
AttachmentSchema.safeParse({
id: "a",
itemId: "b",
filename: "f",
mimeType: "x",
size: -1,
createdAt: 0,
}).success
).toBe(false);
});
it("ShareLinkSchema validates URL", { timeout: 5000 }, () => {
expect(
ShareLinkSchema.safeParse({
id: "a",
itemId: "b",
url: "https://example.com/share/a",
expiresAt: null,
maxViews: null,
createdAt: 0,
}).success
).toBe(true);
expect(
ShareLinkSchema.safeParse({
id: "a",
itemId: "b",
url: "not-a-url",
expiresAt: null,
maxViews: null,
createdAt: 0,
}).success
).toBe(false);
});
it("ManifestArgon2idSchema enforces bit-width caps", {
timeout: 5000,
}, () => {
expect(
ManifestArgon2idSchema.safeParse({
memoryKB: 65536,
iterations: 3,
parallelism: 4,
}).success
).toBe(true);
expect(
ManifestArgon2idSchema.safeParse({
memoryKB: 0xff_ff_ff_ff + 1,
iterations: 3,
parallelism: 4,
}).success
).toBe(false);
});
it("VaultManifestSchema requires 32-byte vaultIdHash", {
timeout: 5000,
}, () => {
const base = {
formatVersion: 1,
kdfId: 1,
aeadAlgorithm: 1,
argon2idMemoryKB: 65536,
argon2idIterations: 3,
argon2idParallelism: 4,
createdAt: 0,
lastModifiedAt: 0,
schemaVersion: 1,
vaultIdHash: new Uint8Array(32),
};
expect(VaultManifestSchema.safeParse(base).success).toBe(true);
expect(
VaultManifestSchema.safeParse({
...base,
vaultIdHash: new Uint8Array(31),
}).success
).toBe(false);
});
});

View File

@@ -0,0 +1,99 @@
import * as z from "zod";
/** Zod schema for the (plaintext) `ItemFields` shape accepted by `vault.items`. */
export const ItemFieldsSchema = z
.object({
title: z.string().min(1).max(512),
category: z.string().min(1).max(64),
fields: z.record(z.string(), z.string()),
concealedFields: z.record(z.string(), z.string()),
tags: z.array(z.string().min(1).max(64)),
favorite: z.boolean(),
})
.strict();
/** Zod schema for partial updates to `ItemFields`. */
export const PartialItemFieldsSchema = ItemFieldsSchema.partial();
/** Zod schema for a persisted vault item. */
// schema: boundary-only
export const VaultItemSchema = ItemFieldsSchema.extend({
id: z.string().min(1),
createdAt: z.number().int().min(0),
updatedAt: z.number().int().min(0),
version: z.number().int().min(1),
deleted: z.boolean(),
}).strict();
/** Zod schema for `vault.items.search` queries. */
export const SearchQuerySchema = z
.object({
query: z.string().max(256).optional(),
category: z.string().max(64).optional(),
tags: z.array(z.string().min(1).max(64)).max(32).optional(),
favorite: z.boolean().optional(),
limit: z.number().int().min(1).max(1000).optional(),
})
.strict();
/** Zod schema for `vault.items.list` options. */
export const ListOptionsSchema = z
.object({
category: z.string().max(64).optional(),
favorite: z.boolean().optional(),
limit: z.number().int().min(1).max(1000).optional(),
offset: z.number().int().min(0).optional(),
})
.strict();
/** Zod schema for an attachment metadata record. */
export const AttachmentSchema = z
.object({
id: z.string().min(1),
itemId: z.string().min(1),
filename: z.string().min(1).max(512),
mimeType: z.string().min(1).max(128),
size: z.number().int().min(0),
createdAt: z.number().int().min(0),
})
.strict();
/** Zod schema for a share-link record. */
export const ShareLinkSchema = z
.object({
id: z.string().min(1),
itemId: z.string().min(1),
url: z.string().url(),
expiresAt: z.number().int().min(0).nullable(),
maxViews: z.number().int().min(1).nullable(),
createdAt: z.number().int().min(0),
})
.strict();
/** Zod schema for the `Argon2idParams` snapshot embedded in the manifest. */
// schema: boundary-only
export const ManifestArgon2idSchema = z
.object({
memoryKB: z.number().int().min(1).max(0xff_ff_ff_ff),
iterations: z.number().int().min(1).max(0xff_ff),
parallelism: z.number().int().min(1).max(0xff),
})
.strict();
/** Zod schema for the bitpacker-packed vault manifest header. */
export const VaultManifestSchema = z
.object({
formatVersion: z.number().int().min(0).max(0xff_ff),
kdfId: z.number().int().min(0).max(0xff),
aeadAlgorithm: z.number().int().min(0).max(0xff),
vaultIdHash: z.instanceof(Uint8Array).refine((b) => b.length === 32, {
message: "vaultIdHash must be 32 bytes",
}),
argon2idMemoryKB: z.number().int().min(1).max(0xff_ff_ff_ff),
argon2idIterations: z.number().int().min(1).max(0xff_ff),
argon2idParallelism: z.number().int().min(1).max(0xff),
createdAt: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
lastModifiedAt: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
schemaVersion: z.number().int().min(0).max(0xff_ff),
})
.strict();

View File

@@ -0,0 +1,70 @@
# vault search
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`search.ts` implements free-text search across vault items with category / favorite / tag filters. The result projection deliberately strips `concealedFields`, so search results NEVER contain plaintext passwords / PINs / secure notes — even if a caller forgets to do its own filtering.
The implementation is a linear scan over the non-deleted rows; with the schema-enforced caps (1000-row `limit`, 256-char `query`, 32-tag filter) this remains O(N × Q) bounded well under millisecond latency for typical vaults. Future versions may swap in an FTS5 in-memory index without changing the public surface.
## Features
- `search(query) → SearchResult[]` — Zod-validated free-text query with optional `category` / `tags` / `favorite` filters and result limit.
- Scoring weights — title = 4, plaintext fields = 2, tags = 1; ties break by insertion order via stable sort.
- Tag filter is intersection (every tag in `query.tags` must appear on the row).
- Empty `query` string returns every matching row (filtered) — useful for filter-only listing.
- Concealed-field plaintext is structurally excluded from the result type — the projection uses `Omit<VaultItem, "concealedFields">`.
- Every search records a histogram sample and an `op: "search"` outcome counter.
## Usage: Basic Example
```ts
import { VaultSearch } from "@suaveplan/vault/search/search";
const search = new VaultSearch({
rows: () => items.allRows(),
vaultKey,
clock: Date.now,
});
const results = search.search({ query: "github", category: "login" });
for (const item of results) {
console.log(item.title, item.fields.username);
// item.concealedFields is `never` here — type-checked exclusion.
}
```
## Usage: Advanced Example
```ts
import { VaultSearch } from "@suaveplan/vault/search/search";
const search = new VaultSearch({ rows, vaultKey, clock });
// Tag intersection — only items with BOTH "work" AND "dev".
const workDev = search.search({ tags: ["work", "dev"], limit: 50 });
// Favorite-only listing with no text filter.
const favorites = search.search({ favorite: true });
// Empty query, category filter, custom limit.
const allLogins = search.search({ category: "login", limit: 20 });
```
## API Reference
- `class VaultSearch`.
- `constructor(options: SearchOptions)`.
- `search(query: SearchQuery): SearchResult[]` — throws `ITEM.VALIDATION_FAILED` on schema violation.
- `interface SearchOptions` — `{ rows: () => VaultItemRow[], vaultKey, clock }`.
- `type SearchResult = Omit<VaultItem, "concealedFields">` — search projection without concealed plaintext.
## Implementation Notes
- The result projection strips `concealedFields` via destructuring; this guarantees no concealed-field plaintext can flow to a search caller, even if a future code path adds new concealed fields. The `SearchResult` type alias propagates the exclusion to TypeScript consumers.
- Scoring is deterministic and field-weighted; titles dominate so a query like `"github"` ranks `{ title: "GitHub" }` ahead of an item that merely mentions GitHub in a username field. Ties retain insertion order via the stable `Array.prototype.sort`.
- The `rows` callback is invoked on every search so the search index always reflects the latest state. Building a persistent index across multiple searches is intentionally deferred to a future version.
- The injected `vaultKey` is needed by the projection to decrypt nothing — concealed-field values are stripped before decryption — but `rowToItem` still derives the per-item key during projection. This keeps the projection pure and avoids special-casing search inside `rowToItem`.
- The OTel counter uses `op: "search"` — search is treated as a read-side item op for telemetry purposes, sharing the items histogram + counter to keep the metric surface narrow.

View File

@@ -0,0 +1,215 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { encryptConcealedFields } from "../encryption/field-encryption.js";
import { VAULT_CODES } from "../errors/codes.js";
import { VaultError } from "../errors/vault-error.js";
import type { VaultItemRow } from "../items/migrations/migrations.js";
import { VaultSearch } from "./search.js";
function vaultKey(): Uint8Array {
const key = new Uint8Array(32);
crypto.getRandomValues(key);
return key;
}
function makeRow(
key: Uint8Array,
over: Partial<VaultItemRow> = {}
): VaultItemRow {
const id = over.id ?? "i1";
return {
id,
createdAt: 0,
updatedAt: 0,
version: 1,
deleted: false,
title: "GitHub",
category: "login",
fieldsJson: JSON.stringify({ username: "u", url: "https://gh.com" }),
concealedFieldsJson: JSON.stringify(
encryptConcealedFields(key, id, { password: "p" })
),
tagsJson: JSON.stringify(["work"]),
favorite: false,
...over,
};
}
describe("VaultSearch", () => {
it("title-match scores higher than tag-only match", {
timeout: 5000,
}, () => {
const key = vaultKey();
const rows = [
makeRow(key, { id: "a", title: "GitHub" }),
makeRow(key, { id: "b", title: "Other", tagsJson: '["github"]' }),
];
const search = new VaultSearch({
rows: () => rows,
vaultKey: key,
clock: () => 0,
});
const result = search.search({ query: "github" });
expect(result[0]?.id).toBe("a");
expect(result.length).toBe(2);
});
it("category filter narrows results", { timeout: 5000 }, () => {
const key = vaultKey();
const rows = [
makeRow(key, { id: "a", title: "GitHub", category: "login" }),
makeRow(key, { id: "b", title: "GitHub", category: "note" }),
];
const search = new VaultSearch({
rows: () => rows,
vaultKey: key,
clock: () => 0,
});
const result = search.search({ query: "github", category: "login" });
expect(result.length).toBe(1);
expect(result[0]?.id).toBe("a");
});
it("tag intersection requires every tag to match", {
timeout: 5000,
}, () => {
const key = vaultKey();
const rows = [
makeRow(key, { id: "a", tagsJson: '["work","dev"]' }),
makeRow(key, { id: "b", tagsJson: '["work"]' }),
];
const search = new VaultSearch({
rows: () => rows,
vaultKey: key,
clock: () => 0,
});
const result = search.search({ tags: ["work", "dev"] });
expect(result.length).toBe(1);
expect(result[0]?.id).toBe("a");
});
it("favorite filter narrows results", { timeout: 5000 }, () => {
const key = vaultKey();
const rows = [
makeRow(key, { id: "a", favorite: true }),
makeRow(key, { id: "b", favorite: false }),
];
const search = new VaultSearch({
rows: () => rows,
vaultKey: key,
clock: () => 0,
});
const result = search.search({ favorite: true });
expect(result.length).toBe(1);
expect(result[0]?.id).toBe("a");
});
it("results never carry concealedFields", { timeout: 5000 }, () => {
const key = vaultKey();
const rows = [makeRow(key, { id: "a" })];
const search = new VaultSearch({
rows: () => rows,
vaultKey: key,
clock: () => 0,
});
const result = search.search({});
for (const item of result) {
expect("concealedFields" in item).toBe(false);
}
});
it("empty query with filters returns all matching rows", {
timeout: 5000,
}, () => {
const key = vaultKey();
const rows = [
makeRow(key, { id: "a", category: "login" }),
makeRow(key, { id: "b", category: "login" }),
makeRow(key, { id: "c", category: "note" }),
];
const search = new VaultSearch({
rows: () => rows,
vaultKey: key,
clock: () => 0,
});
const result = search.search({ category: "login" });
expect(result.length).toBe(2);
});
it("query that matches a plaintext field scores 2", {
timeout: 5000,
}, () => {
const key = vaultKey();
const rows = [makeRow(key, { id: "a" })];
const search = new VaultSearch({
rows: () => rows,
vaultKey: key,
clock: () => 0,
});
const result = search.search({ query: "gh.com" });
expect(result.length).toBe(1);
});
it("query with no matches returns empty", { timeout: 5000 }, () => {
const key = vaultKey();
const rows = [makeRow(key, { id: "a" })];
const search = new VaultSearch({
rows: () => rows,
vaultKey: key,
clock: () => 0,
});
const result = search.search({ query: "no-match" });
expect(result).toEqual([]);
});
it("rejects malformed queries with ITEM.VALIDATION_FAILED", {
timeout: 5000,
}, () => {
const key = vaultKey();
const search = new VaultSearch({
rows: () => [],
vaultKey: key,
clock: () => 0,
});
try {
search.search({ limit: -1 });
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES["ITEM.VALIDATION_FAILED"]
);
}
});
it("limit caps the returned results", { timeout: 5000 }, () => {
const key = vaultKey();
const rows: VaultItemRow[] = [];
for (let i = 0; i < 5; i++) rows.push(makeRow(key, { id: `i${i}` }));
const search = new VaultSearch({
rows: () => rows,
vaultKey: key,
clock: () => 0,
});
const result = search.search({ limit: 2 });
expect(result.length).toBe(2);
});
it("search matches a tag containing the needle", { timeout: 5000 }, () => {
const key = vaultKey();
const rows = [
makeRow(key, {
id: "a",
title: "boring",
tagsJson: '["github"]',
fieldsJson: "{}",
}),
];
const search = new VaultSearch({
rows: () => rows,
vaultKey: key,
clock: () => 0,
});
const result = search.search({ query: "github" });
expect(result.length).toBe(1);
});
});

View File

@@ -0,0 +1,123 @@
import type { Clock } from "@suaveplan/types";
import { VAULT_CODES } from "../errors/codes.js";
import { VaultError } from "../errors/vault-error.js";
import { rowToItem } from "../items/helpers.js";
import type { VaultItemRow } from "../items/migrations/migrations.js";
import { getVaultInstruments } from "../otel/instruments.js";
import { SearchQuerySchema } from "../schemas/schemas.js";
import type { SearchQuery, VaultItem } from "../types/types.js";
/** Construction options for the search index. */
export interface SearchOptions {
readonly rows: () => VaultItemRow[];
readonly vaultKey: Uint8Array;
readonly clock: Clock;
}
/**
* Search-result projection: every public field of `VaultItem` EXCEPT
* `concealedFields`. Search MUST never return concealed-field plaintext —
* the projection enforces this at the type level.
*/
export type SearchResult = Omit<VaultItem, "concealedFields">;
/**
* Linear-scan search over the container's plaintext fields. Concealed-field
* envelopes are never inspected; search remains safe even if the container
* is scanned in a different process from the one holding the vault key.
*
* The scan is `O(N × Q)` where `N` is the non-deleted item count and `Q`
* is the query string length — with the 1000-item / 256-character caps
* enforced by `SearchQuerySchema` this stays well under 250k character
* comparisons per query in the worst case.
*/
export class VaultSearch {
private readonly rows: () => VaultItemRow[];
private readonly vaultKey: Uint8Array;
private readonly clock: Clock;
public constructor(options: SearchOptions) {
this.rows = options.rows;
this.vaultKey = options.vaultKey;
this.clock = options.clock;
}
/** Run a search query and return ranked results without concealed fields. */
public search(query: SearchQuery): SearchResult[] {
const start = this.clock();
const parsed = SearchQuerySchema.safeParse(query);
if (!parsed.success) {
this.outcome("rejected", start);
throw new VaultError(
VAULT_CODES["ITEM.VALIDATION_FAILED"],
"error.vault.item.validation-failed",
{ context: { issues: parsed.error.issues.length } }
);
}
const opts = parsed.data;
const limit = opts.limit ?? Number.POSITIVE_INFINITY;
const needle = opts.query?.toLowerCase() ?? "";
const results: { score: number; item: SearchResult }[] = [];
for (const row of this.rows()) {
if (opts.category !== undefined && row.category !== opts.category)
continue;
if (opts.favorite !== undefined && row.favorite !== opts.favorite)
continue;
const tags = JSON.parse(row.tagsJson) as string[];
if (
opts.tags !== undefined &&
!opts.tags.every((t) => tags.includes(t))
)
continue;
const score = scoreRow(row, tags, needle);
if (needle !== "" && score === 0) continue;
results.push({ score, item: this.project(row) });
}
results.sort((a, b) => b.score - a.score);
const projected = results
.slice(0, Number.isFinite(limit) ? limit : results.length)
.map((r) => r.item);
this.outcome("success", start);
return projected;
}
private project(row: VaultItemRow): SearchResult {
const full = rowToItem(row, this.vaultKey);
// Strip concealedFields — search MUST never return plaintext concealed values.
const { concealedFields: _concealed, ...rest } = full;
return rest;
}
private outcome(status: "success" | "rejected", startedAt: number): void {
const i = getVaultInstruments();
i.itemOpDurationMs.record(this.clock() - startedAt, { op: "search" });
i.itemOutcomeTotal.add(1, { op: "search", status });
}
}
/**
* Compute a relevance score for a row against `needle`. Empty needle
* returns score 1 (every row eligible); non-empty needle scores by
* matched-field weight: title=4, fields=2, tags=1.
*/
function scoreRow(row: VaultItemRow, tags: string[], needle: string): number {
if (needle === "") return 1;
let score = 0;
if (row.title.toLowerCase().includes(needle)) score += 4;
const fields = JSON.parse(row.fieldsJson) as Record<string, string>;
for (const value of Object.values(fields)) {
if (typeof value === "string" && value.toLowerCase().includes(needle)) {
score += 2;
break;
}
}
for (const tag of tags) {
if (tag.toLowerCase().includes(needle)) {
score += 1;
break;
}
}
return score;
}

View File

@@ -0,0 +1,89 @@
# vault shares
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`shares.ts` implements the share-link orchestration for `vault.shares.createLink` and `vault.shares.revokeLink`. The repository wraps the per-share key envelope under a KEK derived from the vault key + share id, then delegates the actual link creation / revocation / status check to a pluggable `DriveSharingLike` adapter (production wires this to `@suaveplan/drive-sharing`; tests use a stub).
The wrapped envelope keeps share-key plaintext out of `drive-sharing`'s reach: the partition store sees only ciphertext + a 24-byte nonce. Even a fully-compromised drive-sharing service cannot recover the shared item's plaintext without the vault key.
## Features
- `createLink(input) → ShareLink` — wrap the item plaintext, register the link, return metadata.
- `revokeLink(id)` — delegate to drive-sharing; remove local metadata.
- `isActive(id) → boolean` — fast local-metadata short-circuit + drive-sharing canonical check.
- `list()` — snapshot of share metadata.
- Drive failures throw `DRIVE.FAILURE` with the underlying error chained.
- Missing-link reads / revokes throw `SHARE.NOT_FOUND`.
- Per-share KEK derived via `deriveItemKey(vaultKey, "share/<itemId>")` and zeroized after wrap.
- AEAD AAD bound to `"vault/v1/share/<itemId>"` so a wrapped envelope cannot be replayed against a different item.
- OTel histogram + counter per `createLink` / `revokeLink` with `op` and `status` labels.
## Usage: Basic Example
```ts
import { VaultShares } from "@suaveplan/vault/shares/shares";
const shares = new VaultShares({
driveSharing,
partitionId: "vault-abc",
vaultKey,
clock: Date.now,
rng: undefined,
metadata: state.shares,
onMutation: () => scheduleFlush(),
});
const link = await shares.createLink({
itemId: "i1",
itemPlaintext: TextEncoder().encode(JSON.stringify(item)),
expiresAt: Date.now() + 86_400_000,
maxViews: 5,
});
console.log(link.url);
```
## Usage: Advanced Example
```ts
import { VaultShares } from "@suaveplan/vault/shares/shares";
import { VaultError, VAULT_CODES } from "@suaveplan/vault/errors";
// Revoke + verify pattern.
async function safeRevoke(shares: VaultShares, id: string) {
try {
await shares.revokeLink(id);
} catch (err) {
if (
VaultError.is(err) &&
err.code === VAULT_CODES["SHARE.NOT_FOUND"]
) {
return false; // already gone
}
throw err;
}
const stillActive = await shares.isActive(id);
return !stillActive;
}
```
## API Reference
- `class VaultShares`.
- `constructor(options: SharesOptions)`.
- `createLink(input: CreateLinkInput): Promise<ShareLink>` — throws `DRIVE.FAILURE` / `ITEM.VALIDATION_FAILED`.
- `revokeLink(id: string): Promise<void>` — throws `SHARE.NOT_FOUND` / `DRIVE.FAILURE`.
- `isActive(id: string): Promise<boolean>`.
- `list(): ShareLink[]`.
- `interface SharesOptions` — `{ driveSharing, partitionId, vaultKey, clock, rng, metadata, onMutation }`.
- `interface CreateLinkInput` — `{ itemId, itemPlaintext, expiresAt?, maxViews? }`.
## Implementation Notes
- The KEK is derived per-share via `deriveItemKey(vaultKey, "share/<itemId>")`. Using a synthetic key path keeps share KEKs distinct from item keys derived from the same vault key under genuine item ids.
- `wrappedKey` returned by `createLink` to drive-sharing is opaque ciphertext bytes — the adapter never sees the unwrapped per-share key. Even if the adapter is fully compromised, the share recipient's vault-key derivation is required to unwrap the envelope.
- `revokeLink` delegates to `DriveSharingLike.revokeLink` first, then removes local metadata only on success. A drive-sharing failure leaves the local metadata intact so a retry hits the same code path.
- `isActive` short-circuits on missing local metadata before calling the adapter; this avoids spurious network round-trips for already-revoked links and keeps `revokedLink === false` consistent with `metadata.delete`.
- The OTel `outcome` counter uses a fixed allowlist (`success | not_found | rejected`); `revoked` is not an outcome here — once a link is revoked it is gone from local metadata and any further calls fall under `not_found`.

View File

@@ -0,0 +1,295 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VAULT_CODES } from "../errors/codes.js";
import { VaultError } from "../errors/vault-error.js";
import type {
CreatedShareLink,
CreateShareLinkInput,
DriveSharingLike,
} from "../types/adapters.js";
import type { ShareLink } from "../types/types.js";
import { VaultShares } from "./shares.js";
function vaultKey(): Uint8Array {
const key = new Uint8Array(32);
crypto.getRandomValues(key);
return key;
}
class StubDriveSharing implements DriveSharingLike {
public received: CreateShareLinkInput[] = [];
public revoked: string[] = [];
public failOn: "create" | "revoke" | undefined;
public counter = 0;
public async createLink(
input: CreateShareLinkInput
): Promise<CreatedShareLink> {
if (this.failOn === "create") throw new Error("offline");
this.received.push(input);
this.counter += 1;
const id = `share-${this.counter}`;
return { id, url: `https://share.example/${id}` };
}
public async revokeLink(id: string): Promise<void> {
if (this.failOn === "revoke") throw new Error("offline");
this.revoked.push(id);
}
public async isActive(id: string): Promise<boolean> {
return !this.revoked.includes(id);
}
}
function makeShares(driveSharing?: DriveSharingLike): {
shares: VaultShares;
driveSharing: StubDriveSharing;
metadata: Map<string, ShareLink>;
} {
const stub =
(driveSharing as StubDriveSharing | undefined) ??
new StubDriveSharing();
const metadata = new Map<string, ShareLink>();
const shares = new VaultShares({
driveSharing: stub,
partitionId: "part-1",
vaultKey: vaultKey(),
clock: () => 1,
rng: undefined,
metadata,
onMutation: () => {},
});
return { shares, driveSharing: stub, metadata };
}
describe("VaultShares", () => {
it("createLink wraps the plaintext and returns metadata", {
timeout: 5000,
}, async () => {
const { shares, driveSharing } = makeShares();
const link = await shares.createLink({
itemId: "i1",
itemPlaintext: new TextEncoder().encode("payload"),
expiresAt: 99,
maxViews: 5,
});
expect(link.id).toBe("share-1");
expect(link.url).toContain("share-1");
expect(driveSharing.received[0]?.partitionId).toBe("part-1");
expect(driveSharing.received[0]?.wrappedKey.length).toBeGreaterThan(0);
});
it("createLink wraps drive failures as DRIVE.FAILURE", {
timeout: 5000,
}, async () => {
const stub = new StubDriveSharing();
stub.failOn = "create";
const { shares } = makeShares(stub);
try {
await shares.createLink({
itemId: "i",
itemPlaintext: new Uint8Array(1),
expiresAt: null,
maxViews: null,
});
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES["DRIVE.FAILURE"]);
}
});
it("revokeLink removes local metadata", { timeout: 5000 }, async () => {
const { shares, driveSharing, metadata } = makeShares();
const link = await shares.createLink({
itemId: "i",
itemPlaintext: new Uint8Array(1),
expiresAt: null,
maxViews: null,
});
await shares.revokeLink(link.id);
expect(metadata.has(link.id)).toBe(false);
expect(driveSharing.revoked).toEqual([link.id]);
});
it("revokeLink throws SHARE.NOT_FOUND on unknown ids", {
timeout: 5000,
}, async () => {
const { shares } = makeShares();
try {
await shares.revokeLink("missing");
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES["SHARE.NOT_FOUND"]
);
}
});
it("revokeLink wraps drive failures as DRIVE.FAILURE", {
timeout: 5000,
}, async () => {
const stub = new StubDriveSharing();
const { shares } = makeShares(stub);
const link = await shares.createLink({
itemId: "i",
itemPlaintext: new Uint8Array(1),
expiresAt: null,
maxViews: null,
});
stub.failOn = "revoke";
try {
await shares.revokeLink(link.id);
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES["DRIVE.FAILURE"]);
}
});
it("isActive short-circuits when local metadata is missing", {
timeout: 5000,
}, async () => {
const { shares } = makeShares();
expect(await shares.isActive("missing")).toBe(false);
});
it("isActive returns the canonical answer from drive-sharing", {
timeout: 5000,
}, async () => {
const { shares } = makeShares();
const link = await shares.createLink({
itemId: "i",
itemPlaintext: new Uint8Array(1),
expiresAt: null,
maxViews: null,
});
expect(await shares.isActive(link.id)).toBe(true);
await shares.revokeLink(link.id);
// After revoke, metadata is removed locally; isActive returns false.
expect(await shares.isActive(link.id)).toBe(false);
});
it("list returns all known share metadata", { timeout: 5000 }, async () => {
const { shares } = makeShares();
await shares.createLink({
itemId: "a",
itemPlaintext: new Uint8Array(1),
expiresAt: null,
maxViews: null,
});
await shares.createLink({
itemId: "b",
itemPlaintext: new Uint8Array(1),
expiresAt: null,
maxViews: null,
});
expect(shares.list().length).toBe(2);
});
it("createLink with an injected RNG produces deterministic wrappedKey nonces", {
timeout: 5000,
}, async () => {
const stub = new StubDriveSharing();
const metadata = new Map<string, ShareLink>();
const rng = {
nextUint32: () => 1,
nextFloat: () => 0,
nextBytes: (len: number) => new Uint8Array(len).fill(2),
};
const shares = new VaultShares({
driveSharing: stub,
partitionId: "p",
vaultKey: new Uint8Array(32),
clock: () => 0,
rng,
metadata,
onMutation: () => {},
});
await shares.createLink({
itemId: "i",
itemPlaintext: new Uint8Array(1),
expiresAt: null,
maxViews: null,
});
const wrapped = stub.received[0]?.wrappedKey as Uint8Array;
expect(wrapped.slice(0, 24)).toEqual(new Uint8Array(24).fill(2));
});
it("non-Error createLink failures still surface as DRIVE.FAILURE", {
timeout: 5000,
}, async () => {
const sentinel: unknown = "non-error";
class BadStub extends StubDriveSharing {
public override async createLink(): Promise<CreatedShareLink> {
throw sentinel;
}
}
const stub = new BadStub();
const { shares } = makeShares(stub);
try {
await shares.createLink({
itemId: "i",
itemPlaintext: new Uint8Array(1),
expiresAt: null,
maxViews: null,
});
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES["DRIVE.FAILURE"]);
}
});
it("non-Error revokeLink failures still surface as DRIVE.FAILURE", {
timeout: 5000,
}, async () => {
const sentinel: unknown = "non-error";
class BadStub extends StubDriveSharing {
public override async revokeLink(): Promise<void> {
throw sentinel;
}
}
const stub = new BadStub();
const { shares } = makeShares(stub);
const link = await shares.createLink({
itemId: "i",
itemPlaintext: new Uint8Array(1),
expiresAt: null,
maxViews: null,
});
try {
await shares.revokeLink(link.id);
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES["DRIVE.FAILURE"]);
}
});
it("createLink validates the produced ShareLink", {
timeout: 5000,
}, async () => {
class BadStub extends StubDriveSharing {
public override async createLink(
_input: CreateShareLinkInput
): Promise<CreatedShareLink> {
return { id: "x", url: "not-a-url" };
}
}
const stub = new BadStub();
const { shares } = makeShares(stub);
try {
await shares.createLink({
itemId: "i",
itemPlaintext: new Uint8Array(1),
expiresAt: null,
maxViews: null,
});
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES["ITEM.VALIDATION_FAILED"]
);
}
});
});

View File

@@ -0,0 +1,191 @@
import { xchacha20poly1305 } from "@suaveplan/crypto-cipher";
import { zeroize } from "@suaveplan/crypto-utils";
import { deriveItemKey } from "@suaveplan/crypto-vault";
import type { Clock, Rng } from "@suaveplan/types";
import { VAULT_CODES } from "../errors/codes.js";
import { VaultError } from "../errors/vault-error.js";
import { getVaultInstruments } from "../otel/instruments.js";
import { ShareLinkSchema } from "../schemas/schemas.js";
import type { DriveSharingLike } from "../types/adapters.js";
import type { ShareLink } from "../types/types.js";
const NONCE_LENGTH = xchacha20poly1305.NONCE_LENGTH;
const TEXT_ENCODER = new TextEncoder();
/** Construction options for the shares repository. */
export interface SharesOptions {
readonly driveSharing: DriveSharingLike;
readonly partitionId: string;
readonly vaultKey: Uint8Array;
readonly clock: Clock;
readonly rng: Rng | undefined;
/** In-memory share metadata, keyed by share id. */
readonly metadata: Map<string, ShareLink>;
/** Notify the orchestrator that share metadata changed. */
readonly onMutation: () => void;
}
/** Input shape for `createLink`. */
export interface CreateLinkInput {
readonly itemId: string;
/** Per-item key the share recipient needs to decrypt the shared item. */
readonly itemPlaintext: Uint8Array;
readonly expiresAt?: number | null;
readonly maxViews?: number | null;
}
/**
* Share-link repository. Wraps the per-share key with a KEK derived from
* the vault key + share id, then hands off to `DriveSharingLike` which
* stores the wrapped envelope under a partition record.
*/
export class VaultShares {
private readonly driveSharing: DriveSharingLike;
private readonly partitionId: string;
private readonly vaultKey: Uint8Array;
private readonly clock: Clock;
private readonly rng: Rng | undefined;
private readonly metadata: Map<string, ShareLink>;
private readonly notifyMutation: () => void;
public constructor(options: SharesOptions) {
this.driveSharing = options.driveSharing;
this.partitionId = options.partitionId;
this.vaultKey = options.vaultKey;
this.clock = options.clock;
this.rng = options.rng;
this.metadata = options.metadata;
this.notifyMutation = options.onMutation;
}
/** Wrap the shared item, register the link, and return its metadata. */
public async createLink(input: CreateLinkInput): Promise<ShareLink> {
const start = this.clock();
const wrappedKey = this.wrap(input.itemId, input.itemPlaintext);
let created: { id: string; url: string };
try {
created = await this.driveSharing.createLink({
partitionId: this.partitionId,
itemId: input.itemId,
expiresAt: input.expiresAt ?? null,
maxViews: input.maxViews ?? null,
wrappedKey,
});
} catch (cause) {
this.outcome("createLink", "rejected", start);
throw new VaultError(
VAULT_CODES["DRIVE.FAILURE"],
"error.vault.drive.failure",
{
cause,
context: { op: "share.create" },
}
);
}
const link: ShareLink = {
id: created.id,
itemId: input.itemId,
url: created.url,
expiresAt: input.expiresAt ?? null,
maxViews: input.maxViews ?? null,
createdAt: this.clock(),
};
const parsed = ShareLinkSchema.safeParse(link);
if (!parsed.success) {
this.outcome("createLink", "rejected", start);
throw new VaultError(
VAULT_CODES["ITEM.VALIDATION_FAILED"],
"error.vault.item.validation-failed",
{ context: { issues: parsed.error.issues.length } }
);
}
this.metadata.set(link.id, parsed.data);
this.notifyMutation();
this.outcome("createLink", "success", start);
return parsed.data;
}
/** Revoke a previously-created share link. */
public async revokeLink(linkId: string): Promise<void> {
const start = this.clock();
const meta = this.metadata.get(linkId);
if (meta === undefined) {
this.outcome("revokeLink", "not_found", start);
throw new VaultError(
VAULT_CODES["SHARE.NOT_FOUND"],
"error.vault.share.not-found",
{ context: { id: linkId } }
);
}
try {
await this.driveSharing.revokeLink(linkId);
} catch (cause) {
this.outcome("revokeLink", "rejected", start);
throw new VaultError(
VAULT_CODES["DRIVE.FAILURE"],
"error.vault.drive.failure",
{
cause,
context: { op: "share.revoke" },
}
);
}
this.metadata.delete(linkId);
this.notifyMutation();
this.outcome("revokeLink", "success", start);
}
/**
* Return whether a link is still active. Falls through to
* `DriveSharingLike.isActive` so callers see the canonical answer
* even if local metadata is stale.
*/
public async isActive(linkId: string): Promise<boolean> {
if (!this.metadata.has(linkId)) return false;
return this.driveSharing.isActive(linkId);
}
/** Snapshot all known share metadata. */
public list(): ShareLink[] {
return Array.from(this.metadata.values());
}
private wrap(itemId: string, plaintext: Uint8Array): Uint8Array {
const kek = deriveItemKey(this.vaultKey, `share/${itemId}`);
const nonce = randomNonce(this.rng);
const aad = TEXT_ENCODER.encode(`vault/v1/share/${itemId}`);
try {
const ciphertext = xchacha20poly1305.encrypt(
kek,
nonce,
plaintext,
aad
);
const out = new Uint8Array(NONCE_LENGTH + ciphertext.length);
out.set(nonce, 0);
out.set(ciphertext, NONCE_LENGTH);
return out;
} finally {
zeroize(kek);
}
}
private outcome(
op: "createLink" | "revokeLink",
status: "success" | "not_found" | "rejected",
startedAt: number
): void {
const i = getVaultInstruments();
i.shareOpDurationMs.record(this.clock() - startedAt, { op });
i.shareOutcomeTotal.add(1, { op, status });
}
}
function randomNonce(rng: Rng | undefined): Uint8Array {
if (rng?.nextBytes !== undefined) {
return new Uint8Array(rng.nextBytes(NONCE_LENGTH));
}
const out = new Uint8Array(NONCE_LENGTH);
crypto.getRandomValues(out);
return out;
}

View File

@@ -0,0 +1,74 @@
# vault sync
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`sync.ts` exposes the `VaultSync` orchestrator that delegates `pull` / `push` / `sync` operations to a `VaultSyncLike` adapter (production wires this to `@suaveplan/vault-sync`'s `SyncEngine`). The orchestrator's responsibilities are: (1) project the in-memory item rows into the wire-shaped `SyncSnapshot`, (2) hydrate the delegate with the latest local snapshot before every operation, (3) translate the delegate's `SyncDelegationResult` into the public `SyncResult` envelope, and (4) wrap any delegate failure in a `VAULT.SYNC.FAILED` `VaultError` so consumers see a stable error code regardless of the underlying transport.
This keeps the sync engine generic — it has no knowledge of the vault's encryption layer, item schema, or container format. The orchestrator handles all the impedance matching at the boundary.
## Features
- `pull() → SyncResult` — pull remote changes via the delegate.
- `push() → SyncResult` — push local changes via the delegate.
- `sync() → SyncResult` — bidirectional sync via the delegate.
- `currentSnapshot()` — read-through to the delegate's view.
- Snapshot is built on-demand from `rows()` — the orchestrator never caches a stale snapshot.
- All operations emit a histogram sample and an `op`-tagged outcome counter; failures map to `VAULT.SYNC.FAILED` with the underlying error chained as `cause`.
## Usage: Basic Example
```ts
import { VaultSync } from "@suaveplan/vault/sync/sync";
const sync = new VaultSync({
delegate: vaultSyncEngine,
rows: () => items.allRows(),
clock: Date.now,
});
const result = await sync.sync();
console.log(`merged ${result.merged.length} items`);
```
## Usage: Advanced Example
```ts
import { VaultSync } from "@suaveplan/vault/sync/sync";
import { VaultError, VAULT_CODES } from "@suaveplan/vault/errors";
async function syncWithBackoff(sync: VaultSync, attempts = 3) {
for (let i = 0; i < attempts; i++) {
try {
return await sync.sync();
} catch (err) {
if (
!VaultError.is(err) ||
err.code !== VAULT_CODES["SYNC.FAILED"] ||
i === attempts - 1
) throw err;
await new Promise((r) => setTimeout(r, 100 * 2 ** i));
}
}
throw new Error("unreachable");
}
```
## API Reference
- `class VaultSync`.
- `constructor(options: SyncOptions)`.
- `pull(): Promise<SyncResult>` — throws `SYNC.FAILED`.
- `push(): Promise<SyncResult>` — throws `SYNC.FAILED`.
- `sync(): Promise<SyncResult>` — throws `SYNC.FAILED`.
- `currentSnapshot(): SyncSnapshot`.
- `interface SyncOptions` — `{ delegate, rows, clock }`.
## Implementation Notes
- `buildSnapshot` projects only the wire-essential fields (`id`, `updatedAt`, `version`, `deleted`, `fieldsJson`, `concealedFieldsJson`, `tagsJson`). It deliberately does not decrypt concealed fields — sync sees ciphertext only, end-to-end. The remote service stores ciphertext, the merge layer compares it as opaque bytes, and concealed-field plaintext never leaves the vault layer.
- The orchestrator hydrates the delegate before every operation. This keeps the snapshot correct even if the caller mutates items between successive `pull` / `push` calls.
- `VAULT.SYNC.FAILED` is the canonical wrapping for any error raised from the delegate. Callers can inspect `err.cause` for the underlying reason (transport timeout, merge conflict, payload-too-large, etc.).
- The OTel `outcome` counter uses a fixed allowlist (`success | failed | rejected`); the orchestrator emits `success` and `failed`. The `rejected` slot is reserved for the lifecycle reject path (e.g. `vault.sync.pull` called while locked) which is enforced one layer up in `Vault`.

View File

@@ -0,0 +1,204 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VAULT_CODES } from "../errors/codes.js";
import { VaultError } from "../errors/vault-error.js";
import type { VaultItemRow } from "../items/migrations/migrations.js";
import type {
SyncDelegationResult,
SyncSnapshot,
VaultSyncLike,
} from "../types/adapters.js";
import { VaultSync } from "./sync.js";
class StubSync implements VaultSyncLike {
public hydrated: SyncSnapshot[] = [];
public failOn: "pull" | "push" | "sync" | undefined;
public lastSnapshot: SyncSnapshot = { items: [], snapshotAt: 0 };
public hydrate(snapshot: SyncSnapshot): void {
this.hydrated.push(snapshot);
this.lastSnapshot = snapshot;
}
public async pull(): Promise<SyncDelegationResult> {
if (this.failOn === "pull") throw new Error("offline");
return {
merged: this.lastSnapshot.items.map((i) => i.id),
conflicts: [],
deleted: [],
durationMs: 1,
};
}
public async push(): Promise<SyncDelegationResult> {
if (this.failOn === "push") throw new Error("offline");
return {
merged: this.lastSnapshot.items.map((i) => i.id),
conflicts: [],
deleted: [],
durationMs: 1,
};
}
public async sync(): Promise<SyncDelegationResult> {
if (this.failOn === "sync") throw new Error("offline");
return {
merged: this.lastSnapshot.items.map((i) => i.id),
conflicts: [],
deleted: [],
durationMs: 2,
};
}
public currentSnapshot(): SyncSnapshot {
return this.lastSnapshot;
}
}
function makeSync(): {
sync: VaultSync;
delegate: StubSync;
rows: VaultItemRow[];
} {
const delegate = new StubSync();
const rows: VaultItemRow[] = [
{
id: "i1",
createdAt: 0,
updatedAt: 1,
version: 1,
deleted: false,
title: "t",
category: "c",
fieldsJson: "{}",
concealedFieldsJson: "{}",
tagsJson: "[]",
favorite: false,
},
];
const sync = new VaultSync({
delegate,
rows: () => rows,
clock: () => 0,
});
return { sync, delegate, rows };
}
describe("VaultSync", () => {
it("pull hydrates and returns the delegate's merged result", {
timeout: 5000,
}, async () => {
const { sync, delegate } = makeSync();
const result = await sync.pull();
expect(result.merged).toEqual(["i1"]);
expect(delegate.hydrated.length).toBe(1);
});
it("push hydrates and returns the delegate's result", {
timeout: 5000,
}, async () => {
const { sync } = makeSync();
const result = await sync.push();
expect(result.merged).toEqual(["i1"]);
});
it("sync hydrates and returns the delegate's result", {
timeout: 5000,
}, async () => {
const { sync } = makeSync();
const result = await sync.sync();
expect(result.merged).toEqual(["i1"]);
});
it("pull failures map to SYNC.FAILED", { timeout: 5000 }, async () => {
const { sync, delegate } = makeSync();
delegate.failOn = "pull";
try {
await sync.pull();
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES["SYNC.FAILED"]);
}
});
it("push failures map to SYNC.FAILED", { timeout: 5000 }, async () => {
const { sync, delegate } = makeSync();
delegate.failOn = "push";
try {
await sync.push();
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
}
});
it("sync failures map to SYNC.FAILED", { timeout: 5000 }, async () => {
const { sync, delegate } = makeSync();
delegate.failOn = "sync";
try {
await sync.sync();
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
}
});
it("non-Error delegate failures still surface as SYNC.FAILED", {
timeout: 5000,
}, async () => {
const sentinel: unknown = "non-error";
const delegate: VaultSyncLike = {
hydrate() {},
async pull() {
throw sentinel;
},
async push() {
return {
merged: [],
conflicts: [],
deleted: [],
durationMs: 0,
};
},
async sync() {
return {
merged: [],
conflicts: [],
deleted: [],
durationMs: 0,
};
},
currentSnapshot() {
return { items: [], snapshotAt: 0 };
},
};
const sync = new VaultSync({
delegate,
rows: () => [],
clock: () => 0,
});
try {
await sync.pull();
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES["SYNC.FAILED"]);
}
});
it("currentSnapshot reads through to the delegate", {
timeout: 5000,
}, () => {
const { sync, delegate } = makeSync();
delegate.lastSnapshot = {
items: [
{
id: "x",
updatedAt: 0,
version: 1,
deleted: false,
fieldsJson: "{}",
concealedFieldsJson: "{}",
tagsJson: "[]",
},
],
snapshotAt: 99,
};
expect(sync.currentSnapshot().items.length).toBe(1);
});
});

View File

@@ -0,0 +1,112 @@
import type { Clock } from "@suaveplan/types";
import { VAULT_CODES } from "../errors/codes.js";
import { VaultError } from "../errors/vault-error.js";
import type { VaultItemRow } from "../items/migrations/migrations.js";
import { getVaultInstruments } from "../otel/instruments.js";
import type {
SyncDelegationResult,
SyncSnapshot,
SyncSnapshotItem,
VaultSyncLike,
} from "../types/adapters.js";
import type { SyncResult } from "../types/types.js";
/** Construction options for the sync orchestrator. */
export interface SyncOptions {
readonly delegate: VaultSyncLike;
readonly rows: () => VaultItemRow[];
readonly clock: Clock;
}
/**
* Sync orchestrator. Delegates to `VaultSyncLike` (production wires this
* to `@suaveplan/vault-sync`'s `SyncEngine`); the orchestrator's job is
* to project the in-memory item rows into the wire-shaped `SyncSnapshot`,
* hydrate the delegate before each operation, and translate the
* `SyncDelegationResult` back into the public `SyncResult` envelope.
*/
export class VaultSync {
private readonly delegate: VaultSyncLike;
private readonly rows: () => VaultItemRow[];
private readonly clock: Clock;
public constructor(options: SyncOptions) {
this.delegate = options.delegate;
this.rows = options.rows;
this.clock = options.clock;
}
/** Pull remote changes via the delegate. */
public async pull(): Promise<SyncResult> {
return this.run("pull", () => this.delegate.pull());
}
/** Push local changes via the delegate. */
public async push(): Promise<SyncResult> {
return this.run("push", () => this.delegate.push());
}
/** Bidirectional sync via the delegate. */
public async sync(): Promise<SyncResult> {
return this.run("sync", () => this.delegate.sync());
}
/** Read the delegate's current snapshot. */
public currentSnapshot(): SyncSnapshot {
return this.delegate.currentSnapshot();
}
private async run(
op: "pull" | "push" | "sync",
fn: () => Promise<SyncDelegationResult>
): Promise<SyncResult> {
const start = this.clock();
// Hydrate the delegate with the latest local snapshot before every
// operation so the merge sees current rows.
this.delegate.hydrate(this.buildSnapshot());
let raw: SyncDelegationResult;
try {
raw = await fn();
} catch (cause) {
this.outcome(op, "failed", start);
throw new VaultError(
VAULT_CODES["SYNC.FAILED"],
"error.vault.sync.failed",
{
cause,
context: { op },
}
);
}
this.outcome(op, "success", start);
return {
merged: raw.merged,
conflicts: raw.conflicts,
deleted: raw.deleted,
durationMs: raw.durationMs,
};
}
private buildSnapshot(): SyncSnapshot {
const items: SyncSnapshotItem[] = this.rows().map((row) => ({
id: row.id,
updatedAt: row.updatedAt,
version: row.version,
deleted: row.deleted,
fieldsJson: row.fieldsJson,
concealedFieldsJson: row.concealedFieldsJson,
tagsJson: row.tagsJson,
}));
return { items, snapshotAt: this.clock() };
}
private outcome(
op: "pull" | "push" | "sync",
status: "success" | "failed" | "rejected",
startedAt: number
): void {
const i = getVaultInstruments();
i.syncOpDurationMs.record(this.clock() - startedAt, { op });
i.syncOutcomeTotal.add(1, { op, status });
}
}

View File

@@ -0,0 +1,91 @@
# vault adapter interfaces
## Purpose
`adapters.ts` defines the Phase-1 adapter contracts the vault orchestration layer uses to talk to blob storage, share-link partitions, and the sync engine. Each contract is a TypeScript `interface` — not a runtime import — so the vault package compiles and tests in isolation without forcing every consumer to pull `@suaveplan/drive`, `@suaveplan/drive-sharing`, and `@suaveplan/vault-sync` transitively.
The interfaces are deliberately narrower than the underlying packages' full surfaces: vault only needs `read` / `write` / `delete` / `exists` from drive (no streaming, no plugins, no middleware), `createLink` / `revokeLink` / `isActive` from drive-sharing, and `hydrate` / `pull` / `push` / `sync` / `currentSnapshot` from sync. Production wiring lives in a thin adapter outside this package; tests inject plain object literals.
## Features
- `DriveLike` — minimal blob-storage surface (4 methods).
- `DriveSharingLike` — share-link surface plus the wrapped-key envelope schema.
- `VaultSyncLike` — sync delegation surface mirroring the `SyncEngine` public API.
- All adapter operations are `Promise`-returning so consumers can route them through any async transport.
## Usage: Basic Example
```ts
import type { DriveLike } from "@suaveplan/vault/types/adapters";
const memory = new Map<string, Uint8Array>();
const drive: DriveLike = {
async read(path) {
const blob = memory.get(path);
if (blob === undefined) throw new Error("not found");
return blob;
},
async write(path, data) {
memory.set(path, data);
},
async delete(path) {
return memory.delete(path);
},
async exists(path) {
return memory.has(path);
},
};
```
## Usage: Advanced Example
```ts
import type {
DriveSharingLike,
CreateShareLinkInput,
CreatedShareLink,
} from "@suaveplan/vault/types/adapters";
class HttpsDriveSharing implements DriveSharingLike {
public constructor(private readonly endpoint: string) {}
public async createLink(input: CreateShareLinkInput): Promise<CreatedShareLink> {
const res = await fetch(`${this.endpoint}/links`, {
method: "POST",
body: JSON.stringify({
partitionId: input.partitionId,
itemId: input.itemId,
expiresAt: input.expiresAt,
maxViews: input.maxViews,
wrappedKeyHex: Buffer.from(input.wrappedKey).toString("hex"),
}),
});
return res.json() as Promise<CreatedShareLink>;
}
public async revokeLink(id: string): Promise<void> {
await fetch(`${this.endpoint}/links/${id}`, { method: "DELETE" });
}
public async isActive(id: string): Promise<boolean> {
const res = await fetch(`${this.endpoint}/links/${id}`);
return res.ok;
}
}
```
## API Reference
- `interface DriveLike` — `{ read, write, delete, exists }`.
- `interface DriveSharingLike` — `{ createLink, revokeLink, isActive }`.
- `interface CreateShareLinkInput` — `{ partitionId, itemId, expiresAt, maxViews, wrappedKey }`.
- `interface CreatedShareLink` — `{ id, url }`.
- `interface SyncSnapshot` — `{ items, snapshotAt }`.
- `interface SyncSnapshotItem` — `{ id, updatedAt, version, deleted, fieldsJson, concealedFieldsJson, tagsJson }`.
- `interface SyncDelegationResult` — `{ merged, conflicts, deleted, durationMs }`.
- `interface VaultSyncLike` — `{ hydrate, pull, push, sync, currentSnapshot }`.
## Implementation Notes
- `DriveLike.write` is contracted as atomic (either replace fully or fail); the vault layer relies on this property when flushing the encrypted SQLite container so a partial write does not corrupt the manifest header.
- `wrappedKey` in `CreateShareLinkInput` is opaque bytes — vault wraps the per-share key with an out-of-band KEK before passing it to drive-sharing. Drive-sharing never sees plaintext key material.
- `SyncSnapshotItem` carries pre-serialised JSON for `fields` / `concealedFields` / `tags`; this keeps the wire shape stable while allowing vault to evolve its in-memory item representation without breaking sync.
- All methods return `Promise` so adapter implementations can be backed by any transport — local filesystem, memory store, IndexedDB, HTTP, gRPC, etc.

View File

@@ -0,0 +1,98 @@
/**
* Phase-1 adapter interfaces for `@suaveplan/vault`.
*
* The vault orchestration layer interacts with the wider monorepo through
* three injectable adapter contracts:
*
* - {@link DriveLike} — bytes-in / bytes-out blob storage (used for the
* encrypted SQLite container and per-attachment ciphertext).
* - {@link DriveSharingLike} — share-link creation / revocation
* (delegates to `@suaveplan/drive-sharing`).
* - {@link VaultSyncLike} — pull / push / bidirectional sync
* (delegates to `@suaveplan/vault-sync`'s `SyncEngine`).
*
* Defining these as interfaces — not as direct re-imports — lets the vault
* package compile and test in isolation: tests inject lightweight stubs,
* production wiring binds the real `@suaveplan/drive` / `drive-sharing` /
* `vault-sync` instances. Only the type signatures are part of the public
* vault surface; the runtime adapter implementations live in their own
* packages.
*/
/** Read/write/delete operations against blob storage. */
export interface DriveLike {
/** Read the full blob at `path` as bytes. Rejects if the path is missing. */
read(path: string): Promise<Uint8Array>;
/** Write the bytes at `path`, replacing any existing blob atomically. */
write(path: string, data: Uint8Array): Promise<void>;
/** Delete the blob at `path`. Returns `true` if a blob was removed. */
delete(path: string): Promise<boolean>;
/** Check whether a blob exists at `path`. */
exists(path: string): Promise<boolean>;
}
/** Options accepted by `DriveSharingLike.createLink`. */
export interface CreateShareLinkInput {
readonly partitionId: string;
readonly itemId: string;
readonly expiresAt: number | null;
readonly maxViews: number | null;
/** Wrapped per-share key envelope produced by the vault layer. */
readonly wrappedKey: Uint8Array;
}
/** Output of `DriveSharingLike.createLink`. */
export interface CreatedShareLink {
readonly id: string;
readonly url: string;
}
/** Share-link operations delegated to `@suaveplan/drive-sharing`. */
export interface DriveSharingLike {
createLink(input: CreateShareLinkInput): Promise<CreatedShareLink>;
revokeLink(linkId: string): Promise<void>;
/** Returns whether a link with `linkId` exists and has not been revoked. */
isActive(linkId: string): Promise<boolean>;
}
/**
* Snapshot exchanged between vault and the sync layer. Treated as opaque
* by `vault` — sync owns the wire-level shape and merge semantics.
*/
export interface SyncSnapshot {
readonly items: readonly SyncSnapshotItem[];
readonly snapshotAt: number;
}
/** A single item entry inside a `SyncSnapshot`. */
export interface SyncSnapshotItem {
readonly id: string;
readonly updatedAt: number;
readonly version: number;
readonly deleted: boolean;
readonly fieldsJson: string;
readonly concealedFieldsJson: string;
readonly tagsJson: string;
}
/** Outcome of a sync operation as exposed to vault callers. */
export interface SyncDelegationResult {
readonly merged: readonly string[];
readonly conflicts: readonly string[];
readonly deleted: readonly string[];
readonly durationMs: number;
}
/** Sync delegation surface consumed by `vault.sync`. */
export interface VaultSyncLike {
/** Apply the local snapshot to the sync layer (e.g. before push). */
hydrate(snapshot: SyncSnapshot): void;
/** Pull remote changes; returns the merge result. */
pull(): Promise<SyncDelegationResult>;
/** Push local changes; returns the push result. */
push(): Promise<SyncDelegationResult>;
/** Bidirectional sync. */
sync(): Promise<SyncDelegationResult>;
/** Read the sync layer's current snapshot view. */
currentSnapshot(): SyncSnapshot;
}

View File

@@ -0,0 +1,81 @@
# vault types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`types.ts` declares the orchestration-level types consumed by every vault module: the lifecycle state union, vault configuration shape, item / attachment / share / sync result shapes, search-query options, and the manifest-header struct. Keeping these in a dedicated `.ts` file decouples them from the runtime modules — sibling modules import only from here, and tests can stub adapters without pulling in the full vault implementation.
The types are mirrors of the upstream Vaulted password-manager domain shapes adjusted for suaveplan: `ItemFields` strictly separates `fields` (searchable plaintext) from `concealedFields` (passwords / PINs / secure notes that are field-level encrypted). `VaultManifest` matches the bitpacker-packed header layout in `manifest/manifest.ts`.
## Features
- `VaultState` — `"locked" | "unlocked"` lifecycle discriminator.
- `VaultConfig` — full open-time configuration including injectable `DriveLike` / `DriveSharingLike` / `VaultSyncLike` adapters, Argon2id parameters, and clock / RNG injection.
- `ItemFields` / `VaultItem` — pre-encryption and persisted forms. `VaultItem` adds `id`, `createdAt`, `updatedAt`, `version`, and `deleted`.
- `SearchQuery` / `ListOptions` — read-side query options.
- `Attachment` / `ShareLink` / `SyncResult` — operation result envelopes.
- `VaultManifest` — bitpacker-packed header struct embedded at the start of the encrypted vault blob.
## Usage: Basic Example
```ts
import type { VaultConfig, VaultItem } from "@suaveplan/vault/types/types";
const config: VaultConfig = {
vaultId: "personal",
email: "user@example.com",
containerPath: "/vault.bin",
attachmentsPrefix: "/attachments",
argon2id: { memoryKB: 65536, iterations: 3, parallelism: 4, outputLength: 32 },
argon2idSalt: crypto.getRandomValues(new Uint8Array(16)),
drive,
driveSharing,
sync,
};
```
## Usage: Advanced Example
```ts
import type { ItemFields, VaultItem, SearchQuery } from "@suaveplan/vault/types/types";
function buildLogin(domain: string, username: string, password: string): ItemFields {
return {
title: domain,
category: "login",
fields: { username, url: `https://${domain}` },
concealedFields: { password },
tags: ["work"],
favorite: false,
};
}
const query: SearchQuery = {
query: "github",
category: "login",
tags: ["work"],
limit: 10,
};
```
## API Reference
- `type VaultState = "locked" | "unlocked"`
- `interface VaultConfig` — see source.
- `interface ItemFields` — pre-encryption item shape.
- `interface VaultItem extends ItemFields` — persisted shape with `id`, `createdAt`, `updatedAt`, `version`, `deleted`.
- `interface SearchQuery` — `{ query?, category?, tags?, favorite?, limit? }`.
- `interface ListOptions` — `{ category?, favorite?, limit?, offset? }`.
- `interface Attachment` — `{ id, itemId, filename, mimeType, size, createdAt }`.
- `interface ShareLink` — `{ id, itemId, url, expiresAt, maxViews, createdAt }`.
- `interface SyncResult` — `{ merged, conflicts, deleted, durationMs }`.
- `interface VaultManifest` — bitpacker header struct.
## Implementation Notes
- `concealedFields` is a plaintext map at the API boundary; the vault layer encrypts each value with the per-item key before persistence and decrypts lazily on read. This keeps the public surface ergonomic while preserving the defence-in-depth boundary inside the SQLite blob.
- `argon2idSalt` is a `Uint8Array` (not `string` / hex) because the manifest layer packs it directly as bytes; conversion to / from hex is the caller's responsibility.
- `VaultManifest.vaultIdHash` is a 32-byte SHA-256 of the `vaultId` — embedded in the manifest so the AAD bound to the AEAD ciphertext includes a stable, non-secret tag identifying the vault.
- `formatVersion`, `kdfId`, `aeadAlgorithm`, `schemaVersion` are integers; the manifest layer constrains them to specific bit widths for forward compatibility.

View File

@@ -0,0 +1,139 @@
/**
* Shared types for `@suaveplan/vault`.
*
* These types describe the orchestration-level shapes that consumers and
* sibling modules see: vault configuration, vault state, item / attachment /
* share / sync records, and result envelopes. Adapter contracts are kept
* separate (see `./adapters.ts`) so they remain swappable in tests and
* Phase-2 integrations.
*/
import type { Argon2idParams } from "@suaveplan/crypto-vault";
import type { Clock, Rng } from "@suaveplan/types";
import type { DriveLike, DriveSharingLike, VaultSyncLike } from "./adapters.js";
/** Vault lifecycle states. */
export type VaultState = "locked" | "unlocked";
/**
* Construction options for {@link VaultConfig}. Captures the vault path on
* the drive, the SQLite filename, the Argon2id parameters, and injectable
* adapters for drive / drive-sharing / vault-sync.
*/
export interface VaultConfig {
/** Stable vault id. Used in HKDF info labels for vault key derivation. */
readonly vaultId: string;
/** User account email — bound into the 2SKD pipeline. */
readonly email: string;
/** Drive path of the encrypted SQLite container blob. */
readonly containerPath: string;
/**
* Drive path prefix for attachments. Each attachment is stored at
* `${attachmentsPrefix}/${attachmentId}`.
*/
readonly attachmentsPrefix: string;
/** Argon2id parameters used by the 2SKD pipeline on unlock. */
readonly argon2id: Argon2idParams;
/**
* 16-byte salt used by Argon2id during 2SKD. Must be persistent across
* lock/unlock cycles for a given account; generated once and stored in
* the manifest.
*/
readonly argon2idSalt: Uint8Array;
/** Pluggable drive adapter — never imported as a hard dep. */
readonly drive: DriveLike;
/** Pluggable drive-sharing adapter for share-link creation/revocation. */
readonly driveSharing: DriveSharingLike;
/** Pluggable sync adapter; passes the in-memory snapshot to sync. */
readonly sync: VaultSyncLike;
/** Wall-clock time source. Defaults to `Date.now`. */
readonly clock?: Clock;
/** Random source. Defaults to `crypto.getRandomValues`. */
readonly rng?: Rng;
/** Debounce window (ms) before a mutation flushes back to drive. */
readonly flushDebounceMs?: number;
/** Hard cap on attachment plaintext size (default 100 MiB). */
readonly maxAttachmentBytes?: number;
}
/**
* Pre-encryption representation of a single vault item, as exposed to
* callers. Concealed-field values are still plaintext at this layer — they
* are encrypted during persistence, decrypted lazily on read.
*/
export interface ItemFields {
readonly title: string;
readonly category: string;
readonly fields: Readonly<Record<string, string>>;
readonly concealedFields: Readonly<Record<string, string>>;
readonly tags: readonly string[];
readonly favorite: boolean;
}
/** Persisted vault item, including timestamps, version, and tombstone flag. */
export interface VaultItem extends ItemFields {
readonly id: string;
readonly createdAt: number;
readonly updatedAt: number;
readonly version: number;
readonly deleted: boolean;
}
/** Search query options accepted by {@link import("../search/search.js").search}. */
export interface SearchQuery {
readonly query?: string;
readonly category?: string;
readonly tags?: readonly string[];
readonly favorite?: boolean;
readonly limit?: number;
}
/** Listing options for `vault.items.list`. */
export interface ListOptions {
readonly category?: string;
readonly favorite?: boolean;
readonly limit?: number;
readonly offset?: number;
}
/** Attachment metadata returned by `vault.attachments`. */
export interface Attachment {
readonly id: string;
readonly itemId: string;
readonly filename: string;
readonly mimeType: string;
readonly size: number;
readonly createdAt: number;
}
/** Share-link record returned by `vault.shares.createLink`. */
export interface ShareLink {
readonly id: string;
readonly itemId: string;
readonly url: string;
readonly expiresAt: number | null;
readonly maxViews: number | null;
readonly createdAt: number;
}
/** Outcome of a `vault.sync.{pull|push|sync}` call. */
export interface SyncResult {
readonly merged: readonly string[];
readonly conflicts: readonly string[];
readonly deleted: readonly string[];
readonly durationMs: number;
}
/** Vault container manifest — packed via bitpacker into the blob header. */
export interface VaultManifest {
readonly formatVersion: number;
readonly kdfId: number;
readonly aeadAlgorithm: number;
readonly vaultIdHash: Uint8Array;
readonly argon2idMemoryKB: number;
readonly argon2idIterations: number;
readonly argon2idParallelism: number;
readonly createdAt: number;
readonly lastModifiedAt: number;
readonly schemaVersion: number;
}

View File

@@ -0,0 +1,77 @@
# vault lifecycle helpers
## Purpose
`lifecycle.ts` extracts the two side-effecting helpers that drive the unlock path: `deriveVaultKeyBuffer` (running 2SKD + key-hierarchy derivation to produce a `SecureBuffer` holding the vault key) and `loadOrCreateContainer` (reading the encrypted blob from drive, or — if missing — creating a fresh container and flushing it back). Splitting them into a sibling file keeps `vault.ts` under the suaveplan 300-line cap while preserving a single-class orchestrator.
The helpers are deliberately self-contained: they take inputs, return outputs, and rely only on the public surfaces of `crypto-vault`, `crypto-utils`, and the local `container/` and `items/migrations/` modules. They do not touch the lifecycle state machine; the caller (the `Vault` class) is responsible for ordering the calls correctly and zeroizing the returned buffer on lock.
## Features
- `deriveVaultKeyBuffer({ password, secretKey, email, salt, params, vaultId, rng? })` — full 2SKD + personal-keyset + vault-key derivation, returning a `SecureBuffer`.
- `loadOrCreateContainer({ config, buffer, now, rng? })` — drive read + decrypt, or pristine create + flush; runs head migrations.
- Intermediate AUK / personal keyset / vault-key buffers are zeroized before return — only the wrapped `SecureBuffer` survives.
- Migrations are applied in lockstep with the load so the returned state is at the head schema version.
## Usage: Basic Example
```ts
import { deriveVaultKeyBuffer, loadOrCreateContainer } from "@suaveplan/vault/vault/lifecycle";
const buffer = await deriveVaultKeyBuffer({
password,
secretKey,
email: "user@example.com",
salt: argon2idSalt,
params: { memoryKB: 65536, iterations: 3, parallelism: 4, outputLength: 32 },
vaultId: "personal",
});
const { manifest, state } = await loadOrCreateContainer({
config,
buffer,
now: () => Date.now(),
});
```
## Usage: Advanced Example
```ts
import { deriveVaultKeyBuffer, loadOrCreateContainer } from "@suaveplan/vault/vault/lifecycle";
import { wrap } from "@suaveplan/crypto-vault";
// Inject a deterministic RNG for testing.
const buffer = await deriveVaultKeyBuffer({
password: "p",
secretKey: "s",
email: "u@e.com",
salt: new Uint8Array(16),
params,
vaultId: "v",
rng: deterministicRng,
});
// Wrap as an opaque handle before crossing into the orchestrator.
const handle = wrap(buffer);
const result = await loadOrCreateContainer({
config,
buffer,
now: () => 1_700_000_000_000,
rng: deterministicRng,
});
```
## API Reference
- `deriveVaultKeyBuffer(input: DeriveVaultKeyInput): Promise<SecureBuffer>`.
- `loadOrCreateContainer(input: LoadOrCreateInput): Promise<LoadOrCreateResult>`.
- `interface DeriveVaultKeyInput` — `{ password, secretKey, email, salt, params, vaultId, rng? }`.
- `interface LoadOrCreateInput` — `{ config, buffer, now, rng? }`.
- `interface LoadOrCreateResult` — `{ manifest, state }`.
## Implementation Notes
- The AUK derived inside `deriveVaultKeyBuffer` is zeroized immediately after the personal keyset is produced. The personal keyset's three keys are also zeroized after deriving the vault key. Only the vault key survives, wrapped in a fresh `SecureBuffer`.
- `loadOrCreateContainer` always returns a state at the head schema version. New migrations added to `VAULT_MIGRATIONS` are applied transparently on the next unlock — old containers self-upgrade without explicit caller intervention.
- The `now` callback is invoked at most twice during a fresh-container creation (once for `createEmptyContainer`, once for `saveContainer`); production passes `Date.now`. Tests can pin a fixed timestamp through this hook.
- A first-time unlock (drive blob missing) writes the freshly-encrypted blob back to drive before returning. This guarantees a successful unlock leaves drive in a valid state — the next read will decrypt against the same salt and Argon2id parameters.

View File

@@ -0,0 +1,242 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { DriveLike } from "../types/adapters.js";
import type { VaultConfig } from "../types/types.js";
import { deriveVaultKeyBuffer, loadOrCreateContainer } from "./lifecycle.js";
function memoryDrive(): DriveLike & { blobs: Map<string, Uint8Array> } {
const blobs = new Map<string, Uint8Array>();
return {
blobs,
async read(path) {
const v = blobs.get(path);
if (v === undefined) throw new Error(`missing ${path}`);
return v;
},
async write(path, data) {
blobs.set(path, new Uint8Array(data));
},
async delete(path) {
return blobs.delete(path);
},
async exists(path) {
return blobs.has(path);
},
};
}
function makeConfig(drive: DriveLike): VaultConfig {
return {
vaultId: "v",
email: "u@example.com",
containerPath: "/v.bin",
attachmentsPrefix: "/att",
argon2id: {
memoryKB: 65536,
iterations: 3,
parallelism: 1,
outputLength: 32,
},
argon2idSalt: new Uint8Array(16),
drive,
driveSharing: {
async createLink() {
return { id: "x", url: "https://x.example/x" };
},
async revokeLink() {},
async isActive() {
return true;
},
},
sync: {
hydrate() {},
async pull() {
return {
merged: [],
conflicts: [],
deleted: [],
durationMs: 0,
};
},
async push() {
return {
merged: [],
conflicts: [],
deleted: [],
durationMs: 0,
};
},
async sync() {
return {
merged: [],
conflicts: [],
deleted: [],
durationMs: 0,
};
},
currentSnapshot() {
return { items: [], snapshotAt: 0 };
},
},
};
}
describe("vault lifecycle helpers", () => {
it("deriveVaultKeyBuffer produces a SecureBuffer of length 32", {
timeout: 30000,
}, async () => {
const buffer = await deriveVaultKeyBuffer({
password: "pass",
secretKey: "secret",
email: "u@example.com",
salt: new Uint8Array(16),
params: {
memoryKB: 65536,
iterations: 3,
parallelism: 1,
outputLength: 32,
},
vaultId: "v",
});
expect(buffer.length).toBe(32);
buffer.close();
});
it("deriveVaultKeyBuffer is deterministic for fixed inputs", {
timeout: 30000,
}, async () => {
const a = await deriveVaultKeyBuffer({
password: "pass",
secretKey: "secret",
email: "u@example.com",
salt: new Uint8Array(16),
params: {
memoryKB: 65536,
iterations: 3,
parallelism: 1,
outputLength: 32,
},
vaultId: "v",
});
const b = await deriveVaultKeyBuffer({
password: "pass",
secretKey: "secret",
email: "u@example.com",
salt: new Uint8Array(16),
params: {
memoryKB: 65536,
iterations: 3,
parallelism: 1,
outputLength: 32,
},
vaultId: "v",
});
expect(a.bytes).toEqual(b.bytes);
a.close();
b.close();
});
it("loadOrCreateContainer creates+flushes when blob is missing", {
timeout: 30000,
}, async () => {
const drive = memoryDrive();
const config = makeConfig(drive);
const buffer = await deriveVaultKeyBuffer({
password: "p",
secretKey: "s",
email: config.email,
salt: config.argon2idSalt,
params: config.argon2id,
vaultId: config.vaultId,
});
const result = await loadOrCreateContainer({
config,
buffer,
now: () => 1,
});
expect(result.manifest.formatVersion).toBe(1);
expect(drive.blobs.has(config.containerPath)).toBe(true);
buffer.close();
});
it("loadOrCreateContainer reads and migrates an existing blob", {
timeout: 30000,
}, async () => {
const drive = memoryDrive();
const config = makeConfig(drive);
const buffer = await deriveVaultKeyBuffer({
password: "p",
secretKey: "s",
email: config.email,
salt: config.argon2idSalt,
params: config.argon2id,
vaultId: config.vaultId,
});
await loadOrCreateContainer({
config,
buffer,
now: () => 1,
});
// Second call should read the existing blob.
const second = await loadOrCreateContainer({
config,
buffer,
now: () => 2,
});
expect(second.state.schemaVersion).toBeGreaterThan(0);
buffer.close();
});
it("deriveVaultKeyBuffer forwards an injected RNG", {
timeout: 30000,
}, async () => {
const rng = {
nextUint32: () => 1,
nextFloat: () => 0,
nextBytes: (len: number) => new Uint8Array(len).fill(2),
};
const buffer = await deriveVaultKeyBuffer({
password: "p",
secretKey: "s",
email: "u@example.com",
salt: new Uint8Array(16),
params: {
memoryKB: 65536,
iterations: 3,
parallelism: 1,
outputLength: 32,
},
vaultId: "v",
rng,
});
expect(buffer.length).toBe(32);
buffer.close();
});
it("loadOrCreateContainer with injected RNG creates the blob", {
timeout: 30000,
}, async () => {
const drive = memoryDrive();
const config = makeConfig(drive);
const rng = {
nextUint32: () => 1,
nextFloat: () => 0,
nextBytes: (len: number) => new Uint8Array(len).fill(3),
};
const buffer = await deriveVaultKeyBuffer({
password: "p",
secretKey: "s",
email: config.email,
salt: config.argon2idSalt,
params: config.argon2id,
vaultId: config.vaultId,
});
await loadOrCreateContainer({
config,
buffer,
now: () => 1,
rng,
});
expect(drive.blobs.has(config.containerPath)).toBe(true);
buffer.close();
});
});

View File

@@ -0,0 +1,114 @@
import { zeroize } from "@suaveplan/crypto-utils";
import {
deriveAuk,
derivePersonalKeyset,
deriveVaultKey,
SecureBuffer,
} from "@suaveplan/crypto-vault";
import type { Rng } from "@suaveplan/types";
import {
createEmptyContainer,
loadContainer,
saveContainer,
} from "../container/container.js";
import {
runMigrations,
type VaultContainerState,
} from "../items/migrations/migrations.js";
import type { VaultConfig, VaultManifest } from "../types/types.js";
/** Inputs for {@link deriveVaultKeyBuffer}. */
export interface DeriveVaultKeyInput {
readonly password: string;
readonly secretKey: string;
readonly email: string;
readonly salt: Uint8Array;
readonly params: VaultConfig["argon2id"];
readonly vaultId: string;
readonly rng?: Rng;
}
/**
* Run the 2SKD pipeline (Argon2id over an HMAC-mixed `(password, secretKey,
* email)`) to obtain the AUK, then derive the personal keyset and a per-vault
* key. Returns a `SecureBuffer` holding the vault key; intermediate buffers
* are zeroized before return.
*/
export async function deriveVaultKeyBuffer(
input: DeriveVaultKeyInput
): Promise<SecureBuffer> {
const auk = await deriveAuk(
input.password,
input.secretKey,
input.email,
input.salt,
{
memoryKB: input.params.memoryKB,
iterations: input.params.iterations,
parallelism: input.params.parallelism,
outputLength: 32,
},
input.rng !== undefined ? { rng: input.rng } : {}
);
const personal = derivePersonalKeyset(auk);
const vaultKey = deriveVaultKey(personal, input.vaultId);
zeroize(auk);
zeroize(personal.encryptKey);
zeroize(personal.signKey);
zeroize(personal.kxKey);
const buffer = new SecureBuffer(vaultKey);
zeroize(vaultKey);
return buffer;
}
/** Inputs for {@link loadOrCreateContainer}. */
export interface LoadOrCreateInput {
readonly config: VaultConfig;
readonly buffer: SecureBuffer;
readonly now: () => number;
readonly rng?: Rng;
}
/** Outcome of {@link loadOrCreateContainer}. */
export interface LoadOrCreateResult {
readonly manifest: VaultManifest;
readonly state: VaultContainerState;
}
/**
* Load the encrypted container blob from drive, or — if missing — create a
* fresh empty container and flush it back atomically. Migrations are
* applied to the loaded state so the head schema is always in effect after
* unlock.
*/
export async function loadOrCreateContainer(
input: LoadOrCreateInput
): Promise<LoadOrCreateResult> {
const exists = await input.config.drive.exists(input.config.containerPath);
if (exists) {
const loaded = await loadContainer(
input.config.drive,
input.config.containerPath,
input.buffer.bytes
);
runMigrations(loaded.state, undefined, input.now);
return { manifest: loaded.manifest, state: loaded.state };
}
const fresh = createEmptyContainer({
vaultId: input.config.vaultId,
argon2idMemoryKB: input.config.argon2id.memoryKB,
argon2idIterations: input.config.argon2id.iterations,
argon2idParallelism: input.config.argon2id.parallelism,
now: input.now(),
});
const refreshed = await saveContainer({
drive: input.config.drive,
path: input.config.containerPath,
manifest: fresh.manifest,
state: fresh.state,
vaultKey: input.buffer.bytes,
now: input.now(),
...(input.rng !== undefined ? { rng: input.rng } : {}),
});
return { manifest: refreshed, state: fresh.state };
}

View File

@@ -0,0 +1,103 @@
# Vault
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - wiring — `wiring.ts` extracts the per-unlock module…
- [wiring](./wiring.md) — `wiring.ts` extracts the per-unlock module factories used by `Vault`: `buildItems`, `buildSearch`, `buildAttachments`,…
- wiring — `wiring.ts` extracts the per-unlock module…
- [wiring](./wiring.md) — `wiring.ts` extracts the per-unlock module factories used by `Vault`: `buildItems`, `buildSearch`, `buildAttachments`,…
- wiring — `wiring.ts` extracts the per-unlock module…
- [wiring](./wiring.md) — `wiring.ts` extracts the per-unlock module factories used by `Vault`: `buildItems`, `buildSearch`, `buildAttachments`,…
- wiring — `wiring.ts` extracts the per-unlock module…
- [wiring](./wiring.md) — `wiring.ts` extracts the per-unlock module factories used by `Vault`: `buildItems`, `buildSearch`, `buildAttachments`,…
- [wiring](./wiring.md) — `wiring.ts` extracts the per-unlock module factories used by `Vault`: `buildItems`, `buildSearch`, `buildAttachments`,…
## Purpose
`vault.ts` exposes the `Vault` class — the public orchestration entry point for `@suaveplan/vault`. It owns the lifecycle state machine (`locked` ↔ `unlocked`), the in-memory container state once unlocked, the `OpaqueKeyHandle` to the vault key (sourced from `@suaveplan/crypto-vault`), and the per-unlock module facades (`VaultItems`, `VaultSearch`, `VaultAttachments`, `VaultShares`, `VaultSync`).
Every public read/write surface routes through `requireUnlocked` so a locked vault rejects every CRUD / sync call with `VAULT.LOCKED`. Mutations notify the flush scheduler; `lock()` drains pending flushes, zeroizes every key buffer via `crypto-vault`'s `zeroizeAll`, and resets the orchestrator into a clean locked state.
## Features
- `Vault.open(config)` — construct a locked vault. Does not touch drive.
- `unlock(password, secretKey)` — derive vault key via 2SKD + key hierarchy, decrypt the drive blob (or create+flush a fresh one on first unlock), run pending migrations, and instantiate module facades.
- `lock()` — drain pending flushes, zeroize keys, reset state. Idempotent.
- `isLocked` / `currentState` — readonly state inspection.
- `items` / `search` / `attachments` / `shares` / `sync` getters — return module facades; throw `VAULT.LOCKED` when locked.
- `currentManifest()` — read the active manifest snapshot.
- `flush()` — force a pending flush immediately (skips the debounce window).
- `VAULT_HEAD_SCHEMA_VERSION` — head schema version constant for client compatibility checks.
## Usage: Basic Example
```ts
import { Vault } from "@suaveplan/vault/vault/vault";
const vault = Vault.open({
vaultId: "personal",
email: "user@example.com",
containerPath: "/personal.vault",
attachmentsPrefix: "/personal/attachments",
argon2id: { memoryKB: 65536, iterations: 3, parallelism: 4, outputLength: 32 },
argon2idSalt: salt,
drive,
driveSharing,
sync: syncEngine,
});
await vault.unlock(password, secretKey);
const item = vault.items.create({ title: "GitHub", category: "login", fields: { username: "u" }, concealedFields: { password: "p" }, tags: [], favorite: false });
await vault.lock();
```
## Usage: Advanced Example
```ts
import { Vault } from "@suaveplan/vault/vault/vault";
import { VaultError, VAULT_CODES } from "@suaveplan/vault/errors";
const vault = Vault.open(config);
try {
await vault.unlock(password, secretKey);
} catch (err) {
if (VaultError.is(err) && err.code === VAULT_CODES.INVALID_CREDENTIALS) {
return promptForPassword();
}
throw err;
}
vault.items.create(item1);
vault.items.create(item2);
await vault.flush(); // force flush before lock
await vault.lock(); // zeroizes keys
// Locked vault rejects every operation.
expect(() => vault.items.list()).toThrow();
```
## API Reference
- `class Vault`.
- `static open(config: VaultConfig): Vault` — construct locked.
- `unlock(password, secretKey): Promise<void>` — throws `ALREADY_OPEN` / `INVALID_CREDENTIALS` / propagates `MANIFEST_INVALID`.
- `lock(): Promise<void>` — idempotent.
- `flush(): Promise<void>` — force flush; throws `LOCKED` if locked.
- `get isLocked: boolean` / `get currentState: VaultState`.
- `get items: VaultItems` / `get search: VaultSearch` / `get attachments: VaultAttachments` / `get shares: VaultShares` / `get sync: VaultSync` — all throw `LOCKED` when locked.
- `currentManifest(): VaultManifest` — throws `LOCKED` when locked.
- `VAULT_HEAD_SCHEMA_VERSION: number` — current head schema version.
## Implementation Notes
- The vault key never leaves the `SecureBuffer` envelope wrapped in an `OpaqueKeyHandle`. Every per-call access goes through `unwrap(handle)` and obtains the same backing buffer; on `lock()`, `zeroizeAll()` from `crypto-vault` walks the live-buffer registry and overwrites every key with zero bytes.
- Mutation callbacks from items / attachments / shares schedule a debounced flush (default 500 ms; configurable via `VaultConfig.flushDebounceMs`). The flush re-encrypts the container blob (manifest as AAD) and writes it back atomically through `DriveLike.write`.
- `unlock` is **not** idempotent — calling it twice while unlocked throws `VAULT.ALREADY_OPEN`. This is deliberate: a re-unlock with stale credentials must fail loudly rather than silently re-derive against a different vault key.
- `lock()` is idempotent. A second call on an already-locked vault is a no-op (records a `locked` lifecycle outcome and returns).
- Failure during the unlock decryption path closes the `SecureBuffer`, drops the handle, and throws `VAULT.INVALID_CREDENTIALS` — even if the underlying error was a manifest issue, the wrong vault key was the cause from the user's perspective. `VaultError`s thrown from inside the load (e.g. a corrupt manifest) propagate unchanged so callers can distinguish corruption from credential mismatch.
- The OTel `lifecycleOutcomeTotal` counter uses a fixed allowlist (`opened | unlocked | locked | denied`); adding new statuses requires an OpenSpec change in lockstep with `getVaultInstruments`.

View File

@@ -0,0 +1,464 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VAULT_CODES } from "../errors/codes.js";
import { VaultError } from "../errors/vault-error.js";
import type {
DriveLike,
DriveSharingLike,
SyncDelegationResult,
SyncSnapshot,
VaultSyncLike,
} from "../types/adapters.js";
import type { VaultConfig } from "../types/types.js";
import { Vault } from "./vault.js";
function memoryDrive(): DriveLike & { blobs: Map<string, Uint8Array> } {
const blobs = new Map<string, Uint8Array>();
return {
blobs,
async read(path) {
const v = blobs.get(path);
if (v === undefined) throw new Error(`missing ${path}`);
return v;
},
async write(path, data) {
blobs.set(path, new Uint8Array(data));
},
async delete(path) {
return blobs.delete(path);
},
async exists(path) {
return blobs.has(path);
},
};
}
class NoopSharing implements DriveSharingLike {
public async createLink(
_input: never
): Promise<{ id: string; url: string }> {
return { id: "x", url: "https://x.example/x" };
}
public async revokeLink(): Promise<void> {}
public async isActive(): Promise<boolean> {
return true;
}
}
class NoopSync implements VaultSyncLike {
public lastSnapshot: SyncSnapshot = { items: [], snapshotAt: 0 };
public hydrate(snapshot: SyncSnapshot): void {
this.lastSnapshot = snapshot;
}
public async pull(): Promise<SyncDelegationResult> {
return { merged: [], conflicts: [], deleted: [], durationMs: 0 };
}
public async push(): Promise<SyncDelegationResult> {
return { merged: [], conflicts: [], deleted: [], durationMs: 0 };
}
public async sync(): Promise<SyncDelegationResult> {
return { merged: [], conflicts: [], deleted: [], durationMs: 0 };
}
public currentSnapshot(): SyncSnapshot {
return this.lastSnapshot;
}
}
function makeConfig(overrides: Partial<VaultConfig> = {}): VaultConfig {
const drive = memoryDrive();
return {
vaultId: "personal",
email: "user@example.com",
containerPath: "/personal.vault",
attachmentsPrefix: "/personal/att",
argon2id: {
memoryKB: 65536,
iterations: 3,
parallelism: 1,
outputLength: 32,
},
argon2idSalt: new Uint8Array(16),
drive,
driveSharing: new NoopSharing(),
sync: new NoopSync(),
flushDebounceMs: 5,
...overrides,
};
}
describe("Vault lifecycle", () => {
it("open returns a locked vault without touching drive", {
timeout: 30000,
}, () => {
const config = makeConfig();
const vault = Vault.open(config);
expect(vault.isLocked).toBe(true);
expect(vault.currentState).toBe("locked");
expect(
(config.drive as ReturnType<typeof memoryDrive>).blobs.size
).toBe(0);
});
it("unlock + lock cycle: locked vault rejects items", {
timeout: 30000,
}, async () => {
const vault = Vault.open(makeConfig());
await vault.unlock("pass", "secret");
expect(vault.isLocked).toBe(false);
await vault.lock();
expect(vault.isLocked).toBe(true);
try {
vault.items.list();
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES.LOCKED);
}
});
it("double unlock throws ALREADY_OPEN", { timeout: 30000 }, async () => {
const vault = Vault.open(makeConfig());
await vault.unlock("pass", "secret");
try {
await vault.unlock("pass", "secret");
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES.ALREADY_OPEN);
}
await vault.lock();
});
it("first unlock writes a fresh container blob to drive", {
timeout: 30000,
}, async () => {
const config = makeConfig();
const vault = Vault.open(config);
await vault.unlock("pass", "secret");
expect(
(config.drive as ReturnType<typeof memoryDrive>).blobs.has(
"/personal.vault"
)
).toBe(true);
await vault.lock();
});
it("second unlock with same credentials decrypts the existing blob", {
timeout: 30000,
}, async () => {
const config = makeConfig();
const vault = Vault.open(config);
await vault.unlock("pass", "secret");
const created = vault.items.create({
title: "GitHub",
category: "login",
fields: { username: "u" },
concealedFields: { password: "p" },
tags: [],
favorite: false,
});
await vault.flush();
await vault.lock();
const v2 = Vault.open(config);
await v2.unlock("pass", "secret");
const fetched = v2.items.get(created.id);
expect(fetched?.concealedFields.password).toBe("p");
await v2.lock();
});
it("wrong password rejects with DECRYPT_FAILED (AEAD authentication)", {
timeout: 30000,
}, async () => {
const config = makeConfig();
const v1 = Vault.open(config);
await v1.unlock("pass", "secret");
await v1.flush();
await v1.lock();
const v2 = Vault.open(config);
try {
await v2.unlock("wrong", "secret");
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
// Wrong password and tampered ciphertext are indistinguishable
// at the AEAD layer; both surface DECRYPT_FAILED. UI consumers
// present this as "wrong password" because manifest tampering
// is essentially impossible without also tampering the ciphertext.
expect((err as VaultError).code).toBe(VAULT_CODES.DECRYPT_FAILED);
}
expect(v2.isLocked).toBe(true);
});
it("non-VaultError failures during unlock surface as INVALID_CREDENTIALS", {
timeout: 30000,
}, async () => {
const sentinel: unknown = "non-error string";
const config = makeConfig({
drive: {
async read() {
throw new Error("not used");
},
async write() {},
async delete() {
return false;
},
async exists() {
throw sentinel;
},
},
});
const vault = Vault.open(config);
try {
await vault.unlock("pass", "secret");
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES.INVALID_CREDENTIALS
);
}
});
it("lock is idempotent on already-locked vaults", {
timeout: 30000,
}, async () => {
const vault = Vault.open(makeConfig());
await vault.lock();
await vault.lock();
expect(vault.isLocked).toBe(true);
});
it("every getter throws VAULT.LOCKED when locked", {
timeout: 30000,
}, () => {
const vault = Vault.open(makeConfig());
const ops: Array<[string, () => unknown]> = [
["items", () => vault.items],
["search", () => vault.search],
["attachments", () => vault.attachments],
["shares", () => vault.shares],
["sync", () => vault.sync],
["currentManifest", () => vault.currentManifest()],
];
for (const [, fn] of ops) {
try {
fn();
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(VAULT_CODES.LOCKED);
}
}
});
it("flush throws when locked", { timeout: 30000 }, async () => {
const vault = Vault.open(makeConfig());
try {
await vault.flush();
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
}
});
it("currentManifest returns the active manifest when unlocked", {
timeout: 30000,
}, async () => {
const vault = Vault.open(makeConfig());
await vault.unlock("pass", "secret");
const manifest = vault.currentManifest();
expect(manifest.formatVersion).toBe(1);
await vault.lock();
});
it("items.create + flush persists to drive; lock survives without flush", {
timeout: 30000,
}, async () => {
const config = makeConfig();
const vault = Vault.open(config);
await vault.unlock("pass", "secret");
vault.items.create({
title: "x",
category: "login",
fields: {},
concealedFields: {},
tags: [],
favorite: false,
});
await vault.lock(); // Lock drains the pending flush.
const v2 = Vault.open(config);
await v2.unlock("pass", "secret");
expect(v2.items.list().length).toBe(1);
await v2.lock();
});
it("tampered drive blob fails the second unlock with DECRYPT_FAILED", {
timeout: 30000,
}, async () => {
const config = makeConfig();
const drive = config.drive as ReturnType<typeof memoryDrive>;
const v1 = Vault.open(config);
await v1.unlock("pass", "secret");
await v1.flush();
await v1.lock();
// Tamper with the ciphertext (after manifest + nonce).
const blob = drive.blobs.get("/personal.vault") as Uint8Array;
const tampered = new Uint8Array(blob);
tampered[tampered.length - 1] ^= 1;
drive.blobs.set("/personal.vault", tampered);
const v2 = Vault.open(config);
try {
await v2.unlock("pass", "secret");
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
// Decrypt failure is wrapped — DECRYPT_FAILED propagates.
expect((err as VaultError).code).toBe(VAULT_CODES.DECRYPT_FAILED);
}
});
it("lock zeroizes the in-memory key and re-unlock re-derives", {
timeout: 30000,
}, async () => {
const config = makeConfig();
const vault = Vault.open(config);
await vault.unlock("pass", "secret");
await vault.lock();
await vault.unlock("pass", "secret");
await vault.lock();
expect(vault.isLocked).toBe(true);
});
it("every facade getter returns a working module when unlocked", {
timeout: 30000,
}, async () => {
const vault = Vault.open(makeConfig());
await vault.unlock("pass", "secret");
expect(vault.items).toBeDefined();
expect(vault.search).toBeDefined();
expect(vault.attachments).toBeDefined();
expect(vault.shares).toBeDefined();
expect(vault.sync).toBeDefined();
expect(vault.currentManifest()).toBeDefined();
expect(vault.currentState).toBe("unlocked");
await vault.lock();
});
it("scheduled flush fires after the debounce window", {
timeout: 30000,
}, async () => {
const vault = Vault.open(makeConfig({ flushDebounceMs: 1 }));
await vault.unlock("pass", "secret");
vault.items.create({
title: "x",
category: "login",
fields: {},
concealedFields: {},
tags: [],
favorite: false,
});
// Wait long enough for the debounced flush to fire.
await new Promise((r) => setTimeout(r, 20));
await vault.lock();
expect(vault.isLocked).toBe(true);
});
it("Vault.open accepts an injected RNG and clock", {
timeout: 30000,
}, async () => {
let now = 1_000;
const rng = {
nextUint32: () => 1,
nextFloat: () => 0,
nextBytes: (len: number) => new Uint8Array(len).fill(4),
};
const config: VaultConfig = makeConfig({
clock: () => now++,
rng,
});
// Drop flushDebounceMs so the constructor's `?? DEFAULT` branch fires.
delete (config as Partial<VaultConfig>).flushDebounceMs;
const vault = Vault.open(config);
await vault.unlock("p", "s");
vault.items.create({
title: "t",
category: "login",
fields: {},
concealedFields: {},
tags: [],
favorite: false,
});
// Two consecutive mutations exercise the `flushTimer !== null` branch.
vault.items.create({
title: "t2",
category: "login",
fields: {},
concealedFields: {},
tags: [],
favorite: false,
});
await vault.lock();
expect(vault.isLocked).toBe(true);
});
it("flushNow returns early if state is locked or manifest is null", {
timeout: 30000,
}, async () => {
const vault = Vault.open(makeConfig());
await vault.unlock("pass", "secret");
// Force the state to locked + manifest/container null then trigger
// flushNow via the private setTimeout callback. We simulate this by
// directly invoking flushNow over reflection.
const internal = vault as unknown as {
state: string;
manifest: unknown;
container: unknown;
flushNow(): Promise<void>;
};
internal.state = "locked";
await internal.flushNow();
internal.state = "unlocked";
internal.manifest = null;
await internal.flushNow();
internal.manifest = {};
internal.container = null;
await internal.flushNow();
// Restore lock state for clean teardown.
internal.state = "locked";
});
// Defensive-branch regression: if internal state is corrupted such that
// the vault is `unlocked` but the OpaqueKeyHandle has been cleared,
// unwrappedKey() must surface VAULT.OPAQUE_HANDLE_INVALID rather than
// dereferencing a null handle. We exercise this by opening, unlocking,
// forcibly clearing the private vaultKeyHandle, then triggering a
// wiring path that calls vaultKey().
it("unwrappedKey throws OPAQUE_HANDLE_INVALID when handle is missing", {
timeout: 30000,
}, async () => {
const vault = Vault.open(makeConfig());
await vault.unlock("pass", "secret");
// Force the defensive branch: clear the private handle while the
// state machine still reports unlocked.
(vault as unknown as { vaultKeyHandle: null }).vaultKeyHandle = null;
let captured: unknown = null;
try {
// The items getter resolves wiringInputs().vaultKey() during
// buildItems(), which calls unwrappedKey() — exactly the code
// path under test.
void vault.items;
} catch (err) {
captured = err;
}
expect(VaultError.is(captured)).toBe(true);
expect((captured as VaultError).code).toBe(
VAULT_CODES.OPAQUE_HANDLE_INVALID
);
});
});

View File

@@ -0,0 +1,261 @@
import {
type OpaqueKeyHandle,
type SecureBuffer,
unwrap,
wrap,
zeroizeAll,
} from "@suaveplan/crypto-vault";
import type { Clock, Rng } from "@suaveplan/types";
import type { VaultAttachments } from "../attachments/attachments.js";
import { saveContainer } from "../container/container.js";
import { VAULT_CODES } from "../errors/codes.js";
import { VaultError } from "../errors/vault-error.js";
import type { VaultItems } from "../items/items.js";
import {
headSchemaVersion,
type VaultContainerState,
} from "../items/migrations/migrations.js";
import { getVaultInstruments } from "../otel/instruments.js";
import type { VaultSearch } from "../search/search.js";
import type { VaultShares } from "../shares/shares.js";
import type { VaultSync } from "../sync/sync.js";
import type { VaultConfig, VaultManifest, VaultState } from "../types/types.js";
import { deriveVaultKeyBuffer, loadOrCreateContainer } from "./lifecycle.js";
import {
buildAttachments,
buildItems,
buildSearch,
buildShares,
buildSync,
freshRuntimeIndex,
type RuntimeIndex,
type WiringInputs,
} from "./wiring.js";
const DEFAULT_FLUSH_DEBOUNCE_MS = 500;
/**
* The orchestration `Vault` class. Owns the lifecycle state machine
* (`locked` / `unlocked`), the in-memory container state once unlocked,
* and the per-unlock instances of the module facades. Locked vaults
* reject every CRUD / sync operation with `VAULT.LOCKED`.
*/
export class Vault {
private state: VaultState = "locked";
private manifest: VaultManifest | null = null;
private container: VaultContainerState | null = null;
private vaultKeyHandle: OpaqueKeyHandle | null = null;
private runtime: RuntimeIndex | null = null;
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private flushPending = false;
private readonly clock: Clock;
private readonly rng: Rng | undefined;
private readonly flushDebounceMs: number;
private constructor(private readonly config: VaultConfig) {
this.clock = config.clock ?? defaultClock;
this.rng = config.rng;
this.flushDebounceMs =
config.flushDebounceMs ?? DEFAULT_FLUSH_DEBOUNCE_MS;
}
/** Construct a new locked vault from `config`. Does not touch drive. */
public static open(config: VaultConfig): Vault {
return new Vault(config);
}
/** Whether the vault is currently locked. */
public get isLocked(): boolean {
return this.state === "locked";
}
/** Current lifecycle state. */
public get currentState(): VaultState {
return this.state;
}
/**
* Unlock the vault: derive the AUK / personal keyset / vault key via
* 2SKD, decrypt the container blob from drive, run pending migrations,
* and instantiate the per-unlock module facades.
*/
public async unlock(password: string, secretKey: string): Promise<void> {
const start = this.clock();
if (this.state === "unlocked") {
this.lifecycleOutcome("denied");
throw new VaultError(
VAULT_CODES.ALREADY_OPEN,
"error.vault.already-open"
);
}
const buffer = await deriveVaultKeyBuffer({
password,
secretKey,
email: this.config.email,
salt: this.config.argon2idSalt,
params: this.config.argon2id,
vaultId: this.config.vaultId,
...(this.rng !== undefined ? { rng: this.rng } : {}),
});
this.vaultKeyHandle = wrap(buffer);
try {
const result = await loadOrCreateContainer({
config: this.config,
buffer,
now: () => this.clock(),
...(this.rng !== undefined ? { rng: this.rng } : {}),
});
this.manifest = result.manifest;
this.container = result.state;
this.runtime = freshRuntimeIndex();
} catch (err) {
buffer.close();
this.vaultKeyHandle = null;
this.lifecycleOutcome("denied");
if (VaultError.is(err)) throw err;
throw new VaultError(
VAULT_CODES.INVALID_CREDENTIALS,
"error.vault.invalid-credentials",
{ cause: err }
);
}
this.state = "unlocked";
getVaultInstruments().unlockDurationMs.record(this.clock() - start);
this.lifecycleOutcome("unlocked");
}
/**
* Lock the vault: flush pending mutations, encrypt the container,
* write back to drive, zeroize every key buffer, and reset the
* orchestrator into the locked state.
*/
public async lock(): Promise<void> {
const start = this.clock();
if (this.state === "locked") {
this.lifecycleOutcome("locked");
return;
}
if (this.flushTimer !== null) clearTimeout(this.flushTimer);
this.flushTimer = null;
if (this.flushPending) await this.flushNow();
this.flushPending = false;
this.state = "locked";
this.manifest = null;
this.container = null;
this.runtime = null;
this.vaultKeyHandle = null;
zeroizeAll();
getVaultInstruments().lockDurationMs.record(this.clock() - start);
this.lifecycleOutcome("locked");
}
/** Items repository. Throws `VAULT.LOCKED` when the vault is locked. */
public get items(): VaultItems {
return this.requireUnlocked(() => buildItems(this.wiringInputs()));
}
/** Search repository. Throws `VAULT.LOCKED` when locked. */
public get search(): VaultSearch {
return this.requireUnlocked(() => buildSearch(this.wiringInputs()));
}
/** Attachments repository. Throws `VAULT.LOCKED` when locked. */
public get attachments(): VaultAttachments {
return this.requireUnlocked(() =>
buildAttachments(this.wiringInputs())
);
}
/** Shares repository. Throws `VAULT.LOCKED` when locked. */
public get shares(): VaultShares {
return this.requireUnlocked(() => buildShares(this.wiringInputs()));
}
/** Sync orchestrator. Throws `VAULT.LOCKED` when locked. */
public get sync(): VaultSync {
return this.requireUnlocked(() => buildSync(this.wiringInputs()));
}
/** Current manifest snapshot. Throws `VAULT.LOCKED` when locked. */
public currentManifest(): VaultManifest {
return this.requireUnlocked(() => this.manifest as VaultManifest);
}
/** Force-flush any pending mutation immediately. */
public async flush(): Promise<void> {
this.requireUnlocked(() => undefined);
if (this.flushTimer !== null) clearTimeout(this.flushTimer);
this.flushTimer = null;
await this.flushNow();
}
private requireUnlocked<T>(fn: () => T): T {
if (this.state !== "unlocked") {
throw new VaultError(VAULT_CODES.LOCKED, "error.vault.locked");
}
return fn();
}
private wiringInputs(): WiringInputs {
return {
config: this.config,
state: this.container as VaultContainerState,
runtime: this.runtime as RuntimeIndex,
vaultKey: () => this.unwrappedKey().bytes,
clock: this.clock,
rng: this.rng,
onMutation: () => this.scheduleFlush(),
};
}
private unwrappedKey(): SecureBuffer {
const handle = this.vaultKeyHandle;
if (handle === null) {
throw new VaultError(
VAULT_CODES.OPAQUE_HANDLE_INVALID,
"error.vault.opaque-handle-invalid"
);
}
return unwrap(handle);
}
private scheduleFlush(): void {
this.flushPending = true;
if (this.flushTimer !== null) clearTimeout(this.flushTimer);
this.flushTimer = setTimeout(() => {
void this.flushNow();
}, this.flushDebounceMs);
}
private async flushNow(): Promise<void> {
if (this.state !== "unlocked") return;
if (this.manifest === null || this.container === null) return;
const start = this.clock();
const buffer = this.unwrappedKey();
const refreshed = await saveContainer({
drive: this.config.drive,
path: this.config.containerPath,
manifest: this.manifest,
state: this.container,
vaultKey: buffer.bytes,
now: this.clock(),
...(this.rng !== undefined ? { rng: this.rng } : {}),
});
this.manifest = refreshed;
this.flushPending = false;
getVaultInstruments().flushDurationMs.record(this.clock() - start);
}
private lifecycleOutcome(
status: "opened" | "unlocked" | "locked" | "denied"
): void {
getVaultInstruments().lifecycleOutcomeTotal.add(1, { status });
}
}
function defaultClock(): number {
return Date.now();
}
/** Re-export the head schema version so consumers can inspect compatibility. */
export const VAULT_HEAD_SCHEMA_VERSION = headSchemaVersion();

View File

@@ -0,0 +1,73 @@
# vault wiring
## Purpose
`wiring.ts` extracts the per-unlock module factories used by `Vault`: `buildItems`, `buildSearch`, `buildAttachments`, `buildShares`, `buildSync`, plus the `RuntimeIndex` and `WiringInputs` shapes. Splitting these helpers into a sibling file keeps `vault.ts` under the suaveplan 300-line cap while preserving an idiomatic single-file orchestrator surface — `vault.ts` deals with the lifecycle state machine, and `wiring.ts` deals with module construction.
The factories are pure: they read live state via the `vaultKey()` callback (which reaches through the `OpaqueKeyHandle`) and instantiate the corresponding module class with the same flush-mutation callback every time. The orchestrator calls them once per public-getter access; module instances are short-lived to keep stale-state hazards minimal.
## Features
- `buildItems(inputs)` — `VaultItems` over the current container state.
- `buildSearch(inputs)` — `VaultSearch` whose `rows` callback reads live state.
- `buildAttachments(inputs)` — `VaultAttachments` bound to drive + runtime metadata.
- `buildShares(inputs)` — `VaultShares` bound to drive-sharing + runtime metadata.
- `buildSync(inputs)` — `VaultSync` bound to the configured sync delegate.
- `freshRuntimeIndex()` — allocate empty maps for attachments + shares metadata.
- `RuntimeIndex` / `WiringInputs` shapes for module-internal threading.
## Usage: Basic Example
```ts
import { buildItems, freshRuntimeIndex } from "@suaveplan/vault/vault/wiring";
const inputs = {
config,
state,
runtime: freshRuntimeIndex(),
vaultKey: () => buffer.bytes,
clock: Date.now,
rng: undefined,
onMutation: () => scheduleFlush(),
};
const items = buildItems(inputs);
items.create({ ... });
```
## Usage: Advanced Example
```ts
import {
buildAttachments,
buildShares,
buildSync,
} from "@suaveplan/vault/vault/wiring";
// Wire every module in lockstep with the same vaultKey + flush hook.
const attachments = buildAttachments(inputs);
const shares = buildShares(inputs);
const sync = buildSync(inputs);
await attachments.upload({ itemId, filename, mimeType, data });
await shares.createLink({ itemId, itemPlaintext, expiresAt: null, maxViews: null });
await sync.sync();
```
## API Reference
- `interface RuntimeIndex` — `{ attachments: Map, shares: Map }`.
- `interface WiringInputs` — `{ config, state, runtime, vaultKey, clock, rng, onMutation }`.
- `buildItems(inputs: WiringInputs): VaultItems`.
- `buildSearch(inputs: WiringInputs): VaultSearch`.
- `buildAttachments(inputs: WiringInputs): VaultAttachments`.
- `buildShares(inputs: WiringInputs): VaultShares`.
- `buildSync(inputs: WiringInputs): VaultSync`.
- `freshRuntimeIndex(): RuntimeIndex`.
## Implementation Notes
- The `vaultKey` callback returns a fresh `Uint8Array` view of the underlying `SecureBuffer`. Because `Vault` resolves the buffer on every access via `unwrap(handle)`, even a transient mid-operation re-key will be picked up on the next module access without rebuilding the orchestrator.
- `WiringInputs.onMutation` is the same flush-debouncing closure for every module — every successful create / update / delete / upload / share-create / share-revoke triggers the same scheduler in `vault.ts`.
- Module instances are deliberately built fresh on every getter access in `vault.ts`. This keeps the lifecycle simple — a future lock / unlock cycle replaces `state` and `runtime` and the next getter access sees the new objects automatically.
- The factory functions are pure with respect to the inputs: the same inputs always produce a module observing the same state, so consumers can safely cache the returned module within a single tick if the per-call cost matters.

View File

@@ -0,0 +1,139 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
emptyContainerState,
runMigrations,
} from "../items/migrations/migrations.js";
import type {
DriveLike,
DriveSharingLike,
SyncDelegationResult,
SyncSnapshot,
VaultSyncLike,
} from "../types/adapters.js";
import type { VaultConfig } from "../types/types.js";
import {
buildAttachments,
buildItems,
buildSearch,
buildShares,
buildSync,
freshRuntimeIndex,
type WiringInputs,
} from "./wiring.js";
function makeInputs(): WiringInputs {
const drive: DriveLike = {
async read() {
throw new Error("nope");
},
async write() {},
async delete() {
return false;
},
async exists() {
return false;
},
};
const sharing: DriveSharingLike = {
async createLink() {
return { id: "x", url: "https://x.example/x" };
},
async revokeLink() {},
async isActive() {
return true;
},
};
const sync: VaultSyncLike = {
hydrate() {},
async pull(): Promise<SyncDelegationResult> {
return { merged: [], conflicts: [], deleted: [], durationMs: 0 };
},
async push(): Promise<SyncDelegationResult> {
return { merged: [], conflicts: [], deleted: [], durationMs: 0 };
},
async sync(): Promise<SyncDelegationResult> {
return { merged: [], conflicts: [], deleted: [], durationMs: 0 };
},
currentSnapshot(): SyncSnapshot {
return { items: [], snapshotAt: 0 };
},
};
const config: VaultConfig = {
vaultId: "v",
email: "u@e.com",
containerPath: "/v.bin",
attachmentsPrefix: "/att",
argon2id: {
memoryKB: 65536,
iterations: 3,
parallelism: 1,
outputLength: 32,
},
argon2idSalt: new Uint8Array(16),
drive,
driveSharing: sharing,
sync,
maxAttachmentBytes: 1024,
};
const state = emptyContainerState();
runMigrations(state, undefined, () => 0);
const key = new Uint8Array(32);
crypto.getRandomValues(key);
return {
config,
state,
runtime: freshRuntimeIndex(),
vaultKey: () => key,
clock: () => 1,
rng: undefined,
onMutation: () => {},
};
}
describe("vault wiring", () => {
it("buildItems returns a working VaultItems", { timeout: 5000 }, () => {
const items = buildItems(makeInputs());
expect(items.list()).toEqual([]);
});
it("buildSearch returns a working VaultSearch", { timeout: 5000 }, () => {
const search = buildSearch(makeInputs());
expect(search.search({})).toEqual([]);
});
it("buildAttachments returns a working VaultAttachments", {
timeout: 5000,
}, () => {
const attachments = buildAttachments(makeInputs());
expect(attachments.list()).toEqual([]);
});
it("buildShares returns a working VaultShares", { timeout: 5000 }, () => {
const shares = buildShares(makeInputs());
expect(shares.list()).toEqual([]);
});
it("buildSync returns a working VaultSync", { timeout: 5000 }, async () => {
const sync = buildSync(makeInputs());
expect(sync.currentSnapshot()).toBeDefined();
// Trigger the rows() callback by invoking pull (which hydrates).
const result = await sync.pull();
expect(result.merged).toBeDefined();
});
it("freshRuntimeIndex starts empty", { timeout: 5000 }, () => {
const index = freshRuntimeIndex();
expect(index.attachments.size).toBe(0);
expect(index.shares.size).toBe(0);
});
it("buildAttachments works without maxAttachmentBytes config", {
timeout: 5000,
}, () => {
const inputs = makeInputs();
const cfg = { ...inputs.config };
delete (cfg as Partial<VaultConfig>).maxAttachmentBytes;
const attachments = buildAttachments({ ...inputs, config: cfg });
expect(attachments.list()).toEqual([]);
});
});

View File

@@ -0,0 +1,91 @@
import type { Clock, Rng } from "@suaveplan/types";
import { VaultAttachments } from "../attachments/attachments.js";
import { VaultItems } from "../items/items.js";
import type { VaultContainerState } from "../items/migrations/migrations.js";
import { VaultSearch } from "../search/search.js";
import { VaultShares } from "../shares/shares.js";
import { VaultSync } from "../sync/sync.js";
import type { Attachment, ShareLink, VaultConfig } from "../types/types.js";
/** Per-unlock metadata indexes that ride alongside the container state. */
export interface RuntimeIndex {
readonly attachments: Map<string, Attachment>;
readonly shares: Map<string, ShareLink>;
}
/** Bag of build inputs threaded through to every module factory. */
export interface WiringInputs {
readonly config: VaultConfig;
readonly state: VaultContainerState;
readonly runtime: RuntimeIndex;
readonly vaultKey: () => Uint8Array;
readonly clock: Clock;
readonly rng: Rng | undefined;
readonly onMutation: () => void;
}
/** Build a fresh `VaultItems` over the current container state. */
export function buildItems(inputs: WiringInputs): VaultItems {
return new VaultItems({
state: inputs.state,
vaultKey: inputs.vaultKey(),
clock: inputs.clock,
rng: inputs.rng,
onMutation: inputs.onMutation,
});
}
/** Build a fresh `VaultSearch` whose `rows()` callback reads live state. */
export function buildSearch(inputs: WiringInputs): VaultSearch {
return new VaultSearch({
rows: () => buildItems(inputs).allRows(),
vaultKey: inputs.vaultKey(),
clock: inputs.clock,
});
}
/** Build a `VaultAttachments` bound to drive + the runtime metadata index. */
export function buildAttachments(inputs: WiringInputs): VaultAttachments {
return new VaultAttachments({
drive: inputs.config.drive,
attachmentsPrefix: inputs.config.attachmentsPrefix,
vaultKey: inputs.vaultKey(),
clock: inputs.clock,
rng: inputs.rng,
metadata: inputs.runtime.attachments,
onMutation: inputs.onMutation,
...(inputs.config.maxAttachmentBytes !== undefined
? { maxAttachmentBytes: inputs.config.maxAttachmentBytes }
: {}),
});
}
/** Build a `VaultShares` bound to drive-sharing + the runtime metadata index. */
export function buildShares(inputs: WiringInputs): VaultShares {
return new VaultShares({
driveSharing: inputs.config.driveSharing,
partitionId: inputs.config.vaultId,
vaultKey: inputs.vaultKey(),
clock: inputs.clock,
rng: inputs.rng,
metadata: inputs.runtime.shares,
onMutation: inputs.onMutation,
});
}
/** Build a `VaultSync` bound to the configured sync delegate. */
export function buildSync(inputs: WiringInputs): VaultSync {
return new VaultSync({
delegate: inputs.config.sync,
rows: () => buildItems(inputs).allRows(),
clock: inputs.clock,
});
}
/** Allocate a fresh per-unlock runtime index. */
export function freshRuntimeIndex(): RuntimeIndex {
return {
attachments: new Map(),
shares: new Map(),
};
}