Initial Commit

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

View File

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

View File

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

View File

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