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,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;
}