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