263 lines
9.4 KiB
TypeScript
263 lines
9.4 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
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<N>.vault` + `server.v<N>.chain`.
|
|
*/
|
|
export async function rollServerKeysChainCommand(
|
|
rootVaultPath: string,
|
|
rootPassword: string,
|
|
serverPassword: string,
|
|
currentVersion: number,
|
|
options?: KeyCommandOptions
|
|
): Promise<void> {
|
|
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<VerifyKeyChainResult> {
|
|
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 };
|