Initial Commit
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user