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