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,21 @@
export {
createRootKeys,
createServerKeys,
getDataFromStorage,
openVault,
packVault,
rollServerKeys,
savePublicKeysToStorage,
saveVaultToStorage,
viewPublicKeys,
} from "./orchestration.js";
export type {
CreateRootKeysOptions,
CreateServerKeysOptions,
LayoutV1Fields,
PackVaultOptions,
RollServerKeysOptions,
RootKeysResult,
ServerKeysResult,
VaultEncryptionOptions,
} from "./orchestration.types.js";

View File

@@ -0,0 +1,88 @@
# internals
Internal building blocks for the orchestration surface.
## Purpose
`internals` is a private implementation seam: it holds the lower-level helpers
that `orchestration.ts` composes (vault sealing, vault opening, Ed25519 sign +
append, public-key-file projection). Splitting these helpers into a sibling
module keeps `orchestration.ts` under the file-size budget without polluting
the public barrel — none of the symbols here are re-exported from
`src/orchestration/index.ts` or `src/index.ts`.
This file is documented because every `.ts` in the package requires a sibling
`.md` (see `CLAUDE.md` §4); the docs treat it as an implementation note rather
than a public contract.
## Features
- `sealVault(plaintext, password, params)` — Argon2id KEK derivation followed
by XChaCha20-Poly1305 AEAD encryption; emits the canonical wire layout
`salt(16) || nonce(24) || ciphertext`.
- `openVaultBytes(vaultBytes, password)` — inverse of `sealVault`. Always uses
`DEFAULT_ARGON2ID_PARAMS`. Collapses short input, wrong password, and
ciphertext tamper into a single `ORCHESTRATION.DECRYPT_FAILED` error.
- `buildPublicKeyFile(keychain, signingSecretKey)` — projects the keychain to
its public-only form, encodes it, and appends an Ed25519 signature.
- Re-exported header byte-size constants (`SALT_BYTES`, `NONCE_BYTES`,
`SIGNATURE_BYTES`, `VAULT_HEADER_BYTES`) for use in the public layer.
## Usage: Basic
```ts
// internals are package-private — consumers go through @suaveplan/crypto-vault
import { sealVault } from "./internals.js";
import { DEFAULT_ARGON2ID_PARAMS } from "../argon2id/argon2id.js";
const bytes = await sealVault(
new TextEncoder().encode("payload"),
"pw",
DEFAULT_ARGON2ID_PARAMS
);
```
## Usage: Advanced
```ts
// Compose with a custom Argon2id profile for a hardened on-disk artefact.
import { sealVault, openVaultBytes } from "./internals.js";
const hardened = {
memoryKB: 256 * 1024,
iterations: 4,
parallelism: 4,
outputLength: 32,
};
const wire = await sealVault(payload, "pw", hardened);
const back = await openVaultBytes(wire, "pw");
```
## API Reference
- `sealVault(plaintext: Uint8Array, password: string, params: Argon2idParams)`
→ `Promise<Uint8Array>`. The wire bytes are exactly
`salt(16) || nonce(24) || ciphertext`. Salt and nonce are drawn from
`crypto.getRandomValues`. The function does not modify `plaintext`.
- `openVaultBytes(vaultBytes: Uint8Array, password: string)`
→ `Promise<Uint8Array>`. Throws
`CryptoVaultError(CRYPTOVAULT.ORCHESTRATION.DECRYPT_FAILED)` for any failure.
- `buildPublicKeyFile(keychain, signingSecretKey)` →
`{ publicKeyBytes, publicKeys }`. The signature is appended; the returned
`publicKeys` is the same in-memory projection produced by `extractPublicKeys`.
- `SALT_BYTES = 16`, `NONCE_BYTES = 24`, `SIGNATURE_BYTES = 64`,
`VAULT_HEADER_BYTES = SALT_BYTES + NONCE_BYTES`.
## Implementation Notes
- `openVaultBytes` always re-derives the KEK with `DEFAULT_ARGON2ID_PARAMS`
rather than reading params from a header. This keeps the wire format flat at
the cost of locking the on-disk profile to the package default; a future
versioned header is the migration path.
- Decryption failure is intentionally indistinguishable from a short-input or
tampered ciphertext failure — the same error code surfaces in all three
cases. Tests verify the property.
- The signing helper takes a raw 32-byte Ed25519 secret key (a `KeySlotPrivate.secretKey`
field) rather than the full slot. This keeps the seam narrow and makes the
unit tests trivial to feed with a hand-rolled keypair.

View File

@@ -0,0 +1,140 @@
/**
* Internal helpers shared by the public {@link createRootKeys} /
* {@link createServerKeys} / {@link openVault} surface. These are NOT part of
* the package's public API — they are an implementation seam that keeps
* `orchestration.ts` under the file-size budget. Consumers always go through
* the top-level barrel.
*/
import { aeadDecrypt, aeadEncrypt } from "@suaveplan/crypto";
import {
encodePublicKeychain,
extractPublicKeys,
type Keychain,
type PublicKeychain,
} from "@suaveplan/crypto/keychain";
import { ed25519 } from "@suaveplan/crypto-curve";
import {
DEFAULT_ARGON2ID_PARAMS,
deriveKeyWithArgon2id,
} from "../argon2id/argon2id.js";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import type { Argon2idParams } from "../types/types.js";
import type { LayoutV1Fields } from "./orchestration.types.js";
/** Length of the Argon2id salt embedded in the vault header. */
export const SALT_BYTES = 16;
/** Length of the XChaCha20-Poly1305 nonce embedded in the vault header. */
export const NONCE_BYTES = 24;
/** Length of an Ed25519 signature appended to the public-key file. */
export const SIGNATURE_BYTES = 64;
/** Combined header size: salt (16) + nonce (24). */
export const VAULT_HEADER_BYTES = SALT_BYTES + NONCE_BYTES;
const PASSWORD_ENCODER = new TextEncoder();
function randomBytes(length: number): Uint8Array {
const out = new Uint8Array(length);
crypto.getRandomValues(out);
return out;
}
/**
* Encrypt `plaintext` under a password-derived KEK. Layout:
* `salt(16) || nonce(24) || ciphertext`. Both header values are drawn fresh
* so two encryptions of the same plaintext under the same password share no
* bytes.
*/
export async function sealVault(
plaintext: Uint8Array,
password: string,
params: Argon2idParams
): Promise<Uint8Array> {
const salt = randomBytes(SALT_BYTES);
const nonce = randomBytes(NONCE_BYTES);
const kek = await deriveKeyWithArgon2id(
PASSWORD_ENCODER.encode(password),
salt,
params
);
const ciphertext = aeadEncrypt("XChaCha20-Poly1305", kek, plaintext, {
nonce,
});
const out = new Uint8Array(VAULT_HEADER_BYTES + ciphertext.length);
out.set(salt, 0);
out.set(nonce, SALT_BYTES);
out.set(ciphertext, VAULT_HEADER_BYTES);
return out;
}
/**
* Inverse of {@link sealVault}. Any failure — short input, wrong password, or
* tampered ciphertext — surfaces as a single
* `CryptoVaultError(CRYPTOVAULT.ORCHESTRATION.DECRYPT_FAILED)` so callers do
* not learn whether the password or the integrity tag was the failure.
*/
export async function openVaultBytes(
vaultBytes: Uint8Array,
password: string
): Promise<Uint8Array> {
if (vaultBytes.length <= VAULT_HEADER_BYTES) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["ORCHESTRATION.DECRYPT_FAILED"],
"error.crypto-vault.orchestration.decrypt-failed",
{ context: { reason: "short-input", length: vaultBytes.length } }
);
}
const salt = vaultBytes.slice(0, SALT_BYTES);
const nonce = vaultBytes.slice(SALT_BYTES, VAULT_HEADER_BYTES);
const ciphertext = vaultBytes.slice(VAULT_HEADER_BYTES);
const kek = await deriveKeyWithArgon2id(
PASSWORD_ENCODER.encode(password),
salt,
DEFAULT_ARGON2ID_PARAMS
);
try {
return aeadDecrypt("XChaCha20-Poly1305", kek, ciphertext, { nonce });
} catch {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["ORCHESTRATION.DECRYPT_FAILED"],
"error.crypto-vault.orchestration.decrypt-failed",
{ context: { reason: "aead-verify-failed" } }
);
}
}
/**
* Sign `data` with the supplied Ed25519 secret key and append the 64-byte
* signature, producing the canonical signed public-key file layout
* `data || signature`.
*/
function signAndAppend(
data: Uint8Array,
ed25519SecretKey: Uint8Array
): Uint8Array {
const signature = ed25519.sign(data, ed25519SecretKey);
const out = new Uint8Array(data.length + signature.length);
out.set(data, 0);
out.set(signature, data.length);
return out;
}
/**
* Project `keychain` to its public-only counterpart, encode, sign, and
* package both forms for callers.
*/
export function buildPublicKeyFile(
keychain: Keychain<LayoutV1Fields>,
signingSlotSecretKey: Uint8Array
): {
publicKeyBytes: Uint8Array;
publicKeys: PublicKeychain<LayoutV1Fields>;
} {
const publicKeys = extractPublicKeys(keychain);
const encoded = encodePublicKeychain(publicKeys);
return {
publicKeyBytes: signAndAppend(encoded, signingSlotSecretKey),
publicKeys,
};
}

View File

@@ -0,0 +1,146 @@
# orchestration
High-level orchestration helpers that bundle keychain generation, password-AEAD
encryption, signed public-key file production, and storage I/O behind a single
call surface.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/crypto-vault` already ships rich low-level primitives — BIP39
mnemonics, Argon2id KDF, hierarchical key derivation, SecureBuffer, SRP — but
no opinionated "create a root keychain plus encrypted vault plus signed
public-key file" entrypoint. The orchestration module fills that gap.
It composes `@suaveplan/crypto`'s `generateKeychain` / `encodeKeychain` /
`encodePublicKeychain` primitives with this package's
`deriveKeyWithArgon2id` KEK derivation and the Ed25519 signing surface from
`@suaveplan/crypto-curve`, producing the single-call surface used by
`@suaveplan/server-keys` operator bins (`create-root-keys`, `create-server-keys`,
`roll-server-keys`, `view-keys`, `view-vault`, `view-public-key-file`, etc.).
## Features
- One-call `createRootKeys` / `createServerKeys` / `rollServerKeys` producing
keychain + encrypted vault + signed public-key file in one trip.
- Deterministic server-key derivation: `(rootMaster, version)` reproduces the
same private material across hosts so rotation never loses key continuity.
- Versioned rotation via `rollServerKeys` with strict `version ≥ 0` validation.
- Round-trip `openVault` / `packVault` with constant-time wrong-password
rejection (no information leak about which of password / integrity failed).
- `viewPublicKeys` with optional Ed25519 signature verification.
- Idempotent storage helpers (`saveVaultToStorage`,
`savePublicKeysToStorage`, `getDataFromStorage`) that translate "missing key"
outcomes from any backend into a typed `CryptoVaultError`.
- suaveplan-native wire formats — explicitly NOT byte-compatible with the
upstream `@suaveplan/keys` BitPacker format; rotation is an offline migration.
## Usage: Basic
```ts
import {
createRootKeys,
openVault,
saveVaultToStorage,
} from "@suaveplan/crypto-vault";
import { MemoryStorage } from "@suaveplan/storage";
const storage = new MemoryStorage({ cleanupIntervalMs: 0 });
const root = await createRootKeys("correct horse battery staple");
await saveVaultToStorage(storage, "root.vault", root.vaultBytes);
const recovered = await openVault(root.vaultBytes, "correct horse battery staple");
// Eight default-purpose slots: "mast" (master), "sign", "encr", "kx ",
// "salt", "hash", "strm", "dev " — keyed by the 4-byte purpose tag.
console.log(recovered["mast"].publicKey.length); // 32
```
## Usage: Advanced
```ts
import {
createRootKeys,
createServerKeys,
rollServerKeys,
viewPublicKeys,
} from "@suaveplan/crypto-vault";
const root = await createRootKeys("root-pw");
// Provision a server keychain at version 1 (default)
const server1 = await createServerKeys(root, "server-pw");
// Roll the server keychain forward — deterministically derives a fresh master.
const server2 = await rollServerKeys(root, server1.version, "server-pw");
// Verify the server's signed public-key file using the root sign pubkey.
const decoded = await viewPublicKeys(
server2.publicKeyBytes,
root.keychain["sign"].publicKey
);
console.log(decoded["sign"].publicKey === server2.publicKeys["sign"].publicKey);
```
## API Reference
- `createRootKeys(password, options?)` → `Promise<RootKeysResult>`. Generates a
fresh root keychain via `generateKeychain`. `options.masterKey` lets callers
supply a pre-existing 32-byte seed; `options.layoutFields` overrides the
default v1 layout `{ masterVersion: 1, userId: 0, depth: 0, slotVersion: 1 }`;
`options.argon2id` overrides KDF parameters.
- `createServerKeys(rootKeys, serverPassword, options?)` →
`Promise<ServerKeysResult>`. Deterministically derives a child master via
`HMAC-SHA512(rootMaster, u32be(version))` truncated to 32 bytes. Throws
`CryptoVaultError(CRYPTOVAULT.ORCHESTRATION.INVALID_VERSION)` on negative or
non-integer `version`. Public-key file is signed by the *root* sign slot.
- `rollServerKeys(rootKeys, currentVersion, serverPassword, options?)` →
`Promise<ServerKeysResult>`. Returns a result at `version = currentVersion + 1`.
Throws the same `INVALID_VERSION` error on negative `currentVersion`.
- `openVault(vaultBytes, password)` → `Promise<Keychain>`. Splits the wire
layout (`salt(16) || nonce(24) || ciphertext`), re-derives the KEK with
Argon2id, and decrypts. Throws
`CryptoVaultError(CRYPTOVAULT.ORCHESTRATION.DECRYPT_FAILED)` on wrong
password, short input, or tampered ciphertext.
- `packVault(keychain, password, options?)` → `Promise<Uint8Array>`. Inverse of
`openVault`; reusable by callers that already hold a `Keychain`.
- `viewPublicKeys(publicKeyBytes, optionalSignKey?)` → `Promise<PublicKeychain>`.
Splits the trailing 64-byte Ed25519 signature, decodes the body, and (when
`optionalSignKey` is provided) verifies the signature. Throws
`CryptoVaultError(CRYPTOVAULT.ORCHESTRATION.PUBLIC_KEY_SIGNATURE_INVALID)` on
short input or signature mismatch.
- `saveVaultToStorage(storage, key, vaultBytes)` /
`savePublicKeysToStorage(storage, key, publicKeyBytes)` → `Promise<void>`.
Thin idempotent wrappers over `AbstractStorage.set`.
- `getDataFromStorage(storage, key)` → `Promise<Uint8Array>`. Translates a
missing key (either `undefined` return or a thrown backend error) into
`CryptoVaultError(CRYPTOVAULT.ORCHESTRATION.STORAGE_KEY_NOT_FOUND)`.
## Implementation Notes
- The KEK is derived with `deriveKeyWithArgon2id` using
`DEFAULT_ARGON2ID_PARAMS` (OWASP-compliant: 64 MiB memory, 3 iterations,
parallelism 4) unless callers override via `options.argon2id`.
- Vault layout: 16-byte Argon2id salt, then a 24-byte XChaCha20-Poly1305
nonce, then ciphertext. Both header values are drawn from
`crypto.getRandomValues` so two encryptions of the same plaintext under the
same password share no bytes.
- Public-key file layout: `encodePublicKeychain(extractPublicKeys(keychain))`
followed by a 64-byte Ed25519 signature. For root keychains the keychain
self-signs; for server keychains the root's sign slot signs the child's
public bytes so a verifier holding only the root sign pubkey can attest the
full chain.
- `openVault` always uses `DEFAULT_ARGON2ID_PARAMS` to re-derive the KEK; the
salt is embedded in the vault header but the params themselves are not — the
trade-off keeps the format flat at the cost of locking the on-disk profile to
the package default. Future overrides will append a versioned header.
- `viewPublicKeys` collapses every signature failure into a single error code
to avoid leaking which check failed first; tests rely on that property when
asserting the conformance behaviour.
- Storage helpers do not introspect the value — they pass `Uint8Array` straight
through. Backends with their own size limits (e.g. `MemoryStorage`'s
`maxValueSize`) surface as their own typed error, which is intentional: the
vault layer should not silently mutate the backend's contract.

View File

@@ -0,0 +1,136 @@
/**
* Spec conformance for the orchestration module:
*
* 1. Every new orchestration-namespaced error code is raised by at least one
* production call path.
* 2. Every exported function signature in the orchestration barrel is the
* shape the spec requires (verified via expectTypeOf).
*/
import { MemoryStorage } from "@suaveplan/storage";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import {
type CreateRootKeysOptions,
type CreateServerKeysOptions,
createRootKeys,
createServerKeys,
getDataFromStorage,
openVault,
type PackVaultOptions,
packVault,
type RollServerKeysOptions,
type RootKeysResult,
rollServerKeys,
type ServerKeysResult,
savePublicKeysToStorage,
saveVaultToStorage,
viewPublicKeys,
} from "./index.js";
const NEW_CODES = [
CRYPTO_VAULT_CODES["ORCHESTRATION.DECRYPT_FAILED"],
CRYPTO_VAULT_CODES["ORCHESTRATION.PUBLIC_KEY_SIGNATURE_INVALID"],
CRYPTO_VAULT_CODES["ORCHESTRATION.STORAGE_KEY_NOT_FOUND"],
CRYPTO_VAULT_CODES["ORCHESTRATION.INVALID_VERSION"],
] as const;
describe("orchestration spec conformance — error codes", () => {
it("every new orchestration code is thrown from a production path", {
timeout: 60_000,
}, async () => {
const seen = new Set<string>();
const record = (err: unknown): void => {
if (err instanceof CryptoVaultError) seen.add(err.code);
};
// DECRYPT_FAILED
try {
await openVault(new Uint8Array(8), "pw");
} catch (e) {
record(e);
}
// PUBLIC_KEY_SIGNATURE_INVALID
try {
await viewPublicKeys(new Uint8Array(8));
} catch (e) {
record(e);
}
// STORAGE_KEY_NOT_FOUND
try {
await getDataFromStorage(
new MemoryStorage({ cleanupIntervalMs: 0 }),
"absent"
);
} catch (e) {
record(e);
}
// INVALID_VERSION
try {
await rollServerKeys({} as RootKeysResult, -1, "pw");
} catch (e) {
record(e);
}
const missing = NEW_CODES.filter((c) => !seen.has(c));
expect(missing).toEqual([]);
});
});
describe("orchestration spec conformance — type-level signatures", () => {
it("exports the spec-required function signatures", {
timeout: 5000,
}, () => {
expectTypeOf(createRootKeys).toBeFunction();
expectTypeOf(createRootKeys).parameter(0).toEqualTypeOf<string>();
expectTypeOf(createRootKeys)
.parameter(1)
.toEqualTypeOf<CreateRootKeysOptions | undefined>();
expectTypeOf(createRootKeys).returns.toEqualTypeOf<
Promise<RootKeysResult>
>();
expectTypeOf(createServerKeys).toBeFunction();
expectTypeOf(createServerKeys).parameter(1).toEqualTypeOf<string>();
expectTypeOf(createServerKeys)
.parameter(2)
.toEqualTypeOf<CreateServerKeysOptions | undefined>();
expectTypeOf(createServerKeys).returns.toEqualTypeOf<
Promise<ServerKeysResult>
>();
expectTypeOf(rollServerKeys).toBeFunction();
expectTypeOf(rollServerKeys).parameter(1).toEqualTypeOf<number>();
expectTypeOf(rollServerKeys)
.parameter(3)
.toEqualTypeOf<RollServerKeysOptions | undefined>();
expectTypeOf(openVault).toBeFunction();
expectTypeOf(openVault).parameter(0).toEqualTypeOf<Uint8Array>();
expectTypeOf(openVault).parameter(1).toEqualTypeOf<string>();
expectTypeOf(packVault).toBeFunction();
expectTypeOf(packVault)
.parameter(2)
.toEqualTypeOf<PackVaultOptions | undefined>();
expectTypeOf(packVault).returns.toEqualTypeOf<Promise<Uint8Array>>();
expectTypeOf(viewPublicKeys).toBeFunction();
expectTypeOf(viewPublicKeys)
.parameter(1)
.toEqualTypeOf<Uint8Array | undefined>();
expectTypeOf(saveVaultToStorage).toBeFunction();
expectTypeOf(saveVaultToStorage).returns.toEqualTypeOf<Promise<void>>();
expectTypeOf(savePublicKeysToStorage).toBeFunction();
expectTypeOf(savePublicKeysToStorage).returns.toEqualTypeOf<
Promise<void>
>();
expectTypeOf(getDataFromStorage).toBeFunction();
expectTypeOf(getDataFromStorage).returns.toEqualTypeOf<
Promise<Uint8Array>
>();
});
});

View File

@@ -0,0 +1,350 @@
import type {
Keychain,
KeySlotPrivate,
KeySlotPublic,
PublicKeychain,
} from "@suaveplan/crypto/keychain";
import { ed25519 } from "@suaveplan/crypto-curve";
import { MemoryStorage } from "@suaveplan/storage";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import {
createRootKeys,
createServerKeys,
getDataFromStorage,
type LayoutV1Fields,
openVault,
packVault,
rollServerKeys,
savePublicKeysToStorage,
saveVaultToStorage,
viewPublicKeys,
} from "./index.js";
function privSlot(
keychain: Keychain<LayoutV1Fields>,
name: "mast" | "sign"
): KeySlotPrivate<LayoutV1Fields> {
return (
keychain as unknown as Record<string, KeySlotPrivate<LayoutV1Fields>>
)[name] as KeySlotPrivate<LayoutV1Fields>;
}
function pubSlot(
keychain: PublicKeychain<LayoutV1Fields>,
name: "mast" | "sign"
): KeySlotPublic<LayoutV1Fields> {
return (
keychain as unknown as Record<string, KeySlotPublic<LayoutV1Fields>>
)[name] as KeySlotPublic<LayoutV1Fields>;
}
function newStorage(): MemoryStorage {
return new MemoryStorage({ cleanupIntervalMs: 0 });
}
describe("createRootKeys", () => {
it("createRootKeys output round-trips through openVault", {
timeout: 30_000,
}, async () => {
const result = await createRootKeys("hunter2");
const recovered = await openVault(result.vaultBytes, "hunter2");
// master slot is symmetric (HKDF) so its publicKey is the all-zero
// placeholder; we round-trip the secret bytes instead.
expect(privSlot(recovered, "mast").secretKey).toEqual(
privSlot(result.keychain, "mast").secretKey
);
expect(privSlot(recovered, "sign").publicKey).toEqual(
privSlot(result.keychain, "sign").publicKey
);
expect(privSlot(recovered, "sign").secretKey).toEqual(
privSlot(result.keychain, "sign").secretKey
);
});
it("produces a signed public-key file with the keychain's sign slot", {
timeout: 30_000,
}, async () => {
const result = await createRootKeys("pw");
const decoded = await viewPublicKeys(
result.publicKeyBytes,
privSlot(result.keychain, "sign").publicKey
);
expect(pubSlot(decoded, "sign").publicKey).toEqual(
pubSlot(result.publicKeys, "sign").publicKey
);
});
});
describe("createServerKeys", () => {
it("createServerKeys deterministically derives from root master", {
timeout: 60_000,
}, async () => {
const root = await createRootKeys("root-pw");
const a = await createServerKeys(root, "server-pw");
const b = await createServerKeys(root, "server-pw");
// Sign-slot publicKey diverges per-master (Ed25519). Comparing the
// master slot's publicKey is a tautology (always all-zero placeholder);
// we additionally compare the master slot's secret bytes.
expect(pubSlot(a.publicKeys, "sign").publicKey).toEqual(
pubSlot(b.publicKeys, "sign").publicKey
);
expect(privSlot(a.keychain, "mast").secretKey).toEqual(
privSlot(b.keychain, "mast").secretKey
);
expect(a.version).toBe(1);
});
it("rejects negative version on createServerKeys", {
timeout: 30_000,
}, async () => {
const root = await createRootKeys("pw");
await expect(
createServerKeys(root, "s", { version: -2 })
).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES["ORCHESTRATION.INVALID_VERSION"],
});
});
it("signs server public-key file with root sign slot", {
timeout: 60_000,
}, async () => {
const root = await createRootKeys("pw");
const server = await createServerKeys(root, "spw");
const decoded = await viewPublicKeys(
server.publicKeyBytes,
privSlot(root.keychain, "sign").publicKey
);
expect(pubSlot(decoded, "sign").publicKey).toEqual(
pubSlot(server.publicKeys, "sign").publicKey
);
});
});
describe("rollServerKeys", () => {
it("rollServerKeys increments version", {
timeout: 60_000,
}, async () => {
const root = await createRootKeys("pw");
const at3 = await createServerKeys(root, "spw", { version: 3 });
const rolled = await rollServerKeys(root, 3, "spw");
expect(rolled.version).toBe(4);
// Master slot is HKDF (no public material). Compare the sign slot,
// which is Ed25519 and carries real public-key bytes that diverge
// across versions.
expect(pubSlot(rolled.publicKeys, "sign").publicKey).not.toEqual(
pubSlot(at3.publicKeys, "sign").publicKey
);
});
it("rollServerKeys rejects negative currentVersion", {
timeout: 30_000,
}, async () => {
const root = await createRootKeys("pw");
await expect(rollServerKeys(root, -1, "pw")).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES["ORCHESTRATION.INVALID_VERSION"],
});
});
it("rollServerKeys rejects non-integer currentVersion", {
timeout: 30_000,
}, async () => {
const root = await createRootKeys("pw");
await expect(rollServerKeys(root, 1.5, "pw")).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES["ORCHESTRATION.INVALID_VERSION"],
});
});
});
describe("openVault and packVault", () => {
it("openVault and packVault round-trip", { timeout: 60_000 }, async () => {
const initial = await createRootKeys("pw");
const repacked = await packVault(initial.keychain, "pw2");
const recovered = await openVault(repacked, "pw2");
expect(privSlot(recovered, "mast").secretKey).toEqual(
privSlot(initial.keychain, "mast").secretKey
);
expect(privSlot(recovered, "sign").publicKey).toEqual(
privSlot(initial.keychain, "sign").publicKey
);
});
it("openVault rejects wrong password", { timeout: 60_000 }, async () => {
const initial = await createRootKeys("right");
await expect(
openVault(initial.vaultBytes, "wrong")
).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES["ORCHESTRATION.DECRYPT_FAILED"],
});
});
it("openVault rejects tampered bytes", {
timeout: 60_000,
}, async () => {
const initial = await createRootKeys("pw");
const tampered = new Uint8Array(initial.vaultBytes);
// Flip a byte inside the ciphertext (past the 40-byte header).
tampered[tampered.length - 1] ^= 0x01;
await expect(openVault(tampered, "pw")).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES["ORCHESTRATION.DECRYPT_FAILED"],
});
});
it("openVault rejects short input", {
timeout: 30_000,
}, async () => {
await expect(openVault(new Uint8Array(8), "pw")).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES["ORCHESTRATION.DECRYPT_FAILED"],
});
});
});
describe("viewPublicKeys", () => {
it("viewPublicKeys verifies signature when sign key supplied", {
timeout: 30_000,
}, async () => {
const result = await createRootKeys("pw");
const decoded = await viewPublicKeys(
result.publicKeyBytes,
privSlot(result.keychain, "sign").publicKey
);
expect(pubSlot(decoded, "sign").publicKey).toEqual(
pubSlot(result.publicKeys, "sign").publicKey
);
});
it("viewPublicKeys rejects wrong sign key", {
timeout: 30_000,
}, async () => {
const result = await createRootKeys("pw");
const otherSecret = ed25519.randomPrivateKey();
const wrongPub = ed25519.getPublicKey(otherSecret);
await expect(
viewPublicKeys(result.publicKeyBytes, wrongPub)
).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES[
"ORCHESTRATION.PUBLIC_KEY_SIGNATURE_INVALID"
],
});
});
it("viewPublicKeys without sign key decodes without verification", {
timeout: 30_000,
}, async () => {
const result = await createRootKeys("pw");
const decoded = await viewPublicKeys(result.publicKeyBytes);
expect(pubSlot(decoded, "sign").publicKey).toEqual(
pubSlot(result.publicKeys, "sign").publicKey
);
});
it("viewPublicKeys rejects short input", {
timeout: 5000,
}, async () => {
await expect(viewPublicKeys(new Uint8Array(8))).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES[
"ORCHESTRATION.PUBLIC_KEY_SIGNATURE_INVALID"
],
});
});
});
describe("storage helpers", () => {
it("saveVaultToStorage + getDataFromStorage round-trip", {
timeout: 30_000,
}, async () => {
const storage = newStorage();
const bytes = new Uint8Array([1, 2, 3, 4, 5]);
await saveVaultToStorage(storage, "k", bytes);
const out = await getDataFromStorage(storage, "k");
expect(out).toEqual(bytes);
});
it("getDataFromStorage raises CRYPTO_VAULT_STORAGE_KEY_NOT_FOUND on missing key", {
timeout: 5000,
}, async () => {
const storage = newStorage();
await expect(
getDataFromStorage(storage, "absent")
).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES["ORCHESTRATION.STORAGE_KEY_NOT_FOUND"],
});
});
it("getDataFromStorage translates backend throw to typed error", {
timeout: 5000,
}, async () => {
const storage = {
get: async () => {
throw new Error("backend explode");
},
} as unknown as MemoryStorage;
await expect(getDataFromStorage(storage, "k")).rejects.toBeInstanceOf(
CryptoVaultError
);
});
it("saveVaultToStorage is idempotent", { timeout: 5000 }, async () => {
const storage = newStorage();
const bytes = new Uint8Array([7, 8, 9]);
await saveVaultToStorage(storage, "k", bytes);
await saveVaultToStorage(storage, "k", bytes);
const out = await getDataFromStorage(storage, "k");
expect(out).toEqual(bytes);
});
it("savePublicKeysToStorage round-trips", { timeout: 5000 }, async () => {
const storage = newStorage();
const bytes = new Uint8Array([10, 11, 12]);
await savePublicKeysToStorage(storage, "p", bytes);
const out = await getDataFromStorage(storage, "p");
expect(out).toEqual(bytes);
});
});
describe("options coverage", () => {
it("createRootKeys accepts an explicit masterKey", {
timeout: 30_000,
}, async () => {
const masterKey = new Uint8Array(32).fill(7);
const a = await createRootKeys("pw", { masterKey });
const b = await createRootKeys("pw", { masterKey });
expect(pubSlot(a.publicKeys, "sign").publicKey).toEqual(
pubSlot(b.publicKeys, "sign").publicKey
);
});
it("createRootKeys accepts an explicit clock", {
timeout: 30_000,
}, async () => {
const fixed = 1_700_000_000_000;
const result = await createRootKeys("pw", { clock: () => fixed });
expect(privSlot(result.keychain, "mast").notBefore).toBe(fixed);
});
it("createRootKeys accepts layoutFields override", {
timeout: 30_000,
}, async () => {
const layout = {
masterVersion: 7,
userId: 9,
depth: 2,
slotVersion: 3,
};
const result = await createRootKeys("pw", { layoutFields: layout });
expect(
privSlot(result.keychain, "mast").coordinate.layoutFields
).toMatchObject(layout);
});
it("createServerKeys accepts userId option", {
timeout: 60_000,
}, async () => {
const root = await createRootKeys("pw");
const server = await createServerKeys(root, "spw", { userId: 42 });
expect(
privSlot(server.keychain, "mast").coordinate.layoutFields.userId
).toBe(42);
});
});

View File

@@ -0,0 +1,279 @@
/**
* High-level orchestration helpers for `@suaveplan/crypto-vault`.
*
* These functions compose the package's own Argon2id KEK derivation with the
* primitives exposed by `@suaveplan/crypto` (keychain generation, public-key
* encoding) and `@suaveplan/crypto-curve` (Ed25519 signing) to produce the
* single-call surface that `@suaveplan/server-keys` operator bins rely on.
*
* Wire formats are genesis-native (NOT byte-compatible with the upstream
* `@suaveplan/keys` BitPacker-based format). A migration command from old to
* new lives in a separate package (`@suaveplan/keys-migration`, follow-up).
*/
import {
decodeKeychain,
decodePublicKeychain,
encodeKeychain,
generateKeychain,
type Keychain,
type PublicKeychain,
} from "@suaveplan/crypto/keychain";
import { ed25519 } from "@suaveplan/crypto-curve";
import type { AbstractStorage } from "@suaveplan/storage";
import { DEFAULT_ARGON2ID_PARAMS } from "../argon2id/argon2id.js";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import { deriveChildKey } from "../hd-key/hd-key.js";
import type { Argon2idParams } from "../types/types.js";
import {
buildPublicKeyFile,
openVaultBytes,
SIGNATURE_BYTES,
sealVault,
} from "./internals.js";
import type {
CreateRootKeysOptions,
CreateServerKeysOptions,
LayoutV1Fields,
PackVaultOptions,
RollServerKeysOptions,
RootKeysResult,
ServerKeysResult,
VaultEncryptionOptions,
} from "./orchestration.types.js";
const DEFAULT_ROOT_LAYOUT: LayoutV1Fields = Object.freeze({
masterVersion: 1,
userId: 0,
depth: 0,
slotVersion: 1,
});
type DefaultSlot = Keychain<LayoutV1Fields>[keyof Keychain<LayoutV1Fields>];
function getSlot(
keychain: Keychain<LayoutV1Fields>,
purpose: "mast" | "sign"
): DefaultSlot {
// `Keychain<LayoutV1Fields>` is `Readonly<Record<PurposeTag, KeySlotPrivate>>`.
// The named string keys ("master", "sign", …) are exactly the eight default
// purposes — always present after `generateKeychain` — but TS can't narrow
// brand-typed Record keys via dot access, so we go through a string-keyed
// view. The `Required` wrapper drops the `| undefined` introduced by index
// signatures under `noUncheckedIndexedAccess`.
const view = keychain as unknown as Record<string, DefaultSlot>;
return view[purpose] as DefaultSlot;
}
function defaultClock(
options: VaultEncryptionOptions | undefined
): () => number {
return options?.clock ?? Date.now;
}
function getArgon2idParams(
options: VaultEncryptionOptions | undefined
): Argon2idParams {
return options?.argon2id ?? DEFAULT_ARGON2ID_PARAMS;
}
function assertNonNegativeInteger(field: string, value: number): void {
if (!Number.isInteger(value) || value < 0) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["ORCHESTRATION.INVALID_VERSION"],
"error.crypto-vault.orchestration.invalid-version",
{ context: { field, value } }
);
}
}
/**
* Generate a fresh root keychain, encrypt it under a password, and produce
* the matching signed public-key file in one step. The returned `vaultBytes`
* round-trip through {@link openVault} with the same password.
*/
export async function createRootKeys(
password: string,
options?: CreateRootKeysOptions
): Promise<RootKeysResult> {
const clock = defaultClock(options);
const layoutFields = options?.layoutFields ?? DEFAULT_ROOT_LAYOUT;
const keychain = generateKeychain({
clock,
layoutFields,
...(options?.masterKey !== undefined
? { masterKey: options.masterKey }
: {}),
});
const vaultBytes = await packVault(keychain, password, options);
const { publicKeyBytes, publicKeys } = buildPublicKeyFile(
keychain,
getSlot(keychain, "sign").secretKey
);
return { keychain, vaultBytes, publicKeyBytes, publicKeys };
}
async function buildServerResult(
rootKeys: RootKeysResult,
serverPassword: string,
version: number,
options: CreateServerKeysOptions | undefined
): Promise<ServerKeysResult> {
const clock = defaultClock(options);
const layoutFields: LayoutV1Fields = {
masterVersion: version,
userId: options?.userId ?? 1,
depth: 1,
slotVersion: 1,
};
const childMaster = deriveChildKey(
getSlot(rootKeys.keychain, "mast").secretKey,
version
);
const keychain = generateKeychain({
clock,
layoutFields,
masterKey: childMaster,
});
const vaultBytes = await packVault(keychain, serverPassword, options);
const { publicKeyBytes, publicKeys } = buildPublicKeyFile(
keychain,
getSlot(rootKeys.keychain, "sign").secretKey
);
return { keychain, vaultBytes, publicKeyBytes, publicKeys, version };
}
/**
* Derive a server keychain deterministically from `rootKeys.keychain.master`
* at the supplied `version` (default `1`), encrypt under `serverPassword`,
* and produce a public-key file signed by the *root* keychain's sign slot.
*/
export async function createServerKeys(
rootKeys: RootKeysResult,
serverPassword: string,
options?: CreateServerKeysOptions
): Promise<ServerKeysResult> {
const version = options?.version ?? 1;
assertNonNegativeInteger("version", version);
return buildServerResult(rootKeys, serverPassword, version, options);
}
/**
* Roll a server keychain to `currentVersion + 1`. Rejects negative
* `currentVersion` values with `CRYPTOVAULT.ORCHESTRATION.INVALID_VERSION`.
*/
export async function rollServerKeys(
rootKeys: RootKeysResult,
currentVersion: number,
serverPassword: string,
options?: RollServerKeysOptions
): Promise<ServerKeysResult> {
assertNonNegativeInteger("currentVersion", currentVersion);
return buildServerResult(
rootKeys,
serverPassword,
currentVersion + 1,
options
);
}
/**
* Decrypt `vaultBytes` (produced by {@link packVault} or any of the
* `create*Keys` helpers) and decode the embedded keychain.
*/
export async function openVault(
vaultBytes: Uint8Array,
password: string
): Promise<Keychain<LayoutV1Fields>> {
const plaintext = await openVaultBytes(vaultBytes, password);
return decodeKeychain<LayoutV1Fields>(plaintext);
}
/**
* Inverse of {@link openVault}: encrypt an existing `Keychain` under
* `password` and return the wire bytes.
*/
export async function packVault(
keychain: Keychain<LayoutV1Fields>,
password: string,
options?: PackVaultOptions
): Promise<Uint8Array> {
const encoded = encodeKeychain(keychain);
return sealVault(encoded, password, getArgon2idParams(options));
}
/**
* Decode a signed public-key file. When `optionalSignKey` is supplied the
* trailing 64-byte signature is verified against it; otherwise the file is
* decoded without verification (caller has another out-of-band path to trust).
*/
export async function viewPublicKeys(
publicKeyBytes: Uint8Array,
optionalSignKey?: Uint8Array
): Promise<PublicKeychain<LayoutV1Fields>> {
if (publicKeyBytes.length <= SIGNATURE_BYTES) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["ORCHESTRATION.PUBLIC_KEY_SIGNATURE_INVALID"],
"error.crypto-vault.orchestration.public-key-signature-invalid",
{
context: {
reason: "short-input",
length: publicKeyBytes.length,
},
}
);
}
const data = publicKeyBytes.slice(0, -SIGNATURE_BYTES);
if (optionalSignKey !== undefined) {
const signature = publicKeyBytes.slice(-SIGNATURE_BYTES);
const ok = ed25519.verify(signature, data, optionalSignKey);
if (!ok) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES[
"ORCHESTRATION.PUBLIC_KEY_SIGNATURE_INVALID"
],
"error.crypto-vault.orchestration.public-key-signature-invalid",
{ context: { reason: "verify-failed" } }
);
}
}
return decodePublicKeychain<LayoutV1Fields>(data);
}
/** Idempotent convenience wrapper over `storage.set` for vault bytes. */
export async function saveVaultToStorage(
storage: AbstractStorage,
key: string,
vaultBytes: Uint8Array
): Promise<void> {
await storage.set(key, vaultBytes);
}
/** Idempotent convenience wrapper over `storage.set` for public-key files. */
export async function savePublicKeysToStorage(
storage: AbstractStorage,
key: string,
publicKeyBytes: Uint8Array
): Promise<void> {
await storage.set(key, publicKeyBytes);
}
/**
* Fetch bytes from `storage`, translating any "missing key" outcome into
* `CryptoVaultError(CRYPTOVAULT.ORCHESTRATION.STORAGE_KEY_NOT_FOUND)`.
*/
export async function getDataFromStorage(
storage: AbstractStorage,
key: string
): Promise<Uint8Array> {
const value = await storage.get(key).catch(() => undefined);
if (value === undefined) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["ORCHESTRATION.STORAGE_KEY_NOT_FOUND"],
"error.crypto-vault.orchestration.storage-key-not-found",
{ context: { key } }
);
}
return value;
}

View File

@@ -0,0 +1,72 @@
import type { Keychain, PublicKeychain } from "@suaveplan/crypto/keychain";
import type { Clock } from "@suaveplan/types";
import type { Argon2idParams } from "../types/types.js";
/**
* v1 layout discriminator carried in every derivation coordinate. Mirrors the
* shape exported by `@suaveplan/crypto`'s derivation grammar; declared locally
* because the upstream package does not expose a subpath for the derivation
* module. Keeping the contract here lets `orchestration` plug into
* `generateKeychain` without taking on a deeper import dependency.
*/
export interface LayoutV1Fields {
readonly masterVersion: number;
readonly userId: number;
readonly depth: number;
readonly slotVersion: number;
}
/**
* Shared base options accepted by every orchestration entrypoint that runs an
* Argon2id-bound encryption step. Callers MAY override the KDF parameters to
* a stronger profile; the defaults (`DEFAULT_ARGON2ID_PARAMS`) already satisfy
* OWASP password-storage guidance.
*/
export interface VaultEncryptionOptions {
/** Argon2id parameters used for the KEK derivation. */
readonly argon2id?: Argon2idParams;
/** Optional clock for keychain validity windows. Defaults to `Date.now`. */
readonly clock?: Clock;
}
/** Options accepted by {@link createRootKeys}. */
export interface CreateRootKeysOptions extends VaultEncryptionOptions {
/** Pre-existing 32-byte master seed; generated if omitted. */
readonly masterKey?: Uint8Array;
/** v1 layout fields override; defaults to the canonical root layout. */
readonly layoutFields?: LayoutV1Fields;
}
/** Options accepted by {@link createServerKeys}. */
export interface CreateServerKeysOptions extends VaultEncryptionOptions {
/** Logical version of the server keychain. Defaults to `1`. */
readonly version?: number;
/** User-id field for the v1 layout. Defaults to `1`. */
readonly userId?: number;
}
/** Options accepted by {@link rollServerKeys}. */
export type RollServerKeysOptions = CreateServerKeysOptions;
/** Options accepted by {@link packVault}. */
export type PackVaultOptions = VaultEncryptionOptions;
/**
* Return shape of {@link createRootKeys}. `keychain` holds private material;
* `vaultBytes` is its password-encrypted form; `publicKeyBytes` is the signed
* public-key file; `publicKeys` is the in-memory decoded `PublicKeychain`.
*/
export interface RootKeysResult {
readonly keychain: Keychain<LayoutV1Fields>;
readonly vaultBytes: Uint8Array;
readonly publicKeyBytes: Uint8Array;
readonly publicKeys: PublicKeychain<LayoutV1Fields>;
}
/**
* Return shape of {@link createServerKeys} and {@link rollServerKeys}.
* Adds the deterministic `version` discriminator used for rotation tracking.
*/
export interface ServerKeysResult extends RootKeysResult {
readonly version: number;
}