Initial Commit
This commit is contained in:
1
packages/security-stack/crypto/crypto-cli/README.md
Normal file
1
packages/security-stack/crypto/crypto-cli/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# @suaveplan/crypto-cli - Suaveplan keychain CLI — root + server identity lifecycle (init, roll, revoke, inspect, verify, export-public, restore)
|
||||
56
packages/security-stack/crypto/crypto-cli/package.json
Normal file
56
packages/security-stack/crypto/crypto-cli/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
53
packages/security-stack/crypto/crypto-cli/src/index.ts
Normal file
53
packages/security-stack/crypto/crypto-cli/src/index.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
12
packages/security-stack/crypto/crypto-cli/tsconfig.lib.json
Normal file
12
packages/security-stack/crypto/crypto-cli/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
43
packages/security-stack/crypto/crypto-cli/vite.config.ts
Normal file
43
packages/security-stack/crypto/crypto-cli/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
42
packages/security-stack/crypto/crypto-cli/vitest.config.ts
Normal file
42
packages/security-stack/crypto/crypto-cli/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user