import { ChainedKeySigner, createRootKeysWithChain, createServerKeysWithChain, rollServerKeysWithChain, } from "@suaveplan/crypto-keys"; import { packVault, viewPublicKeys } from "@suaveplan/crypto-vault"; import { SERVER_KEYS_CODES, ServerKeysError, } from "../../error-codes/error-codes.js"; import { KEY_FILE_NAMES, type KeyCommandOptions, type VerifyKeyChainResult, } from "./keys.types.js"; import { checkSignal, decryptVault, extractSignPublicKey, loadFile, pickLogger, writeVaultPair, } from "./keys-io.js"; /** * Generate a root keychain whose public projection is self-signed via the * ed25519 `ChainedKeySigner`. Persists `root.vault` and `root.chain`. */ export async function createRootKeysChainCommand( password: string, options?: KeyCommandOptions ): Promise { if (!password) { throw new ServerKeysError( SERVER_KEYS_CODES.INVALID_INPUT, "Password is required for root key creation" ); } const outputDir = options?.output ?? "./keys"; checkSignal(options?.signal, "create-root-keys-chain"); const { keys, chain } = await createRootKeysWithChain(); checkSignal(options?.signal, "create-root-keys-chain"); const vaultBytes = await packVault(keys, password); const { vaultPath, publicPath } = await writeVaultPair( outputDir, KEY_FILE_NAMES.rootVault, KEY_FILE_NAMES.rootChain, vaultBytes, chain ); pickLogger(options).info( `Root keys + chain created: vault=${vaultPath} chain=${publicPath}` ); } /** * Derive a chained server keychain from an existing `root.vault` + * `root.chain`. The new chain is signed by the root keychain's `sign` * slot and attaches the parent root chain. */ export async function createServerKeysChainCommand( rootVaultPath: string, rootPassword: string, serverPassword: string, options?: KeyCommandOptions ): Promise { if (!rootVaultPath || !rootPassword || !serverPassword) { throw new ServerKeysError( SERVER_KEYS_CODES.INVALID_INPUT, "rootVaultPath, rootPassword, and serverPassword are required" ); } const outputDir = options?.output ?? "./keys"; const rootChainPath = rootVaultPath.replace(/\.vault$/, ".chain"); const rootChain = await loadFile(rootChainPath); const rootBytes = await loadFile(rootVaultPath); const rootKeychain = await decryptVault( rootBytes, rootPassword, rootVaultPath ); checkSignal(options?.signal, "create-server-keys-chain"); const { keys, chain } = await createServerKeysWithChain({ rootKeys: rootKeychain as unknown as Parameters< typeof createServerKeysWithChain >[0]["rootKeys"], rootChain, }); const vaultBytes = await packVault(keys, serverPassword); const { vaultPath, publicPath } = await writeVaultPair( outputDir, KEY_FILE_NAMES.serverVault, KEY_FILE_NAMES.serverChain, vaultBytes, chain ); pickLogger(options).info( `Server keys + chain created: vault=${vaultPath} chain=${publicPath}` ); } /** * Roll a chained server keychain to `currentVersion + 1`, re-signed by * the root keychain. Persists `server.v.vault` + `server.v.chain`. */ export async function rollServerKeysChainCommand( rootVaultPath: string, rootPassword: string, serverPassword: string, currentVersion: number, options?: KeyCommandOptions ): Promise { if ( !rootVaultPath || !rootPassword || !serverPassword || !Number.isInteger(currentVersion) || currentVersion < 0 ) { throw new ServerKeysError( SERVER_KEYS_CODES.INVALID_INPUT, "rootVaultPath, rootPassword, serverPassword, and a non-negative integer currentVersion are required" ); } const outputDir = options?.output ?? "./keys"; const rootChainPath = rootVaultPath.replace(/\.vault$/, ".chain"); const rootChain = await loadFile(rootChainPath); const rootBytes = await loadFile(rootVaultPath); const rootKeychain = await decryptVault( rootBytes, rootPassword, rootVaultPath ); checkSignal(options?.signal, "roll-server-keys-chain"); const { keys, chain } = await rollServerKeysWithChain({ rootKeys: rootKeychain as unknown as Parameters< typeof rollServerKeysWithChain >[0]["rootKeys"], rootChain, currentVersion, }); const vaultBytes = await packVault(keys, serverPassword); const newVersion = currentVersion + 1; const vaultName = `server.v${newVersion}.vault`; const chainName = `server.v${newVersion}.chain`; const { vaultPath, publicPath } = await writeVaultPair( outputDir, vaultName, chainName, vaultBytes, chain ); pickLogger(options).info( `Server keys + chain rolled v${currentVersion}→v${newVersion}: vault=${vaultPath} chain=${publicPath}` ); } /** * Verify a chain file against the `sign` slot of a trusted root public- * key (or chain) file. Returns the structured verification result — * never throws on a signature failure, only on input errors. */ export async function verifyKeyChainCommand( chainPath: string, rootKeyPath: string, options?: KeyCommandOptions ): Promise { if (!chainPath || !rootKeyPath) { throw new ServerKeysError( SERVER_KEYS_CODES.INVALID_INPUT, "chainPath and rootKeyPath are required" ); } const chainBytes = await loadFile(chainPath); const rootBytes = await loadFile(rootKeyPath); // `extractSignPublicKey` works on signed public-key files; chain // files carry the same public-keychain encoding in their root level, // but the signer's `verify(...)` works directly off the chain bytes. // For chain files we don't need a sign slot — for `.pub` files we do. // Try the signed-public-key path first; if it fails because there is // no embedded signature trailer, fall back to extracting the root // level from the chain itself. let trustedPublicKey: Uint8Array | undefined; let extractCause: unknown; try { trustedPublicKey = await extractSignPublicKey(rootBytes, rootKeyPath); } catch (err) { extractCause = err; } if (trustedPublicKey === undefined) { // Fall back: caller passed a chain file (not a signed-public-key // file) — extract the root level via the signer's parser. try { const fallback = new ChainedKeySigner(); const parsed = await fallback.parse(rootBytes); const rootLevel = parsed.levels[parsed.levels.length - 1]; if (!rootLevel) { let extras: { cause?: Error } | undefined; /* istanbul ignore start -- defensive: extractCause is the error caught above from extractSignPublicKey, which always throws ServerKeysError (Error subclass), so the else branch is unreachable. */ if (extractCause instanceof Error) { extras = { cause: extractCause }; } /* istanbul ignore stop */ throw new ServerKeysError( SERVER_KEYS_CODES.CHAIN_VERIFY_FAILED, `${rootKeyPath} contains no usable root level`, extras ); } trustedPublicKey = rootLevel.signerPublicKey; } catch (fallbackErr) { if (fallbackErr instanceof ServerKeysError) throw fallbackErr; let extras: { cause?: Error; context: { rootKeyPath: string }; } = { context: { rootKeyPath } }; /* istanbul ignore start -- defensive: ChainedKeySigner.parse always throws ChainKeysError (Error subclass) on malformed input, so the else branch is unreachable. */ if (fallbackErr instanceof Error) { extras = { cause: fallbackErr, context: { rootKeyPath }, }; } /* istanbul ignore stop */ throw new ServerKeysError( SERVER_KEYS_CODES.CHAIN_VERIFY_FAILED, `${rootKeyPath} is neither a signed-public-key file nor a chain file`, extras ); } } const signer = new ChainedKeySigner(); const result = await signer.verify(chainBytes, { algorithm: "ed25519", publicKey: trustedPublicKey, }); pickLogger(options).info( `verify-key-chain: chain=${chainPath} root=${rootKeyPath} valid=${result.valid} depth=${result.depth}` ); return { valid: result.valid, depth: result.depth, errors: result.errors, }; } // Re-export a parser helper for `view-chain-public` consumers that want // per-level introspection without re-verification. export async function parseChain( chainBytes: Uint8Array ): Promise<{ depth: number }> { const signer = new ChainedKeySigner(); const parsed = await signer.parse(chainBytes); return { depth: parsed.depth }; } // `viewPublicKeys` is re-exported so the chain-public viewer in // `keys.ts` can stay focused on logging while still letting consumers // of `keys-chain` introspect a signed-public-keychain blob in one // import. (Kept named to satisfy the no-`export *` barrel rule.) export { viewPublicKeys };