Initial Commit
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
# vault attachments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`attachments.ts` implements drive-backed AEAD-encrypted attachment storage. Each attachment is encrypted under a per-attachment key derived from the vault key + a synthetic `attachment/<id>` derivation label, with the AEAD AAD bound to the owning `(itemId, filename)` so an attacker who reorders blobs in drive cannot pass authentication.
|
||||
|
||||
The repository wires `DriveLike.read` / `write` / `delete` for blob I/O and keeps attachment metadata (id / itemId / filename / mimeType / size / createdAt) in a caller-provided in-memory `Map<string, Attachment>` that lives inside the vault's container state. Metadata mutations notify the orchestrator via `onMutation` so the next debounced flush re-encrypts the container blob and writes it back.
|
||||
|
||||
## Features
|
||||
|
||||
- `upload(input) → Attachment` — Zod-validated metadata, AEAD-encrypted body, atomic drive write.
|
||||
- `download(id) → Uint8Array` — drive read + AEAD decryption; throws `ATTACHMENT.NOT_FOUND` on missing metadata.
|
||||
- `delete(id)` — drive delete + metadata removal; throws `ATTACHMENT.NOT_FOUND` on missing metadata.
|
||||
- `list()` — snapshot of every attachment metadata record.
|
||||
- Hard cap on plaintext size (default 100 MiB; configurable via `maxAttachmentBytes`); over-cap uploads throw `ATTACHMENT.TOO_LARGE`.
|
||||
- Drive failures throw `DRIVE.FAILURE` with the underlying error chained as `cause`.
|
||||
- Per-attachment key zeroization in a `finally` block after every encrypt / decrypt.
|
||||
- OTel histogram + counter per upload / download / delete with `op` and `status` labels.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { VaultAttachments } from "@suaveplan/vault/attachments/attachments";
|
||||
|
||||
const attachments = new VaultAttachments({
|
||||
drive,
|
||||
attachmentsPrefix: "/vaults/personal/attachments",
|
||||
vaultKey,
|
||||
clock: Date.now,
|
||||
rng: undefined,
|
||||
metadata: state.attachments,
|
||||
onMutation: () => scheduleFlush(),
|
||||
});
|
||||
|
||||
const meta = await attachments.upload({
|
||||
itemId: "abc",
|
||||
filename: "passport.pdf",
|
||||
mimeType: "application/pdf",
|
||||
data: new Uint8Array(fileBytes),
|
||||
});
|
||||
|
||||
const recovered = await attachments.download(meta.id);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
VaultAttachments,
|
||||
encodeAttachmentMetadata,
|
||||
decodeAttachmentMetadata,
|
||||
} from "@suaveplan/vault/attachments/attachments";
|
||||
|
||||
// Custom 1 MiB cap for a memory-constrained device.
|
||||
const small = new VaultAttachments({
|
||||
drive,
|
||||
attachmentsPrefix: "/embedded",
|
||||
vaultKey,
|
||||
clock,
|
||||
rng,
|
||||
maxAttachmentBytes: 1024 * 1024,
|
||||
metadata,
|
||||
onMutation,
|
||||
});
|
||||
|
||||
// Persist attachment metadata in a sidecar list outside the container.
|
||||
const meta = await small.upload({
|
||||
itemId,
|
||||
filename: "small.bin",
|
||||
mimeType: "application/octet-stream",
|
||||
data,
|
||||
});
|
||||
const encoded = encodeAttachmentMetadata(meta);
|
||||
const decoded = decodeAttachmentMetadata(encoded);
|
||||
// `decoded` round-trips equal to `meta`.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class VaultAttachments`.
|
||||
- `constructor(options: AttachmentsOptions)`.
|
||||
- `upload(input: UploadAttachmentInput): Promise<Attachment>` — throws `ATTACHMENT.TOO_LARGE` / `ITEM.VALIDATION_FAILED` / `DRIVE.FAILURE`.
|
||||
- `download(id: string): Promise<Uint8Array>` — throws `ATTACHMENT.NOT_FOUND` / `DRIVE.FAILURE` / `DECRYPT_FAILED`.
|
||||
- `delete(id: string): Promise<void>` — throws `ATTACHMENT.NOT_FOUND` / `DRIVE.FAILURE`.
|
||||
- `list(): Attachment[]`.
|
||||
- `interface AttachmentsOptions` — `{ drive, attachmentsPrefix, vaultKey, clock, rng, maxAttachmentBytes?, metadata, onMutation }`.
|
||||
- `interface UploadAttachmentInput` — `{ itemId, filename, mimeType, data }`.
|
||||
- `DEFAULT_MAX_ATTACHMENT_BYTES: 100 MiB`.
|
||||
- `encodeAttachmentMetadata(meta): string` — base64 JSON helper.
|
||||
- `decodeAttachmentMetadata(encoded): Attachment` — inverse, validates via `AttachmentSchema`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The blob layout is `[24-byte nonce][ciphertext + 16-byte Poly1305 tag]`. No per-attachment manifest header — the metadata record carries `(id, itemId, filename, mimeType, size)`, and the container manifest's vault-id binding still applies indirectly through the container blob's own AEAD authentication.
|
||||
- The per-attachment key is derived via `deriveItemKey(vaultKey, "attachment/<id>")`. Using a synthetic key path prevents collisions with item keys derived from the same vault key under genuine item ids.
|
||||
- The AEAD AAD is `"vault/v1/attachment/<itemId>/<filename>"`. A renamed attachment (different filename) re-encrypted without updating the metadata would fail authentication on download — which is the desired behaviour: metadata and blob must stay in sync.
|
||||
- Drive `write` is contracted as atomic; a partial write would otherwise leave a half-encrypted blob that fails AEAD authentication on the next read. Adapters that cannot satisfy atomicity (e.g. unsynced filesystem writes) must implement their own staged-tmp + rename.
|
||||
- Over-cap uploads throw `ATTACHMENT.TOO_LARGE` BEFORE any drive I/O — this prevents an attacker from filling drive storage with rejected bytes.
|
||||
- The OTel `outcome` counter uses a fixed allowlist (`success | not_found | rejected`); other failure modes (e.g. AEAD auth) are surfaced through the thrown `VaultError` rather than as a counter status, to keep the metric cardinality bounded.
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
55
packages/security-stack/security/vault/src/errors/codes.md
Normal file
55
packages/security-stack/security/vault/src/errors/codes.md
Normal 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.
|
||||
37
packages/security-stack/security/vault/src/errors/codes.ts
Normal file
37
packages/security-stack/security/vault/src/errors/codes.ts
Normal 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];
|
||||
@@ -0,0 +1,2 @@
|
||||
export { VAULT_CODES, type VaultErrorCode } from "./codes.js";
|
||||
export { VaultError } from "./vault-error.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
150
packages/security-stack/security/vault/src/index.ts
Normal file
150
packages/security-stack/security/vault/src/index.ts
Normal 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";
|
||||
57
packages/security-stack/security/vault/src/items/helpers.md
Normal file
57
packages/security-stack/security/vault/src/items/helpers.md
Normal 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.
|
||||
121
packages/security-stack/security/vault/src/items/helpers.test.ts
Normal file
121
packages/security-stack/security/vault/src/items/helpers.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
74
packages/security-stack/security/vault/src/items/helpers.ts
Normal file
74
packages/security-stack/security/vault/src/items/helpers.ts
Normal 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;
|
||||
}
|
||||
99
packages/security-stack/security/vault/src/items/items.md
Normal file
99
packages/security-stack/security/vault/src/items/items.md
Normal 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`.
|
||||
261
packages/security-stack/security/vault/src/items/items.test.ts
Normal file
261
packages/security-stack/security/vault/src/items/items.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
255
packages/security-stack/security/vault/src/items/items.ts
Normal file
255
packages/security-stack/security/vault/src/items/items.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
175
packages/security-stack/security/vault/src/manifest/manifest.ts
Normal file
175
packages/security-stack/security/vault/src/manifest/manifest.ts
Normal 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 };
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
169
packages/security-stack/security/vault/src/otel/instruments.ts
Normal file
169
packages/security-stack/security/vault/src/otel/instruments.ts
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
70
packages/security-stack/security/vault/src/search/search.md
Normal file
70
packages/security-stack/security/vault/src/search/search.md
Normal 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.
|
||||
215
packages/security-stack/security/vault/src/search/search.test.ts
Normal file
215
packages/security-stack/security/vault/src/search/search.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
123
packages/security-stack/security/vault/src/search/search.ts
Normal file
123
packages/security-stack/security/vault/src/search/search.ts
Normal 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;
|
||||
}
|
||||
89
packages/security-stack/security/vault/src/shares/shares.md
Normal file
89
packages/security-stack/security/vault/src/shares/shares.md
Normal 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`.
|
||||
295
packages/security-stack/security/vault/src/shares/shares.test.ts
Normal file
295
packages/security-stack/security/vault/src/shares/shares.test.ts
Normal 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"]
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
191
packages/security-stack/security/vault/src/shares/shares.ts
Normal file
191
packages/security-stack/security/vault/src/shares/shares.ts
Normal 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;
|
||||
}
|
||||
74
packages/security-stack/security/vault/src/sync/sync.md
Normal file
74
packages/security-stack/security/vault/src/sync/sync.md
Normal 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`.
|
||||
204
packages/security-stack/security/vault/src/sync/sync.test.ts
Normal file
204
packages/security-stack/security/vault/src/sync/sync.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
112
packages/security-stack/security/vault/src/sync/sync.ts
Normal file
112
packages/security-stack/security/vault/src/sync/sync.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
91
packages/security-stack/security/vault/src/types/adapters.md
Normal file
91
packages/security-stack/security/vault/src/types/adapters.md
Normal 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.
|
||||
98
packages/security-stack/security/vault/src/types/adapters.ts
Normal file
98
packages/security-stack/security/vault/src/types/adapters.ts
Normal 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;
|
||||
}
|
||||
81
packages/security-stack/security/vault/src/types/types.md
Normal file
81
packages/security-stack/security/vault/src/types/types.md
Normal 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.
|
||||
139
packages/security-stack/security/vault/src/types/types.ts
Normal file
139
packages/security-stack/security/vault/src/types/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
114
packages/security-stack/security/vault/src/vault/lifecycle.ts
Normal file
114
packages/security-stack/security/vault/src/vault/lifecycle.ts
Normal 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 };
|
||||
}
|
||||
103
packages/security-stack/security/vault/src/vault/vault.md
Normal file
103
packages/security-stack/security/vault/src/vault/vault.md
Normal 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`.
|
||||
464
packages/security-stack/security/vault/src/vault/vault.test.ts
Normal file
464
packages/security-stack/security/vault/src/vault/vault.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
261
packages/security-stack/security/vault/src/vault/vault.ts
Normal file
261
packages/security-stack/security/vault/src/vault/vault.ts
Normal 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();
|
||||
73
packages/security-stack/security/vault/src/vault/wiring.md
Normal file
73
packages/security-stack/security/vault/src/vault/wiring.md
Normal 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.
|
||||
139
packages/security-stack/security/vault/src/vault/wiring.test.ts
Normal file
139
packages/security-stack/security/vault/src/vault/wiring.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
91
packages/security-stack/security/vault/src/vault/wiring.ts
Normal file
91
packages/security-stack/security/vault/src/vault/wiring.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user