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