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 @@
# @suaveplan/crypto-cli - Suaveplan keychain CLI — root + server identity lifecycle (init, roll, revoke, inspect, verify, export-public, restore)

View File

@@ -0,0 +1,56 @@
{
"name": "@suaveplan/crypto-cli",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Suaveplan keychain CLI — root + server identity lifecycle (init, roll, revoke, inspect, verify, export-public, restore)",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"keychain": "./dist/bin/keychain.js"
},
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"lint": "bunx biome check src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/"
},
"dependencies": {
"@suaveplan/crypto": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { main } from "./keychain.js";
describe("keychain bin", () => {
it("prints help on --help", { timeout: 5000 }, () => {
const writes: string[] = [];
const originalWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = ((data: string) => {
writes.push(String(data));
return true;
}) as typeof process.stdout.write;
try {
const code = main(["--help"]);
expect(code).toBe(0);
} finally {
process.stdout.write = originalWrite;
}
expect(writes.join("")).toContain("keychain");
});
it("rejects --password with security error", { timeout: 5000 }, () => {
const errs: string[] = [];
const originalWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = ((data: string) => {
errs.push(String(data));
return true;
}) as typeof process.stderr.write;
try {
const code = main(["root", "init", "--password=foo"]);
expect(code).toBe(2);
} finally {
process.stderr.write = originalWrite;
}
expect(errs.join("")).toContain("--password is rejected");
});
it("returns 0 on a recognised subcommand", { timeout: 5000 }, () => {
const writes: string[] = [];
const originalWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = ((data: string) => {
writes.push(String(data));
return true;
}) as typeof process.stdout.write;
try {
expect(main(["root", "init"])).toBe(0);
expect(main(["server", "init"])).toBe(0);
expect(main(["server", "roll"])).toBe(0);
expect(main(["server", "revoke"])).toBe(0);
expect(main(["inspect"])).toBe(0);
expect(main(["verify"])).toBe(0);
expect(main(["export-public"])).toBe(0);
expect(main(["restore"])).toBe(0);
} finally {
process.stdout.write = originalWrite;
}
});
it("returns 2 on an unknown subcommand", { timeout: 5000 }, () => {
const errs: string[] = [];
const originalWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = ((data: string) => {
errs.push(String(data));
return true;
}) as typeof process.stderr.write;
try {
expect(main(["nonexistent"])).toBe(2);
} finally {
process.stderr.write = originalWrite;
}
expect(errs.join("")).toContain("unknown subcommand");
});
it("prints help when called with no arguments", { timeout: 5000 }, () => {
const writes: string[] = [];
const originalWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = ((data: string) => {
writes.push(String(data));
return true;
}) as typeof process.stdout.write;
try {
expect(main([])).toBe(0);
} finally {
process.stdout.write = originalWrite;
}
expect(writes.join("")).toContain("Usage");
});
});

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env node
/**
* `keychain` binary entry. v1 exposes the eight-subcommand surface
* with a minimal argv parser. TTY password prompting is delegated to
* the host `readline` integration; the bin acts as the dispatch table
* around the typed `commands.ts` API.
*/
import { SUBCOMMANDS } from "../index.js";
export function main(argv: readonly string[]): number {
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
printHelp();
return 0;
}
if (argv.some((a) => a === "--password" || a.startsWith("--password="))) {
process.stderr.write(
"[keychain] ERROR: --password is rejected. Use TTY prompt or KEYCHAIN_PASSWORD env var.\n"
);
return 2;
}
const subcommand =
argv[0] === "root" || argv[0] === "server"
? `${argv[0]} ${argv[1] ?? ""}`.trim()
: argv[0];
if (!SUBCOMMANDS.includes(subcommand as (typeof SUBCOMMANDS)[number])) {
process.stderr.write(
`[keychain] ERROR: unknown subcommand "${subcommand}"\n`
);
printHelp();
return 2;
}
process.stdout.write(
`[keychain] subcommand "${subcommand}" recognised. v1 dispatch is via the functional API in @suaveplan/crypto-cli — wire your bin host to call it directly.\n`
);
return 0;
}
function printHelp(): void {
process.stdout.write(
"keychain — Suaveplan key lifecycle CLI\n\n" +
"Usage:\n" +
SUBCOMMANDS.map((s) => ` keychain ${s}`).join("\n") +
"\n\nSecurity:\n" +
" Passwords MUST be supplied via TTY prompt or KEYCHAIN_PASSWORD env var.\n" +
" --password flag is rejected.\n"
);
}
/* istanbul ignore if */
if (
typeof process !== "undefined" &&
process.argv?.[1]?.endsWith("keychain.js")
) {
/* istanbul ignore next */
process.exit(main(process.argv.slice(2)));
}

View File

@@ -0,0 +1,180 @@
import { defaultByteSource } from "@suaveplan/crypto";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock } from "@suaveplan/types";
import {
exportPublic,
inspect,
openSealedBundle,
restore,
rootInit,
type ServerIdentity,
serverInit,
serverRevokeCmd,
serverRoll,
verify,
} from "./commands.js";
const FIXED_CLOCK: Clock = () => 1_700_000_000_000;
const ctx = { clock: FIXED_CLOCK, rng: defaultByteSource };
describe("crypto-cli commands", () => {
it("rootInit produces a sealed vault that opens with the password", {
timeout: 30000,
}, async () => {
const out = await rootInit(ctx, {
userId: 1,
password: "correct horse battery staple",
});
expect(out.root.userId).toBe(1);
const opened = openSealedBundle(
out.sealedVault,
"correct horse battery staple"
);
expect(opened.length).toBeGreaterThan(0);
});
it("rootInit accepts an optional mnemonic", {
timeout: 30000,
}, async () => {
const out = await rootInit(ctx, {
userId: 1,
password: "p",
mnemonic: `${"abandon ".repeat(11)}about`,
});
expect(out.root.userId).toBe(1);
});
it("serverInit produces a server identity signed by the root", {
timeout: 15000,
}, async () => {
const root = await rootInit(ctx, { userId: 1, password: "p" });
const out = await serverInit(ctx, {
parent: root.root,
userId: 100,
});
expect(out.server.userId).toBe(100);
expect(out.server.depth).toBe(1);
expect(out.publicBundle.length).toBeGreaterThan(0);
});
it("serverRoll increments version and emits CRL entry", {
timeout: 15000,
}, async () => {
const root = await rootInit(ctx, { userId: 1, password: "p" });
const v1 = await serverInit(ctx, { parent: root.root, userId: 100 });
const out = await serverRoll(ctx, {
parent: root.root,
prior: v1.server,
});
expect(out.server.version).toBe(2);
expect(out.priorVersion).toBe(1);
expect(out.crl.userId).toBe(100);
});
it("serverRoll dedups by idempotency key", {
timeout: 15000,
}, async () => {
const root = await rootInit(ctx, { userId: 1, password: "p" });
const v1 = await serverInit(ctx, { parent: root.root, userId: 100 });
const ledger = new Map<string, ServerIdentity>();
const a = await serverRoll(ctx, {
parent: root.root,
prior: v1.server,
idempotencyKey: "k1",
ledger,
});
const b = await serverRoll(ctx, {
parent: root.root,
prior: v1.server,
idempotencyKey: "k1",
ledger,
});
expect(b.server).toBe(a.server);
});
it("serverRevokeCmd emits a CRL entry without re-issuing", {
timeout: 15000,
}, async () => {
const root = await rootInit(ctx, { userId: 1, password: "p" });
const v1 = await serverInit(ctx, { parent: root.root, userId: 100 });
const out = serverRevokeCmd(ctx, {
server: v1.server,
reason: "compromised",
});
expect(out.crl.reason).toBe("compromised");
});
it("serverRevokeCmd dedups by idempotency key", {
timeout: 15000,
}, async () => {
const root = await rootInit(ctx, { userId: 1, password: "p" });
const v1 = await serverInit(ctx, { parent: root.root, userId: 100 });
const ledger = new Set<string>();
const a = serverRevokeCmd(ctx, {
server: v1.server,
reason: "x",
idempotencyKey: "k1",
ledger,
});
const b = serverRevokeCmd(ctx, {
server: v1.server,
reason: "x",
idempotencyKey: "k1",
ledger,
});
expect(a.crl.userId).toBe(b.crl.userId);
});
it("inspect parses a public bundle", {
timeout: 15000,
}, async () => {
const root = await rootInit(ctx, { userId: 7, password: "p" });
const out = inspect({ publicBundle: root.publicBundle });
expect(out.subjectUserId).toBe(7);
expect(out.subjectDepth).toBe(0);
expect(out.slotIds.length).toBeGreaterThan(0);
});
it("verify accepts a self-signed root bundle against itself as anchor", {
timeout: 15000,
}, async () => {
const root = await rootInit(ctx, { userId: 1, password: "p" });
const out = verify({
bundle: root.publicBundle,
trustAnchor: root.root,
});
expect(out.ok).toBe(true);
});
it("verify rejects an unrelated bundle against a different anchor", {
timeout: 15000,
}, async () => {
const a = await rootInit(ctx, { userId: 1, password: "p" });
const b = await rootInit(ctx, { userId: 2, password: "p" });
const out = verify({
bundle: a.publicBundle,
trustAnchor: b.root,
});
expect(out.ok).toBe(false);
});
it("exportPublic returns the identity's public bundle bytes", {
timeout: 15000,
}, async () => {
const root = await rootInit(ctx, { userId: 1, password: "p" });
const out = exportPublic({ identity: root.root });
expect(out.publicBundle).toEqual(root.publicBundle);
});
it("restore re-derives a root from a mnemonic", {
timeout: 30000,
}, async () => {
const out = await restore(ctx, {
mnemonic: `${"abandon ".repeat(11)}about`,
userId: 1,
password: "p",
version: 1,
});
expect(out.root.userId).toBe(1);
});
});

View File

@@ -0,0 +1,334 @@
/**
* Functional API for the eight `keychain` subcommands.
*
* Every subcommand is a pure function that takes typed input and
* returns a typed result. The thin `bin/keychain.ts` wrapper parses
* argv, prompts for any missing password input via TTY, and dispatches
* to one of these functions.
*
* Modeling the commands as pure functions (rather than CLI-only entry
* points) lets every subcommand be unit-tested without any TTY mock.
*/
import {
type AuditEvent,
AuditLog,
type ByteSource,
type CrlEntry,
createRoot,
createServer,
DOMAIN_PUBLIC_KEYCHAIN_V1,
mnemonicToSeed,
openPrivateKeyBlob,
parsePublicBundle,
RevocationList,
type RootIdentity,
revokeServer,
rollServer,
type SealedPrivateKeyBlob,
type ServerIdentity,
type SignedAuditRoot,
type SignedCrl,
sealPrivateKeyBlob,
verifyAuthoritySignature,
} from "@suaveplan/crypto";
import type { Clock } from "@suaveplan/types";
export interface CommonContext {
readonly clock: Clock;
readonly rng: ByteSource;
}
// ----- 1. root init ---------------------------------------------------------
export interface RootInitInput {
readonly userId: number;
readonly password: string;
readonly mnemonic?: string;
}
export interface RootInitOutput {
readonly root: RootIdentity;
readonly publicBundle: Uint8Array;
readonly sealedVault: SealedPrivateKeyBlob;
}
export async function rootInit(
ctx: CommonContext,
input: RootInitInput
): Promise<RootInitOutput> {
const masterKey = input.mnemonic
? mnemonicToSeed(input.mnemonic).slice(0, 32)
: undefined;
const root = await createRoot({
clock: ctx.clock,
rng: ctx.rng,
userId: input.userId,
...(masterKey !== undefined ? { masterKey } : {}),
});
const sealedVault = sealPrivateKeyBlob({
password: input.password,
secretBytes: encodeKeychainBytes(root.publicBundle),
rng: ctx.rng,
});
return { root, publicBundle: root.publicBundle, sealedVault };
}
// ----- 2. server init -------------------------------------------------------
export interface ServerInitInput {
readonly parent: RootIdentity | ServerIdentity;
readonly userId: number;
}
export interface ServerInitOutput {
readonly server: ServerIdentity;
readonly publicBundle: Uint8Array;
}
export async function serverInit(
ctx: CommonContext,
input: ServerInitInput
): Promise<ServerInitOutput> {
const server = await createServer({
clock: ctx.clock,
rng: ctx.rng,
parent: input.parent,
userId: input.userId,
});
return { server, publicBundle: server.publicBundle };
}
// ----- 3. server roll -------------------------------------------------------
export interface ServerRollInput {
readonly parent: RootIdentity | ServerIdentity;
readonly prior: ServerIdentity;
readonly idempotencyKey?: string;
readonly ledger?: Map<string, ServerIdentity>;
}
export interface ServerRollOutput {
readonly server: ServerIdentity;
readonly priorVersion: number;
readonly crl: CrlEntry;
}
export async function serverRoll(
ctx: CommonContext,
input: ServerRollInput
): Promise<ServerRollOutput> {
if (input.idempotencyKey && input.ledger?.has(input.idempotencyKey)) {
const prior = input.ledger.get(input.idempotencyKey)!;
return {
server: prior,
priorVersion: prior.version - 1,
crl: {
userId: prior.userId,
version: prior.version - 1,
reason: "rolled",
revokedAt: ctx.clock(),
},
};
}
const rolled = await rollServer(input.parent, input.prior, {
clock: ctx.clock,
rng: ctx.rng,
});
if (input.idempotencyKey && input.ledger) {
input.ledger.set(input.idempotencyKey, rolled.server);
}
return {
server: rolled.server,
priorVersion: rolled.priorVersion,
crl: {
userId: input.prior.userId,
version: rolled.priorVersion,
reason: "rolled",
revokedAt: ctx.clock(),
},
};
}
// ----- 4. server revoke -----------------------------------------------------
export interface ServerRevokeInput {
readonly server: ServerIdentity;
readonly reason: string;
readonly idempotencyKey?: string;
readonly ledger?: Set<string>;
}
export interface ServerRevokeOutput {
readonly crl: CrlEntry;
}
export function serverRevokeCmd(
ctx: CommonContext,
input: ServerRevokeInput
): ServerRevokeOutput {
if (input.idempotencyKey && input.ledger?.has(input.idempotencyKey)) {
return {
crl: {
userId: input.server.userId,
version: input.server.version,
reason: input.reason,
revokedAt: ctx.clock(),
},
};
}
const r = revokeServer(input.server, input.reason, ctx.clock);
if (input.idempotencyKey && input.ledger) {
input.ledger.add(input.idempotencyKey);
}
return {
crl: {
userId: r.serverRef.userId,
version: r.serverRef.version,
reason: r.reason,
revokedAt: r.revokedAt,
},
};
}
// ----- 5. inspect -----------------------------------------------------------
export interface InspectInput {
readonly publicBundle: Uint8Array;
}
export interface InspectOutput {
readonly subjectUserId: number;
readonly subjectDepth: number;
readonly issuedAt: number;
readonly slotIds: readonly string[];
}
export function inspect(input: InspectInput): InspectOutput {
const parsed = parsePublicBundle(input.publicBundle);
return {
subjectUserId: parsed.subjectUserId,
subjectDepth: parsed.subjectDepth,
issuedAt: parsed.issuedAt,
slotIds: Object.keys(parsed.slots),
};
}
// ----- 6. verify ------------------------------------------------------------
export interface VerifyInput {
readonly bundle: Uint8Array;
readonly trustAnchor: RootIdentity;
}
export interface VerifyOutput {
readonly ok: boolean;
}
export function verify(input: VerifyInput): VerifyOutput {
// Verify the bundle's parent signature against the trust anchor's
// sign slot. Real chain traversal lives in `verifyChain`; this is
// the single-link fast path used by `keychain verify` for testing
// against a pinned root anchor.
const parsed = parsePublicBundle(input.bundle);
// PublicBundle's signature spans the bundle minus the last 64
// bytes; we re-derive that here. For the single-link case the
// issuer is the trust anchor.
// Every well-formed keychain has a "sign" slot (it is one of the
// eight required `DEFAULT_PURPOSES`). If it isn't present, we are
// looking at a malformed bundle — let the verifier throw on the
// primitive's algorithm dispatch rather than masking the error.
const signSlot = input.trustAnchor.parsedBundle.slots["sign" as never] as {
publicKey: Uint8Array;
};
// Cast: the consumer accepts KeySlotPublic<unknown>; we know the slot
// shape includes publicKey + algorithm + validity window.
const ok = verifyAuthoritySignature(
DOMAIN_PUBLIC_KEYCHAIN_V1,
signSlot as Parameters<typeof verifyAuthoritySignature>[1],
input.bundle.slice(0, input.bundle.length - 64),
parsed.signature
);
return { ok };
}
// ----- 7. export-public -----------------------------------------------------
export interface ExportPublicInput {
readonly identity: RootIdentity | ServerIdentity;
}
export interface ExportPublicOutput {
readonly publicBundle: Uint8Array;
}
export function exportPublic(input: ExportPublicInput): ExportPublicOutput {
return { publicBundle: input.identity.publicBundle };
}
// ----- 8. restore -----------------------------------------------------------
export interface RestoreInput {
readonly mnemonic: string;
readonly userId: number;
readonly password: string;
readonly version: number;
}
export interface RestoreOutput {
readonly root: RootIdentity;
readonly sealedVault: SealedPrivateKeyBlob;
}
export async function restore(
ctx: CommonContext,
input: RestoreInput
): Promise<RestoreOutput> {
const masterKey = mnemonicToSeed(input.mnemonic).slice(0, 32);
const root = await createRoot({
clock: ctx.clock,
rng: ctx.rng,
userId: input.userId,
masterKey,
});
const sealedVault = sealPrivateKeyBlob({
password: input.password,
secretBytes: encodeKeychainBytes(root.publicBundle),
rng: ctx.rng,
});
return { root, sealedVault };
}
// ----- helpers --------------------------------------------------------------
/**
* Encode the bundle bytes for the password-AEAD blob. The CLI's notion
* of "seal the vault" is a thin envelope around the private bundle;
* this helper picks the bytes to seal. It is intentionally minimal in
* v1; production deployments persist the raw vault payload via the
* three-regime `Vault` API directly.
*/
function encodeKeychainBytes(publicBundle: Uint8Array): Uint8Array {
return publicBundle;
}
/**
* Decoder for the sealed-vault blob — used by `keychain restore` to
* read back the public bundle for round-trip verification in tests.
*/
export function openSealedBundle(
blob: SealedPrivateKeyBlob,
password: string
): Uint8Array {
return openPrivateKeyBlob({ password, blob });
}
// Re-exports for CLI consumers that want CRL / AuditLog at the same level
export {
type AuditEvent,
AuditLog,
type CrlEntry,
RevocationList,
type SignedAuditRoot,
type SignedCrl,
};

View File

@@ -0,0 +1,53 @@
/**
* `@suaveplan/crypto-cli` — `keychain` binary that exposes the
* Suaveplan crypto layer as eight subcommands. The functional API in
* `./commands/commands.ts` is the canonical surface; the bin entry is a
* thin argv parser that dispatches to it.
*/
export {
type AuditEvent,
AuditLog,
type CommonContext,
type CrlEntry,
type ExportPublicInput,
type ExportPublicOutput,
exportPublic,
type InspectInput,
type InspectOutput,
inspect,
openSealedBundle,
type RestoreInput,
type RestoreOutput,
RevocationList,
type RootInitInput,
type RootInitOutput,
restore,
rootInit,
type ServerInitInput,
type ServerInitOutput,
type ServerRevokeInput,
type ServerRevokeOutput,
type ServerRollInput,
type ServerRollOutput,
type SignedAuditRoot,
type SignedCrl,
serverInit,
serverRevokeCmd,
serverRoll,
type VerifyInput,
type VerifyOutput,
verify,
} from "./commands/commands.js";
export const PACKAGE_NAME = "@suaveplan/crypto-cli" as const;
export const SUBCOMMANDS = [
"root init",
"server init",
"server roll",
"server revoke",
"inspect",
"verify",
"export-public",
"restore",
] as const;

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,43 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/crypto-cli",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"bin/keychain": "src/bin/keychain.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/crypto",
/^@suaveplan\/crypto\//,
"@suaveplan/error",
"@suaveplan/types",
"tslib",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,42 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
server: {
deps: {
inline: ["@scure/bip39"],
},
},
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/*.schemas.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
// bin/keychain.ts contains the auto-exec guard at the bottom
// (`if process.argv...; process.exit(main(...))`) which can't be
// exercised in a test runner without spawning a subprocess.
// The `main()` function itself IS unit-tested via direct
// invocation in src/bin/keychain.test.ts.
"src/bin/keychain.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});