/** * 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[keyof Keychain]; function getSlot( keychain: Keychain, purpose: "mast" | "sign" ): DefaultSlot { // `Keychain` is `Readonly>`. // 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; 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 { 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 { 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 { 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 { 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> { const plaintext = await openVaultBytes(vaultBytes, password); return decodeKeychain(plaintext); } /** * Inverse of {@link openVault}: encrypt an existing `Keychain` under * `password` and return the wire bytes. */ export async function packVault( keychain: Keychain, password: string, options?: PackVaultOptions ): Promise { 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> { 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(data); } /** Idempotent convenience wrapper over `storage.set` for vault bytes. */ export async function saveVaultToStorage( storage: AbstractStorage, key: string, vaultBytes: Uint8Array ): Promise { 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 { 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 { 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; }