Initial Commit
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# `bin/create-root-keys-chain`
|
||||
|
||||
## Purpose
|
||||
|
||||
Commander entry point for the `create-root-keys-chain` operator command. Parses CLI
|
||||
flags, prompts for any required passwords via `@suaveplan/server-keys/prompt`,
|
||||
and dispatches to the matching function in
|
||||
`@suaveplan/server-keys/commands/`. The body runs inside
|
||||
`runFunction("create-root-keys-chain", ...)` so OTel emission, structured stderr
|
||||
banners, and `exit(1)` on failure are all handled by the shared
|
||||
wrapper instead of being re-implemented per bin.
|
||||
|
||||
This file is a thin wrapper — operator-facing behaviour lives in the
|
||||
`commands/` module the bin imports. The bin's only responsibilities
|
||||
are flag parsing, password prompting, and exit-code threading; the
|
||||
spec compliance, error handling, and OTel emission all come from the
|
||||
shared wrapper layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `commander` flag parsing with `-h/--help` for every sub-command.
|
||||
- Password prompts via `@inquirer/prompts` (echo-disabled).
|
||||
- OTel command-total + duration histogram per invocation.
|
||||
- Structured `SERVER_KEYS_*` error banners on failure.
|
||||
- `process.exit(1)` on any thrown failure, after the banner.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```bash
|
||||
create-root-keys-chain --help
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the bin from a shell with a non-TTY `stdin` (e.g. via process
|
||||
substitution) to script multiple operations:
|
||||
|
||||
```bash
|
||||
echo "mypassword" | create-root-keys-chain [flags...]
|
||||
```
|
||||
|
||||
When invoked under a `SIGINT`-trapping shell, the bin honours the
|
||||
shared `AbortController` plumbed through `runFunction`.
|
||||
|
||||
## API Reference
|
||||
|
||||
This bin exposes its surface via `commander` flags only. Run
|
||||
`create-root-keys-chain --help` for the authoritative list. The library-level surface
|
||||
the bin wraps is documented under the matching `commands/` module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bin imports its target command lazily where possible to keep `bun
|
||||
link` cold-start time short. Every prompt accepts `{ inputStream,
|
||||
outputStream, signal }` via `PromptOptions` so tests can drive the
|
||||
bin without a real TTY; in production the defaults resolve to
|
||||
`process.stdin` / `process.stdout`.
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import { createRootKeysChainCommand } from "../commands/keys/keys-chain.js";
|
||||
import { promptForNewPassword } from "../prompt/prompt.js";
|
||||
import { runFunction } from "../run-function/run-function.js";
|
||||
|
||||
const program = new Command()
|
||||
.name("create-root-keys-chain")
|
||||
.description(
|
||||
"Create root keychain with a self-signed public-key chain (vault + chain file)"
|
||||
)
|
||||
.option("-o, --output <dir>", "Output directory", "./keys")
|
||||
.action((options: { output: string }) =>
|
||||
runFunction("create-root-keys-chain", async (signal) => {
|
||||
const password = await promptForNewPassword(
|
||||
"Enter password for the root vault"
|
||||
);
|
||||
await createRootKeysChainCommand(password, {
|
||||
output: options.output,
|
||||
...(signal !== undefined ? { signal } : {}),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,57 @@
|
||||
# `bin/create-root-keys`
|
||||
|
||||
## Purpose
|
||||
|
||||
Commander entry point for the `create-root-keys` operator command. Parses CLI
|
||||
flags, prompts for any required passwords via `@suaveplan/server-keys/prompt`,
|
||||
and dispatches to the matching function in
|
||||
`@suaveplan/server-keys/commands/`. The body runs inside
|
||||
`runFunction("create-root-keys", ...)` so OTel emission, structured stderr
|
||||
banners, and `exit(1)` on failure are all handled by the shared
|
||||
wrapper instead of being re-implemented per bin.
|
||||
|
||||
This file is a thin wrapper — operator-facing behaviour lives in the
|
||||
`commands/` module the bin imports. The bin's only responsibilities
|
||||
are flag parsing, password prompting, and exit-code threading; the
|
||||
spec compliance, error handling, and OTel emission all come from the
|
||||
shared wrapper layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `commander` flag parsing with `-h/--help` for every sub-command.
|
||||
- Password prompts via `@inquirer/prompts` (echo-disabled).
|
||||
- OTel command-total + duration histogram per invocation.
|
||||
- Structured `SERVER_KEYS_*` error banners on failure.
|
||||
- `process.exit(1)` on any thrown failure, after the banner.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```bash
|
||||
create-root-keys --help
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the bin from a shell with a non-TTY `stdin` (e.g. via process
|
||||
substitution) to script multiple operations:
|
||||
|
||||
```bash
|
||||
echo "mypassword" | create-root-keys [flags...]
|
||||
```
|
||||
|
||||
When invoked under a `SIGINT`-trapping shell, the bin honours the
|
||||
shared `AbortController` plumbed through `runFunction`.
|
||||
|
||||
## API Reference
|
||||
|
||||
This bin exposes its surface via `commander` flags only. Run
|
||||
`create-root-keys --help` for the authoritative list. The library-level surface
|
||||
the bin wraps is documented under the matching `commands/` module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bin imports its target command lazily where possible to keep `bun
|
||||
link` cold-start time short. Every prompt accepts `{ inputStream,
|
||||
outputStream, signal }` via `PromptOptions` so tests can drive the
|
||||
bin without a real TTY; in production the defaults resolve to
|
||||
`process.stdin` / `process.stdout`.
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import { createRootKeysCommand } from "../commands/keys/keys.js";
|
||||
import { promptForNewPassword } from "../prompt/prompt.js";
|
||||
import { runFunction } from "../run-function/run-function.js";
|
||||
|
||||
const program = new Command()
|
||||
.name("create-root-keys")
|
||||
.description(
|
||||
"Create root keychain (encrypted vault + signed public-key file)"
|
||||
)
|
||||
.option("-o, --output <dir>", "Output directory", "./keys")
|
||||
.action((options: { output: string }) =>
|
||||
runFunction("create-root-keys", async (signal) => {
|
||||
const password = await promptForNewPassword(
|
||||
"Enter password for the root vault"
|
||||
);
|
||||
await createRootKeysCommand(password, {
|
||||
output: options.output,
|
||||
...(signal !== undefined ? { signal } : {}),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,57 @@
|
||||
# `bin/create-server-keys-chain`
|
||||
|
||||
## Purpose
|
||||
|
||||
Commander entry point for the `create-server-keys-chain` operator command. Parses CLI
|
||||
flags, prompts for any required passwords via `@suaveplan/server-keys/prompt`,
|
||||
and dispatches to the matching function in
|
||||
`@suaveplan/server-keys/commands/`. The body runs inside
|
||||
`runFunction("create-server-keys-chain", ...)` so OTel emission, structured stderr
|
||||
banners, and `exit(1)` on failure are all handled by the shared
|
||||
wrapper instead of being re-implemented per bin.
|
||||
|
||||
This file is a thin wrapper — operator-facing behaviour lives in the
|
||||
`commands/` module the bin imports. The bin's only responsibilities
|
||||
are flag parsing, password prompting, and exit-code threading; the
|
||||
spec compliance, error handling, and OTel emission all come from the
|
||||
shared wrapper layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `commander` flag parsing with `-h/--help` for every sub-command.
|
||||
- Password prompts via `@inquirer/prompts` (echo-disabled).
|
||||
- OTel command-total + duration histogram per invocation.
|
||||
- Structured `SERVER_KEYS_*` error banners on failure.
|
||||
- `process.exit(1)` on any thrown failure, after the banner.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```bash
|
||||
create-server-keys-chain --help
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the bin from a shell with a non-TTY `stdin` (e.g. via process
|
||||
substitution) to script multiple operations:
|
||||
|
||||
```bash
|
||||
echo "mypassword" | create-server-keys-chain [flags...]
|
||||
```
|
||||
|
||||
When invoked under a `SIGINT`-trapping shell, the bin honours the
|
||||
shared `AbortController` plumbed through `runFunction`.
|
||||
|
||||
## API Reference
|
||||
|
||||
This bin exposes its surface via `commander` flags only. Run
|
||||
`create-server-keys-chain --help` for the authoritative list. The library-level surface
|
||||
the bin wraps is documented under the matching `commands/` module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bin imports its target command lazily where possible to keep `bun
|
||||
link` cold-start time short. Every prompt accepts `{ inputStream,
|
||||
outputStream, signal }` via `PromptOptions` so tests can drive the
|
||||
bin without a real TTY; in production the defaults resolve to
|
||||
`process.stdin` / `process.stdout`.
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import { createServerKeysChainCommand } from "../commands/keys/keys-chain.js";
|
||||
import { promptForNewPassword, promptForPassword } from "../prompt/prompt.js";
|
||||
import { runFunction } from "../run-function/run-function.js";
|
||||
|
||||
const program = new Command()
|
||||
.name("create-server-keys-chain")
|
||||
.description(
|
||||
"Derive a chained server keychain (vault + chain file signed by root)"
|
||||
)
|
||||
.option(
|
||||
"-r, --root-vault <path>",
|
||||
"Path to root vault",
|
||||
"./keys/root.vault"
|
||||
)
|
||||
.option("-o, --output <dir>", "Output directory", "./keys")
|
||||
.action((options: { rootVault: string; output: string }) =>
|
||||
runFunction("create-server-keys-chain", async (signal) => {
|
||||
const rootPassword = await promptForPassword(
|
||||
"Enter root vault password"
|
||||
);
|
||||
const serverPassword = await promptForNewPassword(
|
||||
"Enter password for the new server vault"
|
||||
);
|
||||
await createServerKeysChainCommand(
|
||||
options.rootVault,
|
||||
rootPassword,
|
||||
serverPassword,
|
||||
{
|
||||
output: options.output,
|
||||
...(signal !== undefined ? { signal } : {}),
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,57 @@
|
||||
# `bin/create-server-keys`
|
||||
|
||||
## Purpose
|
||||
|
||||
Commander entry point for the `create-server-keys` operator command. Parses CLI
|
||||
flags, prompts for any required passwords via `@suaveplan/server-keys/prompt`,
|
||||
and dispatches to the matching function in
|
||||
`@suaveplan/server-keys/commands/`. The body runs inside
|
||||
`runFunction("create-server-keys", ...)` so OTel emission, structured stderr
|
||||
banners, and `exit(1)` on failure are all handled by the shared
|
||||
wrapper instead of being re-implemented per bin.
|
||||
|
||||
This file is a thin wrapper — operator-facing behaviour lives in the
|
||||
`commands/` module the bin imports. The bin's only responsibilities
|
||||
are flag parsing, password prompting, and exit-code threading; the
|
||||
spec compliance, error handling, and OTel emission all come from the
|
||||
shared wrapper layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `commander` flag parsing with `-h/--help` for every sub-command.
|
||||
- Password prompts via `@inquirer/prompts` (echo-disabled).
|
||||
- OTel command-total + duration histogram per invocation.
|
||||
- Structured `SERVER_KEYS_*` error banners on failure.
|
||||
- `process.exit(1)` on any thrown failure, after the banner.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```bash
|
||||
create-server-keys --help
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the bin from a shell with a non-TTY `stdin` (e.g. via process
|
||||
substitution) to script multiple operations:
|
||||
|
||||
```bash
|
||||
echo "mypassword" | create-server-keys [flags...]
|
||||
```
|
||||
|
||||
When invoked under a `SIGINT`-trapping shell, the bin honours the
|
||||
shared `AbortController` plumbed through `runFunction`.
|
||||
|
||||
## API Reference
|
||||
|
||||
This bin exposes its surface via `commander` flags only. Run
|
||||
`create-server-keys --help` for the authoritative list. The library-level surface
|
||||
the bin wraps is documented under the matching `commands/` module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bin imports its target command lazily where possible to keep `bun
|
||||
link` cold-start time short. Every prompt accepts `{ inputStream,
|
||||
outputStream, signal }` via `PromptOptions` so tests can drive the
|
||||
bin without a real TTY; in production the defaults resolve to
|
||||
`process.stdin` / `process.stdout`.
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import { createServerKeysCommand } from "../commands/keys/keys.js";
|
||||
import { promptForNewPassword, promptForPassword } from "../prompt/prompt.js";
|
||||
import { runFunction } from "../run-function/run-function.js";
|
||||
|
||||
const program = new Command()
|
||||
.name("create-server-keys")
|
||||
.description("Derive a server keychain from a root vault")
|
||||
.option(
|
||||
"-r, --root-vault <path>",
|
||||
"Path to root vault",
|
||||
"./keys/root.vault"
|
||||
)
|
||||
.option("-o, --output <dir>", "Output directory", "./keys")
|
||||
.action((options: { rootVault: string; output: string }) =>
|
||||
runFunction("create-server-keys", async (signal) => {
|
||||
const rootPassword = await promptForPassword(
|
||||
"Enter root vault password"
|
||||
);
|
||||
const serverPassword = await promptForNewPassword(
|
||||
"Enter password for the new server vault"
|
||||
);
|
||||
await createServerKeysCommand(
|
||||
options.rootVault,
|
||||
rootPassword,
|
||||
serverPassword,
|
||||
{
|
||||
output: options.output,
|
||||
...(signal !== undefined ? { signal } : {}),
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,57 @@
|
||||
# `bin/get-file-stats`
|
||||
|
||||
## Purpose
|
||||
|
||||
Commander entry point for the `get-file-stats` operator command. Parses CLI
|
||||
flags, prompts for any required passwords via `@suaveplan/server-keys/prompt`,
|
||||
and dispatches to the matching function in
|
||||
`@suaveplan/server-keys/commands/`. The body runs inside
|
||||
`runFunction("get-file-stats", ...)` so OTel emission, structured stderr
|
||||
banners, and `exit(1)` on failure are all handled by the shared
|
||||
wrapper instead of being re-implemented per bin.
|
||||
|
||||
This file is a thin wrapper — operator-facing behaviour lives in the
|
||||
`commands/` module the bin imports. The bin's only responsibilities
|
||||
are flag parsing, password prompting, and exit-code threading; the
|
||||
spec compliance, error handling, and OTel emission all come from the
|
||||
shared wrapper layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `commander` flag parsing with `-h/--help` for every sub-command.
|
||||
- Password prompts via `@inquirer/prompts` (echo-disabled).
|
||||
- OTel command-total + duration histogram per invocation.
|
||||
- Structured `SERVER_KEYS_*` error banners on failure.
|
||||
- `process.exit(1)` on any thrown failure, after the banner.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```bash
|
||||
get-file-stats --help
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the bin from a shell with a non-TTY `stdin` (e.g. via process
|
||||
substitution) to script multiple operations:
|
||||
|
||||
```bash
|
||||
echo "mypassword" | get-file-stats [flags...]
|
||||
```
|
||||
|
||||
When invoked under a `SIGINT`-trapping shell, the bin honours the
|
||||
shared `AbortController` plumbed through `runFunction`.
|
||||
|
||||
## API Reference
|
||||
|
||||
This bin exposes its surface via `commander` flags only. Run
|
||||
`get-file-stats --help` for the authoritative list. The library-level surface
|
||||
the bin wraps is documented under the matching `commands/` module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bin imports its target command lazily where possible to keep `bun
|
||||
link` cold-start time short. Every prompt accepts `{ inputStream,
|
||||
outputStream, signal }` via `PromptOptions` so tests can drive the
|
||||
bin without a real TTY; in production the defaults resolve to
|
||||
`process.stdin` / `process.stdout`.
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import { getFileStatsCommand } from "../commands/files/files.js";
|
||||
import { runFunction } from "../run-function/run-function.js";
|
||||
|
||||
const program = new Command()
|
||||
.name("get-file-stats")
|
||||
.description("Print file packing statistics without performing any packing")
|
||||
.requiredOption("-f, --file <path>", "Input file path")
|
||||
.option(
|
||||
"--packet-size <bytes>",
|
||||
"Packet size in bytes",
|
||||
Number.parseInt,
|
||||
1048576
|
||||
)
|
||||
.action((options: { file: string; packetSize: number }) =>
|
||||
runFunction("get-file-stats", async () => {
|
||||
await getFileStatsCommand(options.file, {
|
||||
packetSize: options.packetSize,
|
||||
logger: {
|
||||
info: (m: string) => process.stdout.write(`${m}\n`),
|
||||
error: (m: string) => process.stderr.write(`${m}\n`),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,57 @@
|
||||
# `bin/index`
|
||||
|
||||
## Purpose
|
||||
|
||||
Commander entry point for the `index` operator command. Parses CLI
|
||||
flags, prompts for any required passwords via `@suaveplan/server-keys/prompt`,
|
||||
and dispatches to the matching function in
|
||||
`@suaveplan/server-keys/commands/`. The body runs inside
|
||||
`runFunction("index", ...)` so OTel emission, structured stderr
|
||||
banners, and `exit(1)` on failure are all handled by the shared
|
||||
wrapper instead of being re-implemented per bin.
|
||||
|
||||
This file is a thin wrapper — operator-facing behaviour lives in the
|
||||
`commands/` module the bin imports. The bin's only responsibilities
|
||||
are flag parsing, password prompting, and exit-code threading; the
|
||||
spec compliance, error handling, and OTel emission all come from the
|
||||
shared wrapper layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `commander` flag parsing with `-h/--help` for every sub-command.
|
||||
- Password prompts via `@inquirer/prompts` (echo-disabled).
|
||||
- OTel command-total + duration histogram per invocation.
|
||||
- Structured `SERVER_KEYS_*` error banners on failure.
|
||||
- `process.exit(1)` on any thrown failure, after the banner.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```bash
|
||||
index --help
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the bin from a shell with a non-TTY `stdin` (e.g. via process
|
||||
substitution) to script multiple operations:
|
||||
|
||||
```bash
|
||||
echo "mypassword" | index [flags...]
|
||||
```
|
||||
|
||||
When invoked under a `SIGINT`-trapping shell, the bin honours the
|
||||
shared `AbortController` plumbed through `runFunction`.
|
||||
|
||||
## API Reference
|
||||
|
||||
This bin exposes its surface via `commander` flags only. Run
|
||||
`index --help` for the authoritative list. The library-level surface
|
||||
the bin wraps is documented under the matching `commands/` module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bin imports its target command lazily where possible to keep `bun
|
||||
link` cold-start time short. Every prompt accepts `{ inputStream,
|
||||
outputStream, signal }` via `PromptOptions` so tests can drive the
|
||||
bin without a real TTY; in production the defaults resolve to
|
||||
`process.stdin` / `process.stdout`.
|
||||
119
packages/security-stack/security/server-keys/src/bin/index.ts
Normal file
119
packages/security-stack/security/server-keys/src/bin/index.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
|
||||
const SUBCOMMANDS: ReadonlyArray<{
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly load: () => Promise<unknown>;
|
||||
}> = [
|
||||
{
|
||||
name: "create-root-keys",
|
||||
description: "Create a root keychain (vault + signed public-key file)",
|
||||
load: () => import("./create-root-keys.js"),
|
||||
},
|
||||
{
|
||||
name: "create-root-keys-chain",
|
||||
description:
|
||||
"Create a root keychain with a self-signed public-key chain",
|
||||
load: () => import("./create-root-keys-chain.js"),
|
||||
},
|
||||
{
|
||||
name: "create-server-keys",
|
||||
description: "Derive a server keychain from a root vault",
|
||||
load: () => import("./create-server-keys.js"),
|
||||
},
|
||||
{
|
||||
name: "create-server-keys-chain",
|
||||
description: "Derive a chained server keychain (signed by the root)",
|
||||
load: () => import("./create-server-keys-chain.js"),
|
||||
},
|
||||
{
|
||||
name: "roll-server-keys",
|
||||
description: "Rotate a server keychain to currentVersion + 1",
|
||||
load: () => import("./roll-server-keys.js"),
|
||||
},
|
||||
{
|
||||
name: "roll-server-keys-chain",
|
||||
description: "Rotate a chained server keychain to currentVersion + 1",
|
||||
load: () => import("./roll-server-keys-chain.js"),
|
||||
},
|
||||
{
|
||||
name: "verify-key-chain",
|
||||
description: "Verify a chain file against a trusted root file",
|
||||
load: () => import("./verify-key-chain.js"),
|
||||
},
|
||||
{
|
||||
name: "view-keys",
|
||||
description: "Decrypt a vault and print a keychain summary",
|
||||
load: () => import("./view-keys.js"),
|
||||
},
|
||||
{
|
||||
name: "view-vault",
|
||||
description: "Print vault file metadata without decryption",
|
||||
load: () => import("./view-vault.js"),
|
||||
},
|
||||
{
|
||||
name: "view-public-key-file",
|
||||
description: "Decode and print a signed public-key file",
|
||||
load: () => import("./view-public-key-file.js"),
|
||||
},
|
||||
{
|
||||
name: "view-chain-public",
|
||||
description: "Decode a public-key file with optional verification",
|
||||
load: () => import("./view-chain-public.js"),
|
||||
},
|
||||
{
|
||||
name: "prepare-file",
|
||||
description: "Pack a file into signed packets",
|
||||
load: () => import("./prepare-file.js"),
|
||||
},
|
||||
{
|
||||
name: "serve-file",
|
||||
description: "Reconstruct a file from prepared packets",
|
||||
load: () => import("./serve-file.js"),
|
||||
},
|
||||
{
|
||||
name: "validate-file",
|
||||
description: "Validate every packet in a prepared directory",
|
||||
load: () => import("./validate-file.js"),
|
||||
},
|
||||
{
|
||||
name: "get-file-stats",
|
||||
description: "Print file packing statistics without packing",
|
||||
load: () => import("./get-file-stats.js"),
|
||||
},
|
||||
{
|
||||
name: "validate-packet",
|
||||
description: "Validate a single packet against an explicit verify key",
|
||||
load: () => import("./validate-packet.js"),
|
||||
},
|
||||
{
|
||||
name: "license",
|
||||
description:
|
||||
"Generate, validate, parse, or revoke software license keys",
|
||||
load: () => import("./license.js"),
|
||||
},
|
||||
];
|
||||
|
||||
const program = new Command()
|
||||
.name("suaveplan")
|
||||
.description("Suaveplan operator CLI for server-keys, files, and license")
|
||||
.version("0.1.0");
|
||||
|
||||
for (const sub of SUBCOMMANDS) {
|
||||
program
|
||||
.command(sub.name)
|
||||
.description(sub.description)
|
||||
.helpOption("-h, --help", "Show help for this sub-command")
|
||||
.allowUnknownOption()
|
||||
.action(async () => {
|
||||
await sub.load();
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv.length <= 2) {
|
||||
program.outputHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
program.parseAsync(process.argv);
|
||||
@@ -0,0 +1,57 @@
|
||||
# `bin/license`
|
||||
|
||||
## Purpose
|
||||
|
||||
Commander entry point for the `license` operator command. Parses CLI
|
||||
flags, prompts for any required passwords via `@suaveplan/server-keys/prompt`,
|
||||
and dispatches to the matching function in
|
||||
`@suaveplan/server-keys/commands/`. The body runs inside
|
||||
`runFunction("license", ...)` so OTel emission, structured stderr
|
||||
banners, and `exit(1)` on failure are all handled by the shared
|
||||
wrapper instead of being re-implemented per bin.
|
||||
|
||||
This file is a thin wrapper — operator-facing behaviour lives in the
|
||||
`commands/` module the bin imports. The bin's only responsibilities
|
||||
are flag parsing, password prompting, and exit-code threading; the
|
||||
spec compliance, error handling, and OTel emission all come from the
|
||||
shared wrapper layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `commander` flag parsing with `-h/--help` for every sub-command.
|
||||
- Password prompts via `@inquirer/prompts` (echo-disabled).
|
||||
- OTel command-total + duration histogram per invocation.
|
||||
- Structured `SERVER_KEYS_*` error banners on failure.
|
||||
- `process.exit(1)` on any thrown failure, after the banner.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```bash
|
||||
license --help
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the bin from a shell with a non-TTY `stdin` (e.g. via process
|
||||
substitution) to script multiple operations:
|
||||
|
||||
```bash
|
||||
echo "mypassword" | license [flags...]
|
||||
```
|
||||
|
||||
When invoked under a `SIGINT`-trapping shell, the bin honours the
|
||||
shared `AbortController` plumbed through `runFunction`.
|
||||
|
||||
## API Reference
|
||||
|
||||
This bin exposes its surface via `commander` flags only. Run
|
||||
`license --help` for the authoritative list. The library-level surface
|
||||
the bin wraps is documented under the matching `commands/` module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bin imports its target command lazily where possible to keep `bun
|
||||
link` cold-start time short. Every prompt accepts `{ inputStream,
|
||||
outputStream, signal }` via `PromptOptions` so tests can drive the
|
||||
bin without a real TTY; in production the defaults resolve to
|
||||
`process.stdin` / `process.stdout`.
|
||||
170
packages/security-stack/security/server-keys/src/bin/license.ts
Normal file
170
packages/security-stack/security/server-keys/src/bin/license.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env node
|
||||
import { PackageTier } from "@suaveplan/license";
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
exportPublicKeyFile,
|
||||
generate,
|
||||
keygen,
|
||||
parse,
|
||||
revoke,
|
||||
validate,
|
||||
} from "../commands/license/license.js";
|
||||
import { runFunction } from "../run-function/run-function.js";
|
||||
|
||||
const stdoutLogger = {
|
||||
info: (m: string) => process.stdout.write(`${m}\n`),
|
||||
error: (m: string) => process.stderr.write(`${m}\n`),
|
||||
};
|
||||
|
||||
const program = new Command()
|
||||
.name("license")
|
||||
.description("Generate, validate, and manage software license keys");
|
||||
|
||||
program
|
||||
.command("keygen")
|
||||
.description("Generate a fresh secp256k1 signing keypair")
|
||||
.requiredOption("-o, --output <path>", "Output JSON file path")
|
||||
.option(
|
||||
"-k, --key-id <number>",
|
||||
"Key id to assign",
|
||||
(v) => Number.parseInt(v, 10),
|
||||
0
|
||||
)
|
||||
.option(
|
||||
"--export-pub <path>",
|
||||
"Also write a public-keys JSON file at this path"
|
||||
)
|
||||
.action((opts: { output: string; keyId: number; exportPub?: string }) =>
|
||||
runFunction("license-keygen", async (signal) => {
|
||||
const file = await keygen(opts.output, opts.keyId, {
|
||||
logger: stdoutLogger,
|
||||
...(signal !== undefined ? { signal } : {}),
|
||||
});
|
||||
if (opts.exportPub) {
|
||||
await exportPublicKeyFile(file, opts.exportPub);
|
||||
stdoutLogger.info(
|
||||
`license keygen: exported public-keys to ${opts.exportPub}`
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
program
|
||||
.command("generate")
|
||||
.description("Generate a license key")
|
||||
.requiredOption("-s, --signing-key <path>", "Signing key JSON file")
|
||||
.option(
|
||||
"-p, --product-id <number>",
|
||||
"Product id",
|
||||
(v) => Number.parseInt(v, 10),
|
||||
1
|
||||
)
|
||||
.option(
|
||||
"-t, --tier <number>",
|
||||
"Tier (0=FREE 1=BASIC 2=PRO 3=BUSINESS 4=ENTERPRISE)",
|
||||
(v) => Number.parseInt(v, 10),
|
||||
PackageTier.BASIC
|
||||
)
|
||||
.option(
|
||||
"-d, --days <number>",
|
||||
"Expiration days (0 = perpetual)",
|
||||
(v) => Number.parseInt(v, 10),
|
||||
0
|
||||
)
|
||||
.option("-a, --absolute", "Use absolute time encoding")
|
||||
.option(
|
||||
"-F, --flags <number>",
|
||||
"License-flags bitfield",
|
||||
(v) => Number.parseInt(v, 10),
|
||||
0
|
||||
)
|
||||
.option(
|
||||
"-f, --features <number>",
|
||||
"Feature-mask bitfield",
|
||||
(v) => Number.parseInt(v, 10),
|
||||
0
|
||||
)
|
||||
.option("--full-signature", "Use the full 64-byte signature")
|
||||
.action(
|
||||
(opts: {
|
||||
signingKey: string;
|
||||
productId: number;
|
||||
tier: PackageTier;
|
||||
days: number;
|
||||
absolute?: boolean;
|
||||
flags: number;
|
||||
features: number;
|
||||
fullSignature?: boolean;
|
||||
}) =>
|
||||
runFunction("license-generate", async (signal) => {
|
||||
const result = await generate({
|
||||
signingKeyPath: opts.signingKey,
|
||||
productId: opts.productId,
|
||||
tier: opts.tier,
|
||||
expirationDays: opts.days,
|
||||
flags: opts.flags,
|
||||
featureMask: opts.features,
|
||||
useAbsoluteTime: opts.absolute === true,
|
||||
useFullSignature: opts.fullSignature === true,
|
||||
logger: stdoutLogger,
|
||||
...(signal !== undefined ? { signal } : {}),
|
||||
});
|
||||
stdoutLogger.info(`License: ${result.key}`);
|
||||
})
|
||||
);
|
||||
|
||||
program
|
||||
.command("validate <key>")
|
||||
.description("Validate a license key")
|
||||
.requiredOption("-p, --public-keys <path>", "Public-keys JSON file")
|
||||
.option("-r, --reference-date <iso>", "Reference date (ISO 8601)")
|
||||
.action(
|
||||
async (
|
||||
key: string,
|
||||
opts: { publicKeys: string; referenceDate?: string }
|
||||
) =>
|
||||
runFunction("license-validate", async (signal) => {
|
||||
const result = await validate(key, {
|
||||
publicKeysPath: opts.publicKeys,
|
||||
...(opts.referenceDate !== undefined
|
||||
? { referenceDate: new Date(opts.referenceDate) }
|
||||
: {}),
|
||||
logger: stdoutLogger,
|
||||
...(signal !== undefined ? { signal } : {}),
|
||||
});
|
||||
if (!result.success) process.exitCode = 1;
|
||||
})
|
||||
);
|
||||
|
||||
program
|
||||
.command("parse <key>")
|
||||
.description("Parse a license key without verification")
|
||||
.action((key: string) =>
|
||||
runFunction("license-parse", async () => {
|
||||
const parsed = parse(key, { logger: stdoutLogger });
|
||||
stdoutLogger.info(JSON.stringify(parsed.payload, null, 2));
|
||||
})
|
||||
);
|
||||
|
||||
program
|
||||
.command("revoke <key>")
|
||||
.description(
|
||||
"Revoke a license key by appending its fingerprint to the store"
|
||||
)
|
||||
.requiredOption(
|
||||
"-s, --store <path>",
|
||||
"Revocation-store JSON file",
|
||||
"./keys/revocations.json"
|
||||
)
|
||||
.action((key: string, opts: { store: string }) =>
|
||||
runFunction("license-revoke", async (signal) => {
|
||||
const fp = await revoke(key, {
|
||||
revocationStorePath: opts.store,
|
||||
logger: stdoutLogger,
|
||||
...(signal !== undefined ? { signal } : {}),
|
||||
});
|
||||
stdoutLogger.info(`Revoked: ${fp}`);
|
||||
})
|
||||
);
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,57 @@
|
||||
# `bin/prepare-file`
|
||||
|
||||
## Purpose
|
||||
|
||||
Commander entry point for the `prepare-file` operator command. Parses CLI
|
||||
flags, prompts for any required passwords via `@suaveplan/server-keys/prompt`,
|
||||
and dispatches to the matching function in
|
||||
`@suaveplan/server-keys/commands/`. The body runs inside
|
||||
`runFunction("prepare-file", ...)` so OTel emission, structured stderr
|
||||
banners, and `exit(1)` on failure are all handled by the shared
|
||||
wrapper instead of being re-implemented per bin.
|
||||
|
||||
This file is a thin wrapper — operator-facing behaviour lives in the
|
||||
`commands/` module the bin imports. The bin's only responsibilities
|
||||
are flag parsing, password prompting, and exit-code threading; the
|
||||
spec compliance, error handling, and OTel emission all come from the
|
||||
shared wrapper layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `commander` flag parsing with `-h/--help` for every sub-command.
|
||||
- Password prompts via `@inquirer/prompts` (echo-disabled).
|
||||
- OTel command-total + duration histogram per invocation.
|
||||
- Structured `SERVER_KEYS_*` error banners on failure.
|
||||
- `process.exit(1)` on any thrown failure, after the banner.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```bash
|
||||
prepare-file --help
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the bin from a shell with a non-TTY `stdin` (e.g. via process
|
||||
substitution) to script multiple operations:
|
||||
|
||||
```bash
|
||||
echo "mypassword" | prepare-file [flags...]
|
||||
```
|
||||
|
||||
When invoked under a `SIGINT`-trapping shell, the bin honours the
|
||||
shared `AbortController` plumbed through `runFunction`.
|
||||
|
||||
## API Reference
|
||||
|
||||
This bin exposes its surface via `commander` flags only. Run
|
||||
`prepare-file --help` for the authoritative list. The library-level surface
|
||||
the bin wraps is documented under the matching `commands/` module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bin imports its target command lazily where possible to keep `bun
|
||||
link` cold-start time short. Every prompt accepts `{ inputStream,
|
||||
outputStream, signal }` via `PromptOptions` so tests can drive the
|
||||
bin without a real TTY; in production the defaults resolve to
|
||||
`process.stdin` / `process.stdout`.
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import { prepareFileCommand } from "../commands/files/files.js";
|
||||
import { promptForPassword } from "../prompt/prompt.js";
|
||||
import { runFunction } from "../run-function/run-function.js";
|
||||
|
||||
const program = new Command()
|
||||
.name("prepare-file")
|
||||
.description("Pack a file into signed packets under an output directory")
|
||||
.requiredOption("-f, --file <path>", "Input file path")
|
||||
.requiredOption("-v, --vault <path>", "Path to server vault")
|
||||
.requiredOption(
|
||||
"-o, --output <dir>",
|
||||
"Output directory for prepared packets"
|
||||
)
|
||||
.option(
|
||||
"--packet-size <bytes>",
|
||||
"Packet size in bytes",
|
||||
Number.parseInt,
|
||||
1048576
|
||||
)
|
||||
.action(
|
||||
(options: {
|
||||
file: string;
|
||||
vault: string;
|
||||
output: string;
|
||||
packetSize: number;
|
||||
}) =>
|
||||
runFunction("prepare-file", async (signal) => {
|
||||
const password = await promptForPassword(
|
||||
"Enter server vault password"
|
||||
);
|
||||
await prepareFileCommand(
|
||||
options.file,
|
||||
options.vault,
|
||||
password,
|
||||
{
|
||||
output: options.output,
|
||||
packetSize: options.packetSize,
|
||||
...(signal !== undefined ? { signal } : {}),
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,57 @@
|
||||
# `bin/roll-server-keys-chain`
|
||||
|
||||
## Purpose
|
||||
|
||||
Commander entry point for the `roll-server-keys-chain` operator command. Parses CLI
|
||||
flags, prompts for any required passwords via `@suaveplan/server-keys/prompt`,
|
||||
and dispatches to the matching function in
|
||||
`@suaveplan/server-keys/commands/`. The body runs inside
|
||||
`runFunction("roll-server-keys-chain", ...)` so OTel emission, structured stderr
|
||||
banners, and `exit(1)` on failure are all handled by the shared
|
||||
wrapper instead of being re-implemented per bin.
|
||||
|
||||
This file is a thin wrapper — operator-facing behaviour lives in the
|
||||
`commands/` module the bin imports. The bin's only responsibilities
|
||||
are flag parsing, password prompting, and exit-code threading; the
|
||||
spec compliance, error handling, and OTel emission all come from the
|
||||
shared wrapper layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `commander` flag parsing with `-h/--help` for every sub-command.
|
||||
- Password prompts via `@inquirer/prompts` (echo-disabled).
|
||||
- OTel command-total + duration histogram per invocation.
|
||||
- Structured `SERVER_KEYS_*` error banners on failure.
|
||||
- `process.exit(1)` on any thrown failure, after the banner.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```bash
|
||||
roll-server-keys-chain --help
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the bin from a shell with a non-TTY `stdin` (e.g. via process
|
||||
substitution) to script multiple operations:
|
||||
|
||||
```bash
|
||||
echo "mypassword" | roll-server-keys-chain [flags...]
|
||||
```
|
||||
|
||||
When invoked under a `SIGINT`-trapping shell, the bin honours the
|
||||
shared `AbortController` plumbed through `runFunction`.
|
||||
|
||||
## API Reference
|
||||
|
||||
This bin exposes its surface via `commander` flags only. Run
|
||||
`roll-server-keys-chain --help` for the authoritative list. The library-level surface
|
||||
the bin wraps is documented under the matching `commands/` module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bin imports its target command lazily where possible to keep `bun
|
||||
link` cold-start time short. Every prompt accepts `{ inputStream,
|
||||
outputStream, signal }` via `PromptOptions` so tests can drive the
|
||||
bin without a real TTY; in production the defaults resolve to
|
||||
`process.stdin` / `process.stdout`.
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import { rollServerKeysChainCommand } from "../commands/keys/keys-chain.js";
|
||||
import { promptForNewPassword, promptForPassword } from "../prompt/prompt.js";
|
||||
import { runFunction } from "../run-function/run-function.js";
|
||||
|
||||
const program = new Command()
|
||||
.name("roll-server-keys-chain")
|
||||
.description("Rotate a chained server keychain to currentVersion + 1")
|
||||
.requiredOption(
|
||||
"-r, --root-vault <path>",
|
||||
"Path to root vault",
|
||||
"./keys/root.vault"
|
||||
)
|
||||
.requiredOption(
|
||||
"-v, --current-version <number>",
|
||||
"Current server keychain version",
|
||||
Number.parseInt
|
||||
)
|
||||
.option("-o, --output <dir>", "Output directory", "./keys")
|
||||
.action(
|
||||
(options: {
|
||||
rootVault: string;
|
||||
currentVersion: number;
|
||||
output: string;
|
||||
}) =>
|
||||
runFunction("roll-server-keys-chain", async (signal) => {
|
||||
const rootPassword = await promptForPassword(
|
||||
"Enter root vault password"
|
||||
);
|
||||
const serverPassword = await promptForNewPassword(
|
||||
"Enter password for the new server vault"
|
||||
);
|
||||
await rollServerKeysChainCommand(
|
||||
options.rootVault,
|
||||
rootPassword,
|
||||
serverPassword,
|
||||
options.currentVersion,
|
||||
{
|
||||
output: options.output,
|
||||
...(signal !== undefined ? { signal } : {}),
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,57 @@
|
||||
# `bin/roll-server-keys`
|
||||
|
||||
## Purpose
|
||||
|
||||
Commander entry point for the `roll-server-keys` operator command. Parses CLI
|
||||
flags, prompts for any required passwords via `@suaveplan/server-keys/prompt`,
|
||||
and dispatches to the matching function in
|
||||
`@suaveplan/server-keys/commands/`. The body runs inside
|
||||
`runFunction("roll-server-keys", ...)` so OTel emission, structured stderr
|
||||
banners, and `exit(1)` on failure are all handled by the shared
|
||||
wrapper instead of being re-implemented per bin.
|
||||
|
||||
This file is a thin wrapper — operator-facing behaviour lives in the
|
||||
`commands/` module the bin imports. The bin's only responsibilities
|
||||
are flag parsing, password prompting, and exit-code threading; the
|
||||
spec compliance, error handling, and OTel emission all come from the
|
||||
shared wrapper layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `commander` flag parsing with `-h/--help` for every sub-command.
|
||||
- Password prompts via `@inquirer/prompts` (echo-disabled).
|
||||
- OTel command-total + duration histogram per invocation.
|
||||
- Structured `SERVER_KEYS_*` error banners on failure.
|
||||
- `process.exit(1)` on any thrown failure, after the banner.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```bash
|
||||
roll-server-keys --help
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the bin from a shell with a non-TTY `stdin` (e.g. via process
|
||||
substitution) to script multiple operations:
|
||||
|
||||
```bash
|
||||
echo "mypassword" | roll-server-keys [flags...]
|
||||
```
|
||||
|
||||
When invoked under a `SIGINT`-trapping shell, the bin honours the
|
||||
shared `AbortController` plumbed through `runFunction`.
|
||||
|
||||
## API Reference
|
||||
|
||||
This bin exposes its surface via `commander` flags only. Run
|
||||
`roll-server-keys --help` for the authoritative list. The library-level surface
|
||||
the bin wraps is documented under the matching `commands/` module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bin imports its target command lazily where possible to keep `bun
|
||||
link` cold-start time short. Every prompt accepts `{ inputStream,
|
||||
outputStream, signal }` via `PromptOptions` so tests can drive the
|
||||
bin without a real TTY; in production the defaults resolve to
|
||||
`process.stdin` / `process.stdout`.
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import { rollServerKeysCommand } from "../commands/keys/keys.js";
|
||||
import { promptForNewPassword, promptForPassword } from "../prompt/prompt.js";
|
||||
import { runFunction } from "../run-function/run-function.js";
|
||||
|
||||
const program = new Command()
|
||||
.name("roll-server-keys")
|
||||
.description("Rotate a server keychain to currentVersion + 1")
|
||||
.requiredOption(
|
||||
"-r, --root-vault <path>",
|
||||
"Path to root vault",
|
||||
"./keys/root.vault"
|
||||
)
|
||||
.requiredOption(
|
||||
"-v, --current-version <number>",
|
||||
"Current server keychain version",
|
||||
Number.parseInt
|
||||
)
|
||||
.option("-o, --output <dir>", "Output directory", "./keys")
|
||||
.action(
|
||||
(options: {
|
||||
rootVault: string;
|
||||
currentVersion: number;
|
||||
output: string;
|
||||
}) =>
|
||||
runFunction("roll-server-keys", async (signal) => {
|
||||
const rootPassword = await promptForPassword(
|
||||
"Enter root vault password"
|
||||
);
|
||||
const serverPassword = await promptForNewPassword(
|
||||
"Enter password for the new server vault"
|
||||
);
|
||||
await rollServerKeysCommand(
|
||||
options.rootVault,
|
||||
rootPassword,
|
||||
serverPassword,
|
||||
options.currentVersion,
|
||||
{
|
||||
output: options.output,
|
||||
...(signal !== undefined ? { signal } : {}),
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,57 @@
|
||||
# `bin/serve-file`
|
||||
|
||||
## Purpose
|
||||
|
||||
Commander entry point for the `serve-file` operator command. Parses CLI
|
||||
flags, prompts for any required passwords via `@suaveplan/server-keys/prompt`,
|
||||
and dispatches to the matching function in
|
||||
`@suaveplan/server-keys/commands/`. The body runs inside
|
||||
`runFunction("serve-file", ...)` so OTel emission, structured stderr
|
||||
banners, and `exit(1)` on failure are all handled by the shared
|
||||
wrapper instead of being re-implemented per bin.
|
||||
|
||||
This file is a thin wrapper — operator-facing behaviour lives in the
|
||||
`commands/` module the bin imports. The bin's only responsibilities
|
||||
are flag parsing, password prompting, and exit-code threading; the
|
||||
spec compliance, error handling, and OTel emission all come from the
|
||||
shared wrapper layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `commander` flag parsing with `-h/--help` for every sub-command.
|
||||
- Password prompts via `@inquirer/prompts` (echo-disabled).
|
||||
- OTel command-total + duration histogram per invocation.
|
||||
- Structured `SERVER_KEYS_*` error banners on failure.
|
||||
- `process.exit(1)` on any thrown failure, after the banner.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```bash
|
||||
serve-file --help
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the bin from a shell with a non-TTY `stdin` (e.g. via process
|
||||
substitution) to script multiple operations:
|
||||
|
||||
```bash
|
||||
echo "mypassword" | serve-file [flags...]
|
||||
```
|
||||
|
||||
When invoked under a `SIGINT`-trapping shell, the bin honours the
|
||||
shared `AbortController` plumbed through `runFunction`.
|
||||
|
||||
## API Reference
|
||||
|
||||
This bin exposes its surface via `commander` flags only. Run
|
||||
`serve-file --help` for the authoritative list. The library-level surface
|
||||
the bin wraps is documented under the matching `commands/` module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bin imports its target command lazily where possible to keep `bun
|
||||
link` cold-start time short. Every prompt accepts `{ inputStream,
|
||||
outputStream, signal }` via `PromptOptions` so tests can drive the
|
||||
bin without a real TTY; in production the defaults resolve to
|
||||
`process.stdin` / `process.stdout`.
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import { serveFileCommand } from "../commands/files/serve-file.js";
|
||||
import { runFunction } from "../run-function/run-function.js";
|
||||
|
||||
const program = new Command()
|
||||
.name("serve-file")
|
||||
.description("Reconstruct a file from a prepared packet directory")
|
||||
.requiredOption(
|
||||
"-d, --packet-dir <path>",
|
||||
"Directory containing the prepared packets + manifest"
|
||||
)
|
||||
.option(
|
||||
"-o, --output <path>",
|
||||
"Output path (defaults to <packetDir>/<originalFilename>)"
|
||||
)
|
||||
.action((options: { packetDir: string; output?: string }) =>
|
||||
runFunction("serve-file", async (signal) => {
|
||||
await serveFileCommand(options.packetDir, {
|
||||
...(options.output !== undefined
|
||||
? { output: options.output }
|
||||
: {}),
|
||||
...(signal !== undefined ? { signal } : {}),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,57 @@
|
||||
# `bin/validate-file`
|
||||
|
||||
## Purpose
|
||||
|
||||
Commander entry point for the `validate-file` operator command. Parses CLI
|
||||
flags, prompts for any required passwords via `@suaveplan/server-keys/prompt`,
|
||||
and dispatches to the matching function in
|
||||
`@suaveplan/server-keys/commands/`. The body runs inside
|
||||
`runFunction("validate-file", ...)` so OTel emission, structured stderr
|
||||
banners, and `exit(1)` on failure are all handled by the shared
|
||||
wrapper instead of being re-implemented per bin.
|
||||
|
||||
This file is a thin wrapper — operator-facing behaviour lives in the
|
||||
`commands/` module the bin imports. The bin's only responsibilities
|
||||
are flag parsing, password prompting, and exit-code threading; the
|
||||
spec compliance, error handling, and OTel emission all come from the
|
||||
shared wrapper layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `commander` flag parsing with `-h/--help` for every sub-command.
|
||||
- Password prompts via `@inquirer/prompts` (echo-disabled).
|
||||
- OTel command-total + duration histogram per invocation.
|
||||
- Structured `SERVER_KEYS_*` error banners on failure.
|
||||
- `process.exit(1)` on any thrown failure, after the banner.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```bash
|
||||
validate-file --help
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the bin from a shell with a non-TTY `stdin` (e.g. via process
|
||||
substitution) to script multiple operations:
|
||||
|
||||
```bash
|
||||
echo "mypassword" | validate-file [flags...]
|
||||
```
|
||||
|
||||
When invoked under a `SIGINT`-trapping shell, the bin honours the
|
||||
shared `AbortController` plumbed through `runFunction`.
|
||||
|
||||
## API Reference
|
||||
|
||||
This bin exposes its surface via `commander` flags only. Run
|
||||
`validate-file --help` for the authoritative list. The library-level surface
|
||||
the bin wraps is documented under the matching `commands/` module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bin imports its target command lazily where possible to keep `bun
|
||||
link` cold-start time short. Every prompt accepts `{ inputStream,
|
||||
outputStream, signal }` via `PromptOptions` so tests can drive the
|
||||
bin without a real TTY; in production the defaults resolve to
|
||||
`process.stdin` / `process.stdout`.
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import { validateFileCommand } from "../commands/files/files.js";
|
||||
import { runFunction } from "../run-function/run-function.js";
|
||||
|
||||
const program = new Command()
|
||||
.name("validate-file")
|
||||
.description(
|
||||
"Walk every packet in a prepared directory and verify integrity"
|
||||
)
|
||||
.requiredOption(
|
||||
"-d, --packet-dir <path>",
|
||||
"Directory containing the prepared packets + manifest"
|
||||
)
|
||||
.action((options: { packetDir: string }) =>
|
||||
runFunction("validate-file", async (signal) => {
|
||||
const result = await validateFileCommand(options.packetDir, {
|
||||
logger: {
|
||||
info: (m: string) => process.stdout.write(`${m}\n`),
|
||||
error: (m: string) => process.stderr.write(`${m}\n`),
|
||||
},
|
||||
...(signal !== undefined ? { signal } : {}),
|
||||
});
|
||||
if (!result.isComplete) process.exitCode = 1;
|
||||
})
|
||||
);
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,57 @@
|
||||
# `bin/validate-packet`
|
||||
|
||||
## Purpose
|
||||
|
||||
Commander entry point for the `validate-packet` operator command. Parses CLI
|
||||
flags, prompts for any required passwords via `@suaveplan/server-keys/prompt`,
|
||||
and dispatches to the matching function in
|
||||
`@suaveplan/server-keys/commands/`. The body runs inside
|
||||
`runFunction("validate-packet", ...)` so OTel emission, structured stderr
|
||||
banners, and `exit(1)` on failure are all handled by the shared
|
||||
wrapper instead of being re-implemented per bin.
|
||||
|
||||
This file is a thin wrapper — operator-facing behaviour lives in the
|
||||
`commands/` module the bin imports. The bin's only responsibilities
|
||||
are flag parsing, password prompting, and exit-code threading; the
|
||||
spec compliance, error handling, and OTel emission all come from the
|
||||
shared wrapper layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `commander` flag parsing with `-h/--help` for every sub-command.
|
||||
- Password prompts via `@inquirer/prompts` (echo-disabled).
|
||||
- OTel command-total + duration histogram per invocation.
|
||||
- Structured `SERVER_KEYS_*` error banners on failure.
|
||||
- `process.exit(1)` on any thrown failure, after the banner.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```bash
|
||||
validate-packet --help
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the bin from a shell with a non-TTY `stdin` (e.g. via process
|
||||
substitution) to script multiple operations:
|
||||
|
||||
```bash
|
||||
echo "mypassword" | validate-packet [flags...]
|
||||
```
|
||||
|
||||
When invoked under a `SIGINT`-trapping shell, the bin honours the
|
||||
shared `AbortController` plumbed through `runFunction`.
|
||||
|
||||
## API Reference
|
||||
|
||||
This bin exposes its surface via `commander` flags only. Run
|
||||
`validate-packet --help` for the authoritative list. The library-level surface
|
||||
the bin wraps is documented under the matching `commands/` module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bin imports its target command lazily where possible to keep `bun
|
||||
link` cold-start time short. Every prompt accepts `{ inputStream,
|
||||
outputStream, signal }` via `PromptOptions` so tests can drive the
|
||||
bin without a real TTY; in production the defaults resolve to
|
||||
`process.stdin` / `process.stdout`.
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import { validatePacketCommand } from "../commands/files/files.js";
|
||||
import { runFunction } from "../run-function/run-function.js";
|
||||
|
||||
const program = new Command()
|
||||
.name("validate-packet")
|
||||
.description("Validate a single packet against an explicit hex public key")
|
||||
.requiredOption("-p, --packet <path>", "Path to a single packet file")
|
||||
.requiredOption(
|
||||
"-k, --verify-key <hex>",
|
||||
"Hex-encoded ed25519 public key the packet must verify against"
|
||||
)
|
||||
.action((options: { packet: string; verifyKey: string }) =>
|
||||
runFunction("validate-packet", async (signal) => {
|
||||
const result = await validatePacketCommand(
|
||||
options.packet,
|
||||
options.verifyKey,
|
||||
{
|
||||
logger: {
|
||||
info: (m: string) => process.stdout.write(`${m}\n`),
|
||||
error: (m: string) => process.stderr.write(`${m}\n`),
|
||||
},
|
||||
...(signal !== undefined ? { signal } : {}),
|
||||
}
|
||||
);
|
||||
if (!result.isValid) process.exitCode = 1;
|
||||
})
|
||||
);
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,57 @@
|
||||
# `bin/verify-key-chain`
|
||||
|
||||
## Purpose
|
||||
|
||||
Commander entry point for the `verify-key-chain` operator command. Parses CLI
|
||||
flags, prompts for any required passwords via `@suaveplan/server-keys/prompt`,
|
||||
and dispatches to the matching function in
|
||||
`@suaveplan/server-keys/commands/`. The body runs inside
|
||||
`runFunction("verify-key-chain", ...)` so OTel emission, structured stderr
|
||||
banners, and `exit(1)` on failure are all handled by the shared
|
||||
wrapper instead of being re-implemented per bin.
|
||||
|
||||
This file is a thin wrapper — operator-facing behaviour lives in the
|
||||
`commands/` module the bin imports. The bin's only responsibilities
|
||||
are flag parsing, password prompting, and exit-code threading; the
|
||||
spec compliance, error handling, and OTel emission all come from the
|
||||
shared wrapper layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `commander` flag parsing with `-h/--help` for every sub-command.
|
||||
- Password prompts via `@inquirer/prompts` (echo-disabled).
|
||||
- OTel command-total + duration histogram per invocation.
|
||||
- Structured `SERVER_KEYS_*` error banners on failure.
|
||||
- `process.exit(1)` on any thrown failure, after the banner.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```bash
|
||||
verify-key-chain --help
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the bin from a shell with a non-TTY `stdin` (e.g. via process
|
||||
substitution) to script multiple operations:
|
||||
|
||||
```bash
|
||||
echo "mypassword" | verify-key-chain [flags...]
|
||||
```
|
||||
|
||||
When invoked under a `SIGINT`-trapping shell, the bin honours the
|
||||
shared `AbortController` plumbed through `runFunction`.
|
||||
|
||||
## API Reference
|
||||
|
||||
This bin exposes its surface via `commander` flags only. Run
|
||||
`verify-key-chain --help` for the authoritative list. The library-level surface
|
||||
the bin wraps is documented under the matching `commands/` module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bin imports its target command lazily where possible to keep `bun
|
||||
link` cold-start time short. Every prompt accepts `{ inputStream,
|
||||
outputStream, signal }` via `PromptOptions` so tests can drive the
|
||||
bin without a real TTY; in production the defaults resolve to
|
||||
`process.stdin` / `process.stdout`.
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import { verifyKeyChainCommand } from "../commands/keys/keys-chain.js";
|
||||
import { runFunction } from "../run-function/run-function.js";
|
||||
|
||||
const program = new Command()
|
||||
.name("verify-key-chain")
|
||||
.description(
|
||||
"Verify a chain file against a trusted root public-key or chain file"
|
||||
)
|
||||
.requiredOption("-c, --chain <path>", "Chain file to verify")
|
||||
.requiredOption(
|
||||
"-r, --root <path>",
|
||||
"Trusted root public-key file (.pub) or chain file (.chain)"
|
||||
)
|
||||
.action((options: { chain: string; root: string }) =>
|
||||
runFunction("verify-key-chain", async () => {
|
||||
const result = await verifyKeyChainCommand(
|
||||
options.chain,
|
||||
options.root,
|
||||
{
|
||||
logger: {
|
||||
info: (m: string) => process.stdout.write(`${m}\n`),
|
||||
error: (m: string) => process.stderr.write(`${m}\n`),
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!result.valid) process.exitCode = 1;
|
||||
})
|
||||
);
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,57 @@
|
||||
# `bin/view-chain-public`
|
||||
|
||||
## Purpose
|
||||
|
||||
Commander entry point for the `view-chain-public` operator command. Parses CLI
|
||||
flags, prompts for any required passwords via `@suaveplan/server-keys/prompt`,
|
||||
and dispatches to the matching function in
|
||||
`@suaveplan/server-keys/commands/`. The body runs inside
|
||||
`runFunction("view-chain-public", ...)` so OTel emission, structured stderr
|
||||
banners, and `exit(1)` on failure are all handled by the shared
|
||||
wrapper instead of being re-implemented per bin.
|
||||
|
||||
This file is a thin wrapper — operator-facing behaviour lives in the
|
||||
`commands/` module the bin imports. The bin's only responsibilities
|
||||
are flag parsing, password prompting, and exit-code threading; the
|
||||
spec compliance, error handling, and OTel emission all come from the
|
||||
shared wrapper layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `commander` flag parsing with `-h/--help` for every sub-command.
|
||||
- Password prompts via `@inquirer/prompts` (echo-disabled).
|
||||
- OTel command-total + duration histogram per invocation.
|
||||
- Structured `SERVER_KEYS_*` error banners on failure.
|
||||
- `process.exit(1)` on any thrown failure, after the banner.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```bash
|
||||
view-chain-public --help
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the bin from a shell with a non-TTY `stdin` (e.g. via process
|
||||
substitution) to script multiple operations:
|
||||
|
||||
```bash
|
||||
echo "mypassword" | view-chain-public [flags...]
|
||||
```
|
||||
|
||||
When invoked under a `SIGINT`-trapping shell, the bin honours the
|
||||
shared `AbortController` plumbed through `runFunction`.
|
||||
|
||||
## API Reference
|
||||
|
||||
This bin exposes its surface via `commander` flags only. Run
|
||||
`view-chain-public --help` for the authoritative list. The library-level surface
|
||||
the bin wraps is documented under the matching `commands/` module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bin imports its target command lazily where possible to keep `bun
|
||||
link` cold-start time short. Every prompt accepts `{ inputStream,
|
||||
outputStream, signal }` via `PromptOptions` so tests can drive the
|
||||
bin without a real TTY; in production the defaults resolve to
|
||||
`process.stdin` / `process.stdout`.
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import { viewChainPublicCommand } from "../commands/keys/keys.js";
|
||||
import { runFunction } from "../run-function/run-function.js";
|
||||
|
||||
const program = new Command()
|
||||
.name("view-chain-public")
|
||||
.description(
|
||||
"Decode a public-key file with optional signature verification"
|
||||
)
|
||||
.requiredOption("-p, --public-key <path>", "Path to public-key file")
|
||||
.option(
|
||||
"-s, --sign-key <path>",
|
||||
"Optional sign-key file used for verification"
|
||||
)
|
||||
.action((options: { publicKey: string; signKey?: string }) =>
|
||||
runFunction("view-chain-public", async () => {
|
||||
await viewChainPublicCommand(options.publicKey, options.signKey, {
|
||||
logger: {
|
||||
info: (m: string) => process.stdout.write(`${m}\n`),
|
||||
error: (m: string) => process.stderr.write(`${m}\n`),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,57 @@
|
||||
# `bin/view-keys`
|
||||
|
||||
## Purpose
|
||||
|
||||
Commander entry point for the `view-keys` operator command. Parses CLI
|
||||
flags, prompts for any required passwords via `@suaveplan/server-keys/prompt`,
|
||||
and dispatches to the matching function in
|
||||
`@suaveplan/server-keys/commands/`. The body runs inside
|
||||
`runFunction("view-keys", ...)` so OTel emission, structured stderr
|
||||
banners, and `exit(1)` on failure are all handled by the shared
|
||||
wrapper instead of being re-implemented per bin.
|
||||
|
||||
This file is a thin wrapper — operator-facing behaviour lives in the
|
||||
`commands/` module the bin imports. The bin's only responsibilities
|
||||
are flag parsing, password prompting, and exit-code threading; the
|
||||
spec compliance, error handling, and OTel emission all come from the
|
||||
shared wrapper layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `commander` flag parsing with `-h/--help` for every sub-command.
|
||||
- Password prompts via `@inquirer/prompts` (echo-disabled).
|
||||
- OTel command-total + duration histogram per invocation.
|
||||
- Structured `SERVER_KEYS_*` error banners on failure.
|
||||
- `process.exit(1)` on any thrown failure, after the banner.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```bash
|
||||
view-keys --help
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the bin from a shell with a non-TTY `stdin` (e.g. via process
|
||||
substitution) to script multiple operations:
|
||||
|
||||
```bash
|
||||
echo "mypassword" | view-keys [flags...]
|
||||
```
|
||||
|
||||
When invoked under a `SIGINT`-trapping shell, the bin honours the
|
||||
shared `AbortController` plumbed through `runFunction`.
|
||||
|
||||
## API Reference
|
||||
|
||||
This bin exposes its surface via `commander` flags only. Run
|
||||
`view-keys --help` for the authoritative list. The library-level surface
|
||||
the bin wraps is documented under the matching `commands/` module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bin imports its target command lazily where possible to keep `bun
|
||||
link` cold-start time short. Every prompt accepts `{ inputStream,
|
||||
outputStream, signal }` via `PromptOptions` so tests can drive the
|
||||
bin without a real TTY; in production the defaults resolve to
|
||||
`process.stdin` / `process.stdout`.
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import { viewKeysCommand } from "../commands/keys/keys.js";
|
||||
import { promptForPassword } from "../prompt/prompt.js";
|
||||
import { runFunction } from "../run-function/run-function.js";
|
||||
|
||||
const program = new Command()
|
||||
.name("view-keys")
|
||||
.description("Decrypt a vault and print a keychain summary")
|
||||
.requiredOption("-v, --vault <path>", "Path to vault file")
|
||||
.action((options: { vault: string }) =>
|
||||
runFunction("view-keys", async () => {
|
||||
const password = await promptForPassword("Enter vault password");
|
||||
await viewKeysCommand(options.vault, password, {
|
||||
logger: {
|
||||
info: (m: string) => process.stdout.write(`${m}\n`),
|
||||
error: (m: string) => process.stderr.write(`${m}\n`),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,57 @@
|
||||
# `bin/view-public-key-file`
|
||||
|
||||
## Purpose
|
||||
|
||||
Commander entry point for the `view-public-key-file` operator command. Parses CLI
|
||||
flags, prompts for any required passwords via `@suaveplan/server-keys/prompt`,
|
||||
and dispatches to the matching function in
|
||||
`@suaveplan/server-keys/commands/`. The body runs inside
|
||||
`runFunction("view-public-key-file", ...)` so OTel emission, structured stderr
|
||||
banners, and `exit(1)` on failure are all handled by the shared
|
||||
wrapper instead of being re-implemented per bin.
|
||||
|
||||
This file is a thin wrapper — operator-facing behaviour lives in the
|
||||
`commands/` module the bin imports. The bin's only responsibilities
|
||||
are flag parsing, password prompting, and exit-code threading; the
|
||||
spec compliance, error handling, and OTel emission all come from the
|
||||
shared wrapper layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `commander` flag parsing with `-h/--help` for every sub-command.
|
||||
- Password prompts via `@inquirer/prompts` (echo-disabled).
|
||||
- OTel command-total + duration histogram per invocation.
|
||||
- Structured `SERVER_KEYS_*` error banners on failure.
|
||||
- `process.exit(1)` on any thrown failure, after the banner.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```bash
|
||||
view-public-key-file --help
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the bin from a shell with a non-TTY `stdin` (e.g. via process
|
||||
substitution) to script multiple operations:
|
||||
|
||||
```bash
|
||||
echo "mypassword" | view-public-key-file [flags...]
|
||||
```
|
||||
|
||||
When invoked under a `SIGINT`-trapping shell, the bin honours the
|
||||
shared `AbortController` plumbed through `runFunction`.
|
||||
|
||||
## API Reference
|
||||
|
||||
This bin exposes its surface via `commander` flags only. Run
|
||||
`view-public-key-file --help` for the authoritative list. The library-level surface
|
||||
the bin wraps is documented under the matching `commands/` module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bin imports its target command lazily where possible to keep `bun
|
||||
link` cold-start time short. Every prompt accepts `{ inputStream,
|
||||
outputStream, signal }` via `PromptOptions` so tests can drive the
|
||||
bin without a real TTY; in production the defaults resolve to
|
||||
`process.stdin` / `process.stdout`.
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import { viewPublicKeyFileCommand } from "../commands/keys/keys.js";
|
||||
import { runFunction } from "../run-function/run-function.js";
|
||||
|
||||
const program = new Command()
|
||||
.name("view-public-key-file")
|
||||
.description("Decode and print a signed public-key file")
|
||||
.requiredOption("-p, --public-key <path>", "Path to public-key file")
|
||||
.action((options: { publicKey: string }) =>
|
||||
runFunction("view-public-key-file", async () => {
|
||||
await viewPublicKeyFileCommand(options.publicKey, {
|
||||
logger: {
|
||||
info: (m: string) => process.stdout.write(`${m}\n`),
|
||||
error: (m: string) => process.stderr.write(`${m}\n`),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,57 @@
|
||||
# `bin/view-vault`
|
||||
|
||||
## Purpose
|
||||
|
||||
Commander entry point for the `view-vault` operator command. Parses CLI
|
||||
flags, prompts for any required passwords via `@suaveplan/server-keys/prompt`,
|
||||
and dispatches to the matching function in
|
||||
`@suaveplan/server-keys/commands/`. The body runs inside
|
||||
`runFunction("view-vault", ...)` so OTel emission, structured stderr
|
||||
banners, and `exit(1)` on failure are all handled by the shared
|
||||
wrapper instead of being re-implemented per bin.
|
||||
|
||||
This file is a thin wrapper — operator-facing behaviour lives in the
|
||||
`commands/` module the bin imports. The bin's only responsibilities
|
||||
are flag parsing, password prompting, and exit-code threading; the
|
||||
spec compliance, error handling, and OTel emission all come from the
|
||||
shared wrapper layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `commander` flag parsing with `-h/--help` for every sub-command.
|
||||
- Password prompts via `@inquirer/prompts` (echo-disabled).
|
||||
- OTel command-total + duration histogram per invocation.
|
||||
- Structured `SERVER_KEYS_*` error banners on failure.
|
||||
- `process.exit(1)` on any thrown failure, after the banner.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```bash
|
||||
view-vault --help
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the bin from a shell with a non-TTY `stdin` (e.g. via process
|
||||
substitution) to script multiple operations:
|
||||
|
||||
```bash
|
||||
echo "mypassword" | view-vault [flags...]
|
||||
```
|
||||
|
||||
When invoked under a `SIGINT`-trapping shell, the bin honours the
|
||||
shared `AbortController` plumbed through `runFunction`.
|
||||
|
||||
## API Reference
|
||||
|
||||
This bin exposes its surface via `commander` flags only. Run
|
||||
`view-vault --help` for the authoritative list. The library-level surface
|
||||
the bin wraps is documented under the matching `commands/` module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bin imports its target command lazily where possible to keep `bun
|
||||
link` cold-start time short. Every prompt accepts `{ inputStream,
|
||||
outputStream, signal }` via `PromptOptions` so tests can drive the
|
||||
bin without a real TTY; in production the defaults resolve to
|
||||
`process.stdin` / `process.stdout`.
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import { viewVaultCommand } from "../commands/keys/keys.js";
|
||||
import { runFunction } from "../run-function/run-function.js";
|
||||
|
||||
const program = new Command()
|
||||
.name("view-vault")
|
||||
.description("Print vault file metadata without decryption")
|
||||
.requiredOption("-v, --vault <path>", "Path to vault file")
|
||||
.action((options: { vault: string }) =>
|
||||
runFunction("view-vault", async () => {
|
||||
await viewVaultCommand(options.vault, {
|
||||
logger: {
|
||||
info: (m: string) => process.stdout.write(`${m}\n`),
|
||||
error: (m: string) => process.stderr.write(`${m}\n`),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,122 @@
|
||||
# `commands/files/files`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Local-packer file-transport commands: `prepareFileCommand`,
|
||||
`validateFileCommand`, `getFileStatsCommand`, `validatePacketCommand`.
|
||||
These four functions implement the operator-facing surface every file-
|
||||
transport bin in `@suaveplan/server-keys` calls into. Reconstruction
|
||||
lives in the sibling `serve-file.ts`. The implementation is fully local —
|
||||
no HTTP, no remote file server — backed by `@suaveplan/packer`'s
|
||||
whole-file `Packer` / `Unpacker` and plain Node `fs`.
|
||||
|
||||
The choice of a local packer (rather than an HTTP client to a remote
|
||||
file-server, which the upstream package shipped) was made deliberately:
|
||||
the upstream HTTP design forces operators to stand up a separate
|
||||
file-server before they can prepare or serve a file, and the binary
|
||||
contract is dominated by the packet wire format anyway. Owning the
|
||||
format here keeps the suaveplan test surface auditable without a network
|
||||
hop and removes a non-trivial deployment dependency.
|
||||
|
||||
## Features
|
||||
|
||||
- Fixed-size packetisation (`packetSize`, default 1 MiB) with a JSON
|
||||
envelope per packet on disk.
|
||||
- ed25519 signature per packet using the vault's `sign` slot secret;
|
||||
recipient verifies against the manifest's `signerPublicKey`.
|
||||
- Manifest is a single JSON document that lists every packet's
|
||||
`index`, `checksum`, `filename`, and `originalSize`.
|
||||
- Cooperative abort at every packet boundary via `options.signal`.
|
||||
- Typed `ServerKeysError` for every failure (`INVALID_INPUT`,
|
||||
`VAULT_DECRYPT_FAILED`, `PACKET_INTEGRITY_FAILED`, `FILE_NOT_FOUND`,
|
||||
`ABORTED`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
prepareFileCommand,
|
||||
validateFileCommand,
|
||||
getFileStatsCommand,
|
||||
} from "@suaveplan/server-keys/commands/files/files";
|
||||
|
||||
const stats = await getFileStatsCommand("/tmp/data.bin", {
|
||||
packetSize: 256 * 1024,
|
||||
});
|
||||
console.log(stats.estimatedPackets);
|
||||
|
||||
await prepareFileCommand(
|
||||
"/tmp/data.bin",
|
||||
"/tmp/keys/server.vault",
|
||||
"serverpw1234",
|
||||
{ output: "/tmp/prepared", packetSize: 256 * 1024 },
|
||||
);
|
||||
|
||||
const result = await validateFileCommand("/tmp/prepared");
|
||||
expect(result.isComplete).toBe(true);
|
||||
expect(result.invalidPackets).toEqual([]);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Single-packet validation against an explicit hex public key (useful for
|
||||
spot-checking a remote-supplied packet without trusting the colocated
|
||||
manifest):
|
||||
|
||||
```ts
|
||||
import { validatePacketCommand } from "@suaveplan/server-keys/commands/files/files";
|
||||
|
||||
const r = await validatePacketCommand(
|
||||
"/tmp/prepared/packet.000000.bin",
|
||||
"abcd...ed25519-public-key-hex",
|
||||
);
|
||||
expect(r.isValid).toBe(true);
|
||||
```
|
||||
|
||||
Detect a tampered packet:
|
||||
|
||||
```ts
|
||||
import { promises as fs } from "node:fs";
|
||||
const target = "/tmp/prepared/packet.000003.bin";
|
||||
const original = await fs.readFile(target, "utf-8");
|
||||
const obj = JSON.parse(original);
|
||||
const bytes = Buffer.from(obj.data, "hex");
|
||||
bytes[10] ^= 0xff;
|
||||
obj.data = bytes.toString("hex");
|
||||
await fs.writeFile(target, JSON.stringify(obj));
|
||||
const r = await validateFileCommand("/tmp/prepared");
|
||||
expect(r.isComplete).toBe(false);
|
||||
expect(r.invalidPackets).toContain(3);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `prepareFileCommand(filePath, vaultPath, password, options?) =>
|
||||
Promise<FileManifest>`
|
||||
- `validateFileCommand(packetDir, options?) =>
|
||||
Promise<ValidateFileResult>`
|
||||
- `getFileStatsCommand(filePath, options?) => Promise<FileStatsResult>`
|
||||
- `validatePacketCommand(packetPath, verifyKeyHex, options?) =>
|
||||
Promise<ValidatePacketResult>`
|
||||
|
||||
All take `FileCommandOptions = { output?, packetSize?, logger?,
|
||||
signal? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`prepareFileCommand` constructs a new `Packer` per packet so the
|
||||
underlying event bus stays scoped to a single pack call — the abstract
|
||||
`Packer` is an `EventEmitter` and we don't want pack events leaking
|
||||
across packets. Each packet stores its `signature`, `data`, `checksum`,
|
||||
and `originalSize` in a small JSON envelope (`{ data, signature,
|
||||
checksum, originalSize }`), all hex-encoded — operators inspecting
|
||||
packets by hand can paste them into a hex viewer without further
|
||||
tooling. The intentional `compressionLevel: 0` (no gzip) keeps each
|
||||
packet's plaintext slice byte-identical to the input region; without
|
||||
this, signing operates over the gzip stream and reconstruction
|
||||
requires inverting it, which is an avoidable layer for the local
|
||||
transport.
|
||||
@@ -0,0 +1,458 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { SERVER_KEYS_CODES } from "../../error-codes/error-codes.js";
|
||||
import { createRootKeysCommand } from "../keys/keys.js";
|
||||
import {
|
||||
getFileStatsCommand,
|
||||
prepareFileCommand,
|
||||
validateFileCommand,
|
||||
validatePacketCommand,
|
||||
} from "./files.js";
|
||||
import { serveFileCommand } from "./serve-file.js";
|
||||
|
||||
const PW = "vaultpw1234";
|
||||
|
||||
function tmp(label: string): string {
|
||||
return path.join(
|
||||
tmpdir(),
|
||||
`sf-${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanup(dir: string): Promise<void> {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
interface Fixture {
|
||||
readonly dir: string;
|
||||
readonly vault: string;
|
||||
readonly input: string;
|
||||
readonly inputBytes: Uint8Array;
|
||||
}
|
||||
|
||||
async function setupVault(label: string, fileSize = 4096): Promise<Fixture> {
|
||||
const dir = tmp(label);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await createRootKeysCommand(PW, { output: dir });
|
||||
const input = path.join(dir, "input.bin");
|
||||
const inputBytes = new Uint8Array(fileSize);
|
||||
for (let i = 0; i < fileSize; i++) inputBytes[i] = i & 0xff;
|
||||
await fs.writeFile(input, inputBytes);
|
||||
return { dir, vault: path.join(dir, "root.vault"), input, inputBytes };
|
||||
}
|
||||
|
||||
describe("prepareFile + serveFile round-trip", () => {
|
||||
let fixture: Fixture | undefined;
|
||||
beforeAll(async () => {
|
||||
fixture = await setupVault("rt", 8192);
|
||||
});
|
||||
afterAll(async () => {
|
||||
if (fixture) await cleanup(fixture.dir);
|
||||
});
|
||||
|
||||
it("round-trips a binary input byte-identical with multiple packets", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const f = fixture as Fixture;
|
||||
const out = path.join(f.dir, "prepared");
|
||||
await prepareFileCommand(f.input, f.vault, PW, {
|
||||
output: out,
|
||||
packetSize: 1024,
|
||||
});
|
||||
const result = await serveFileCommand(out, {
|
||||
output: path.join(f.dir, "reassembled.bin"),
|
||||
});
|
||||
const round = await fs.readFile(result.outputPath);
|
||||
expect(round.byteLength).toBe(f.inputBytes.byteLength);
|
||||
for (let i = 0; i < round.byteLength; i++) {
|
||||
expect(round[i]).toBe(f.inputBytes[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trips a small file in a single packet", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const f = fixture as Fixture;
|
||||
const out = path.join(f.dir, "prepared-single");
|
||||
await prepareFileCommand(f.input, f.vault, PW, {
|
||||
output: out,
|
||||
packetSize: 64 * 1024,
|
||||
});
|
||||
const result = await serveFileCommand(out);
|
||||
const round = await fs.readFile(result.outputPath);
|
||||
expect(round.byteLength).toBe(f.inputBytes.byteLength);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateFile detects a tampered packet", () => {
|
||||
let fixture: Fixture | undefined;
|
||||
let preparedDir = "";
|
||||
beforeAll(async () => {
|
||||
fixture = await setupVault("tamp");
|
||||
preparedDir = path.join(fixture.dir, "prepared");
|
||||
await prepareFileCommand(fixture.input, fixture.vault, PW, {
|
||||
output: preparedDir,
|
||||
packetSize: 1024,
|
||||
});
|
||||
});
|
||||
afterAll(async () => {
|
||||
if (fixture) await cleanup(fixture.dir);
|
||||
});
|
||||
|
||||
it("reports the tampered packet's index in invalidPackets", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
// Tamper packet index 1: flip a byte in `data` hex.
|
||||
const target = path.join(preparedDir, "packet.000001.bin");
|
||||
const text = await fs.readFile(target, "utf-8");
|
||||
const obj = JSON.parse(text) as { data: string };
|
||||
const dataBytes = Buffer.from(obj.data, "hex");
|
||||
dataBytes[5] = (dataBytes[5] ?? 0) ^ 0xff;
|
||||
obj.data = dataBytes.toString("hex");
|
||||
await fs.writeFile(target, JSON.stringify(obj));
|
||||
const result = await validateFileCommand(preparedDir);
|
||||
expect(result.isComplete).toBe(false);
|
||||
expect(result.invalidPackets).toContain(1);
|
||||
});
|
||||
|
||||
it("serveFile refuses to write the output when a packet fails verification", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
serveFileCommand(preparedDir, {
|
||||
output: path.join(
|
||||
(fixture as Fixture).dir,
|
||||
"should-not-exist.bin"
|
||||
),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.PACKET_INTEGRITY_FAILED,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFileStats prints manifest without verification", () => {
|
||||
let fixture: Fixture | undefined;
|
||||
beforeAll(async () => {
|
||||
fixture = await setupVault("stats");
|
||||
});
|
||||
afterAll(async () => {
|
||||
if (fixture) await cleanup(fixture.dir);
|
||||
});
|
||||
|
||||
it("returns size + estimated packet count without packing", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const f = fixture as Fixture;
|
||||
const result = await getFileStatsCommand(f.input, {
|
||||
packetSize: 1024,
|
||||
});
|
||||
expect(result.originalSize).toBe(f.inputBytes.byteLength);
|
||||
expect(result.packetSize).toBe(1024);
|
||||
expect(result.estimatedPackets).toBe(4);
|
||||
expect(result.lastModified).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("rejects empty file path", { timeout: 5000 }, async () => {
|
||||
await expect(getFileStatsCommand("")).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-positive packetSize", { timeout: 5000 }, async () => {
|
||||
const f = fixture as Fixture;
|
||||
await expect(
|
||||
getFileStatsCommand(f.input, { packetSize: 0 })
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("raises FILE_NOT_FOUND on missing path", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
getFileStatsCommand("/nonexistent/file.bin")
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.FILE_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePacket reports a single-packet failure", () => {
|
||||
let fixture: Fixture | undefined;
|
||||
let preparedDir = "";
|
||||
let manifestPubKey = "";
|
||||
beforeAll(async () => {
|
||||
fixture = await setupVault("vp");
|
||||
preparedDir = path.join(fixture.dir, "prepared");
|
||||
const manifest = await prepareFileCommand(
|
||||
fixture.input,
|
||||
fixture.vault,
|
||||
PW,
|
||||
{ output: preparedDir, packetSize: 1024 }
|
||||
);
|
||||
manifestPubKey = manifest.signerPublicKey;
|
||||
});
|
||||
afterAll(async () => {
|
||||
if (fixture) await cleanup(fixture.dir);
|
||||
});
|
||||
|
||||
it("returns isValid=true for an untouched packet", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const result = await validatePacketCommand(
|
||||
path.join(preparedDir, "packet.000000.bin"),
|
||||
manifestPubKey
|
||||
);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("returns isValid=false with an error message for a tampered packet", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const target = path.join(preparedDir, "packet.000002.bin");
|
||||
const text = await fs.readFile(target, "utf-8");
|
||||
const obj = JSON.parse(text) as { data: string };
|
||||
const data = Buffer.from(obj.data, "hex");
|
||||
data[3] = (data[3] ?? 0) ^ 0xff;
|
||||
obj.data = data.toString("hex");
|
||||
await fs.writeFile(target, JSON.stringify(obj));
|
||||
const result = await validatePacketCommand(target, manifestPubKey);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects empty inputs with INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(validatePacketCommand("", "")).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("input validation + abort", () => {
|
||||
it("prepareFile rejects empty arguments", { timeout: 5000 }, async () => {
|
||||
await expect(prepareFileCommand("", "", "")).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("prepareFile rejects non-positive packetSize", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
prepareFileCommand("/x", "/y", "z", { packetSize: 0 })
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("validateFile rejects empty packetDir", { timeout: 5000 }, async () => {
|
||||
await expect(validateFileCommand("")).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("serveFile rejects empty packetDir", { timeout: 5000 }, async () => {
|
||||
await expect(serveFileCommand("")).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("prepareFile respects pre-aborted signal", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const fx = await setupVault("abort-p");
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
prepareFileCommand(fx.input, fx.vault, PW, {
|
||||
output: path.join(fx.dir, "prepared-aborted"),
|
||||
signal: ac.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ code: SERVER_KEYS_CODES.ABORTED });
|
||||
await cleanup(fx.dir);
|
||||
});
|
||||
|
||||
it("prepareFile defaults output to <inputDir>/prepared when options.output is omitted", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const fx = await setupVault("default-out", 256);
|
||||
// No options.output → falls into the `path.join(path.dirname(filePath), "prepared")` default.
|
||||
const manifest = await prepareFileCommand(fx.input, fx.vault, PW);
|
||||
expect(manifest.totalPackets).toBeGreaterThanOrEqual(1);
|
||||
const defaultOut = path.join(path.dirname(fx.input), "prepared");
|
||||
const manifestPath = path.join(defaultOut, "manifest.json");
|
||||
const text = await fs.readFile(manifestPath, "utf-8");
|
||||
expect(JSON.parse(text).fileId).toBe(manifest.fileId);
|
||||
await cleanup(fx.dir);
|
||||
});
|
||||
|
||||
it("serveFile rejects when manifest is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const emptyDir = tmp("empty");
|
||||
await fs.mkdir(emptyDir, { recursive: true });
|
||||
await expect(serveFileCommand(emptyDir)).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.FILE_NOT_FOUND,
|
||||
});
|
||||
await cleanup(emptyDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serveFile + validateFile detect manifest-integrity tampering", () => {
|
||||
let fixture: Fixture | undefined;
|
||||
let preparedDir = "";
|
||||
beforeAll(async () => {
|
||||
fixture = await setupVault("tamper-manifest", 4096);
|
||||
preparedDir = path.join(fixture.dir, "prepared");
|
||||
await prepareFileCommand(fixture.input, fixture.vault, PW, {
|
||||
output: preparedDir,
|
||||
packetSize: 1024,
|
||||
});
|
||||
});
|
||||
afterAll(async () => {
|
||||
if (fixture) await cleanup(fixture.dir);
|
||||
});
|
||||
|
||||
async function readManifestJson(): Promise<Record<string, unknown>> {
|
||||
const text = await fs.readFile(
|
||||
path.join(preparedDir, "manifest.json"),
|
||||
"utf-8"
|
||||
);
|
||||
return JSON.parse(text) as Record<string, unknown>;
|
||||
}
|
||||
async function writeManifestJson(
|
||||
m: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await fs.writeFile(
|
||||
path.join(preparedDir, "manifest.json"),
|
||||
JSON.stringify(m)
|
||||
);
|
||||
}
|
||||
|
||||
it("serveFile raises PACKET_INTEGRITY_FAILED when totalPackets > entries.length", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const original = await readManifestJson();
|
||||
try {
|
||||
const tampered = { ...original, totalPackets: 99 };
|
||||
await writeManifestJson(tampered);
|
||||
await expect(
|
||||
serveFileCommand(preparedDir, {
|
||||
output: path.join((fixture as Fixture).dir, "out-x.bin"),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.PACKET_INTEGRITY_FAILED,
|
||||
});
|
||||
} finally {
|
||||
await writeManifestJson(original);
|
||||
}
|
||||
});
|
||||
|
||||
it("serveFile raises PACKET_INTEGRITY_FAILED when a packet record's index has a gap", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const original = await readManifestJson();
|
||||
try {
|
||||
const packets = (
|
||||
original.packets as Array<Record<string, unknown>>
|
||||
).map((p) => ({ ...p }));
|
||||
// Re-number the last entry's index to create a gap (no consecutive 0..N-1).
|
||||
if (packets.length > 0) {
|
||||
packets[packets.length - 1] = {
|
||||
...packets[packets.length - 1],
|
||||
index: 999,
|
||||
};
|
||||
}
|
||||
const tampered = {
|
||||
...original,
|
||||
packets,
|
||||
totalPackets: packets.length,
|
||||
};
|
||||
await writeManifestJson(tampered);
|
||||
await expect(
|
||||
serveFileCommand(preparedDir, {
|
||||
output: path.join((fixture as Fixture).dir, "out-y.bin"),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.PACKET_INTEGRITY_FAILED,
|
||||
});
|
||||
} finally {
|
||||
await writeManifestJson(original);
|
||||
}
|
||||
});
|
||||
|
||||
it("serveFile raises PACKET_INTEGRITY_FAILED when a packet record's originalSize lies", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const original = await readManifestJson();
|
||||
try {
|
||||
const packets = (
|
||||
original.packets as Array<Record<string, unknown>>
|
||||
).map((p, i) =>
|
||||
i === 0 ? { ...p, originalSize: 999_999 } : { ...p }
|
||||
);
|
||||
const tampered = { ...original, packets };
|
||||
await writeManifestJson(tampered);
|
||||
await expect(
|
||||
serveFileCommand(preparedDir, {
|
||||
output: path.join((fixture as Fixture).dir, "out-z.bin"),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.PACKET_INTEGRITY_FAILED,
|
||||
});
|
||||
} finally {
|
||||
await writeManifestJson(original);
|
||||
}
|
||||
});
|
||||
|
||||
it("serveFile raises PACKET_INTEGRITY_FAILED when manifest.originalSize is larger than reassembled bytes", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const original = await readManifestJson();
|
||||
try {
|
||||
const inflated = {
|
||||
...original,
|
||||
originalSize: (original.originalSize as number) + 4096,
|
||||
};
|
||||
await writeManifestJson(inflated);
|
||||
await expect(
|
||||
serveFileCommand(preparedDir, {
|
||||
output: path.join((fixture as Fixture).dir, "out-w.bin"),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.PACKET_INTEGRITY_FAILED,
|
||||
});
|
||||
} finally {
|
||||
await writeManifestJson(original);
|
||||
}
|
||||
});
|
||||
|
||||
it("validateFile reports an invalid packet when record.originalSize lies", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const original = await readManifestJson();
|
||||
try {
|
||||
const packets = (
|
||||
original.packets as Array<Record<string, unknown>>
|
||||
).map((p, i) =>
|
||||
i === 0 ? { ...p, originalSize: 12345 } : { ...p }
|
||||
);
|
||||
const tampered = { ...original, packets };
|
||||
await writeManifestJson(tampered);
|
||||
const result = await validateFileCommand(preparedDir);
|
||||
expect(result.invalidPackets).toContain(0);
|
||||
} finally {
|
||||
await writeManifestJson(original);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Packer, Unpacker } from "@suaveplan/packer";
|
||||
import {
|
||||
SERVER_KEYS_CODES,
|
||||
ServerKeysError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { checkSignal, decryptVault, loadFile } from "../keys/keys-io.js";
|
||||
import {
|
||||
DEFAULT_PACKET_SIZE,
|
||||
type FileCommandOptions,
|
||||
type FileManifest,
|
||||
type FileStatsResult,
|
||||
type FilesLogger,
|
||||
type PacketRecord,
|
||||
type ValidateFileResult,
|
||||
type ValidatePacketResult,
|
||||
} from "./files.types.js";
|
||||
import {
|
||||
bytesToHex,
|
||||
deserializePacket,
|
||||
hexToBytes,
|
||||
packetFilename,
|
||||
readManifest,
|
||||
serializePacket,
|
||||
writeManifest,
|
||||
} from "./packet-io.js";
|
||||
|
||||
const SILENT: () => void = () => {};
|
||||
const SILENT_LOGGER: FilesLogger = { info: SILENT, error: SILENT };
|
||||
|
||||
function pickLogger(options: FileCommandOptions | undefined): FilesLogger {
|
||||
return options?.logger ?? SILENT_LOGGER;
|
||||
}
|
||||
|
||||
interface SignSlot {
|
||||
readonly algorithm: number;
|
||||
readonly secretKey: Uint8Array;
|
||||
readonly publicKey: Uint8Array;
|
||||
}
|
||||
|
||||
function getSignSlot(keychain: unknown, vaultPath: string): SignSlot {
|
||||
const slot = (keychain as Record<string, SignSlot | undefined>)["sign"];
|
||||
/* istanbul ignore start -- defensive: encodeKeychain in @suaveplan/crypto requires every DEFAULT_PURPOSES slot (incl. `sign`) with a non-empty secretKey, so any vault successfully decrypted by openVault cannot reach this branch. */
|
||||
if (
|
||||
!slot ||
|
||||
!(slot.secretKey instanceof Uint8Array) ||
|
||||
slot.secretKey.length === 0
|
||||
) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.PACKET_INTEGRITY_FAILED,
|
||||
`${vaultPath} keychain has no usable sign slot`
|
||||
);
|
||||
}
|
||||
/* istanbul ignore stop */
|
||||
return slot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split `filePath` into fixed-size packets, sign each with the vault's
|
||||
* `sign` slot, and persist them with a manifest under `options.output`.
|
||||
*/
|
||||
export async function prepareFileCommand(
|
||||
filePath: string,
|
||||
vaultPath: string,
|
||||
password: string,
|
||||
options: FileCommandOptions = {}
|
||||
): Promise<FileManifest> {
|
||||
if (!filePath || !vaultPath || !password) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
"filePath, vaultPath, and password are required"
|
||||
);
|
||||
}
|
||||
const packetSize = options.packetSize ?? DEFAULT_PACKET_SIZE;
|
||||
if (!Number.isInteger(packetSize) || packetSize <= 0) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
"packetSize must be a positive integer"
|
||||
);
|
||||
}
|
||||
const outputDir =
|
||||
options.output ?? path.join(path.dirname(filePath), "prepared");
|
||||
checkSignal(options.signal, "prepare-file");
|
||||
const fileBuf = await loadFile(filePath);
|
||||
const vaultBytes = await loadFile(vaultPath);
|
||||
const keychain = await decryptVault(vaultBytes, password, vaultPath);
|
||||
const signSlot = getSignSlot(keychain, vaultPath);
|
||||
const totalPackets = Math.max(
|
||||
1,
|
||||
Math.ceil(fileBuf.byteLength / packetSize)
|
||||
);
|
||||
const fileId = randomUUID();
|
||||
const records: PacketRecord[] = [];
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
for (let i = 0; i < totalPackets; i++) {
|
||||
checkSignal(options.signal, "prepare-file");
|
||||
const start = i * packetSize;
|
||||
const end = Math.min(fileBuf.byteLength, start + packetSize);
|
||||
const slice = fileBuf.subarray(start, end);
|
||||
const packer = new Packer({
|
||||
compressionLevel: 0,
|
||||
signing: { key: signSlot.secretKey },
|
||||
});
|
||||
const packed = await packer.pack(slice, path.basename(filePath));
|
||||
const filename = packetFilename(i);
|
||||
await fs.writeFile(
|
||||
path.join(outputDir, filename),
|
||||
serializePacket(packed, slice.byteLength)
|
||||
);
|
||||
records.push({
|
||||
index: i,
|
||||
checksum: packed.metadata.checksum,
|
||||
filename,
|
||||
originalSize: slice.byteLength,
|
||||
});
|
||||
}
|
||||
const manifest: FileManifest = {
|
||||
schemaVersion: 1,
|
||||
fileId,
|
||||
originalFilename: path.basename(filePath),
|
||||
originalSize: fileBuf.byteLength,
|
||||
packetSize,
|
||||
totalPackets,
|
||||
signerPublicKey: bytesToHex(signSlot.publicKey),
|
||||
packets: records,
|
||||
};
|
||||
await writeManifest(outputDir, manifest);
|
||||
pickLogger(options).info(
|
||||
`prepare-file: fileId=${fileId} packets=${totalPackets} out=${outputDir}`
|
||||
);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/** Walk every packet and verify integrity + signature against the manifest. */
|
||||
export async function validateFileCommand(
|
||||
packetDir: string,
|
||||
options: FileCommandOptions = {}
|
||||
): Promise<ValidateFileResult> {
|
||||
if (!packetDir) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
"packetDir is required"
|
||||
);
|
||||
}
|
||||
const manifest = await readManifest(packetDir);
|
||||
const verifyKey = hexToBytes(manifest.signerPublicKey);
|
||||
const invalid: number[] = [];
|
||||
let validCount = 0;
|
||||
for (const record of manifest.packets) {
|
||||
checkSignal(options.signal, "validate-file");
|
||||
const buf = await loadFile(path.join(packetDir, record.filename));
|
||||
const { packed } = deserializePacket(buf);
|
||||
const unpacker = new Unpacker();
|
||||
try {
|
||||
const result = await unpacker.unpack(packed, {
|
||||
verifySignature: true,
|
||||
verifyKey,
|
||||
verifyChecksum: true,
|
||||
});
|
||||
if (result.data.byteLength !== record.originalSize) {
|
||||
invalid.push(record.index);
|
||||
} else {
|
||||
validCount++;
|
||||
}
|
||||
} catch {
|
||||
invalid.push(record.index);
|
||||
}
|
||||
}
|
||||
pickLogger(options).info(
|
||||
`validate-file: fileId=${manifest.fileId} valid=${validCount}/${manifest.totalPackets}`
|
||||
);
|
||||
return {
|
||||
isComplete: invalid.length === 0,
|
||||
totalPackets: manifest.totalPackets,
|
||||
validPackets: validCount,
|
||||
invalidPackets: invalid,
|
||||
fileId: manifest.fileId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Print file packing statistics without performing any packing work. */
|
||||
export async function getFileStatsCommand(
|
||||
filePath: string,
|
||||
options: FileCommandOptions = {}
|
||||
): Promise<FileStatsResult> {
|
||||
if (!filePath) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
"filePath is required"
|
||||
);
|
||||
}
|
||||
const packetSize = options.packetSize ?? DEFAULT_PACKET_SIZE;
|
||||
if (!Number.isInteger(packetSize) || packetSize <= 0) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
"packetSize must be a positive integer"
|
||||
);
|
||||
}
|
||||
let stat: Awaited<ReturnType<typeof fs.stat>>;
|
||||
try {
|
||||
stat = await fs.stat(filePath);
|
||||
} catch (err) {
|
||||
let extras: { cause?: Error; context: { filePath: string } } = {
|
||||
context: { filePath },
|
||||
};
|
||||
/* istanbul ignore start -- defensive: fs.stat always throws NodeJS.ErrnoException (Error subclass), so the else branch is unreachable. */
|
||||
if (err instanceof Error) {
|
||||
extras = { cause: err, context: { filePath } };
|
||||
}
|
||||
/* istanbul ignore stop */
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.FILE_NOT_FOUND,
|
||||
`File not found: ${filePath}`,
|
||||
extras
|
||||
);
|
||||
}
|
||||
const estimatedPackets = Math.max(1, Math.ceil(stat.size / packetSize));
|
||||
const overheadPerPacket = 256;
|
||||
const estimatedOverheadBytes = estimatedPackets * overheadPerPacket;
|
||||
const result: FileStatsResult = {
|
||||
inputPath: filePath,
|
||||
originalSize: stat.size,
|
||||
packetSize,
|
||||
estimatedPackets,
|
||||
estimatedOverheadBytes,
|
||||
estimatedPackedBytes: stat.size * 2 + estimatedOverheadBytes,
|
||||
lastModified: stat.mtime,
|
||||
};
|
||||
pickLogger(options).info(
|
||||
`get-file-stats: ${filePath} size=${stat.size} packets≈${estimatedPackets}`
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Validate a single packet against an explicit verify key (hex). */
|
||||
export async function validatePacketCommand(
|
||||
packetPath: string,
|
||||
verifyKeyHex: string,
|
||||
options: FileCommandOptions = {}
|
||||
): Promise<ValidatePacketResult> {
|
||||
if (!packetPath || !verifyKeyHex) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
"packetPath and verifyKeyHex are required"
|
||||
);
|
||||
}
|
||||
checkSignal(options.signal, "validate-packet");
|
||||
const buf = await loadFile(packetPath);
|
||||
const { packed, envelope } = deserializePacket(buf);
|
||||
const verifyKey = hexToBytes(verifyKeyHex);
|
||||
const unpacker = new Unpacker();
|
||||
try {
|
||||
await unpacker.unpack(packed, {
|
||||
verifySignature: true,
|
||||
verifyKey,
|
||||
verifyChecksum: true,
|
||||
});
|
||||
pickLogger(options).info(`validate-packet: ${packetPath} VALID`);
|
||||
return {
|
||||
isValid: true,
|
||||
index: -1,
|
||||
checksum: envelope.checksum,
|
||||
};
|
||||
} catch (err) {
|
||||
let message: string = String(err);
|
||||
/* istanbul ignore start -- defensive: Unpacker.unpack rejects with Error subclasses on every documented failure mode, so the else branch is unreachable. */
|
||||
if (err instanceof Error) {
|
||||
message = err.message;
|
||||
}
|
||||
/* istanbul ignore stop */
|
||||
pickLogger(options).error(
|
||||
`validate-packet: ${packetPath} INVALID: ${message}`
|
||||
);
|
||||
return {
|
||||
isValid: false,
|
||||
index: -1,
|
||||
checksum: envelope.checksum,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
# `commands/files/files.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
Type definitions and the default packet-size constant shared by every
|
||||
file-transport command in `@suaveplan/server-keys`. Centralising the
|
||||
shapes here lets `files.ts`, `serve-file.ts`, and `packet-io.ts` all
|
||||
agree on the wire format without circular imports, and gives downstream
|
||||
programmatic consumers a single import for the typed return envelopes
|
||||
(`FileManifest`, `ValidateFileResult`, `FileStatsResult`,
|
||||
`ValidatePacketResult`, `PacketRecord`).
|
||||
|
||||
The `DEFAULT_PACKET_SIZE = 1_048_576` (1 MiB) constant mirrors the
|
||||
upstream default; both the prepare and stats commands fall back to it
|
||||
when the caller doesn't override `packetSize`.
|
||||
|
||||
## Features
|
||||
|
||||
- `FilesLogger` — `Pick<LoggerLike, "info" | "error">`; injected.
|
||||
- `FileCommandOptions` — shared `{ output?, packetSize?, logger?,
|
||||
signal? }`.
|
||||
- `FileManifest` — top-level on-disk JSON shape, schema-versioned via
|
||||
`schemaVersion: 1`.
|
||||
- `PacketRecord` — per-packet metadata in the manifest.
|
||||
- `ValidateFileResult`, `FileStatsResult`, `ValidatePacketResult` —
|
||||
typed return envelopes.
|
||||
- `DEFAULT_PACKET_SIZE = 1_048_576`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type {
|
||||
FileCommandOptions,
|
||||
FileManifest,
|
||||
} from "@suaveplan/server-keys/commands/files/files.types";
|
||||
|
||||
const options: FileCommandOptions = {
|
||||
output: "/tmp/prepared",
|
||||
packetSize: 512 * 1024,
|
||||
};
|
||||
|
||||
function packetCount(m: FileManifest): number {
|
||||
return m.totalPackets;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Validate a third-party-produced manifest at the boundary before
|
||||
consuming it:
|
||||
|
||||
```ts
|
||||
import type { FileManifest } from "@suaveplan/server-keys/commands/files/files.types";
|
||||
|
||||
function isFileManifest(value: unknown): value is FileManifest {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return (
|
||||
v.schemaVersion === 1 &&
|
||||
typeof v.fileId === "string" &&
|
||||
typeof v.originalFilename === "string" &&
|
||||
typeof v.originalSize === "number" &&
|
||||
typeof v.packetSize === "number" &&
|
||||
typeof v.totalPackets === "number" &&
|
||||
typeof v.signerPublicKey === "string" &&
|
||||
Array.isArray(v.packets)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface FilesLogger`
|
||||
- `interface FileCommandOptions`
|
||||
- `interface PacketRecord { index, checksum, filename, originalSize }`
|
||||
- `interface FileManifest { schemaVersion, fileId, originalFilename,
|
||||
originalSize, packetSize, totalPackets, signerPublicKey, packets }`
|
||||
- `interface ValidateFileResult { isComplete, totalPackets,
|
||||
validPackets, invalidPackets, fileId }`
|
||||
- `interface FileStatsResult { inputPath, originalSize, packetSize,
|
||||
estimatedPackets, estimatedOverheadBytes, estimatedPackedBytes,
|
||||
lastModified }`
|
||||
- `interface ValidatePacketResult { isValid, index, checksum, error? }`
|
||||
- `const DEFAULT_PACKET_SIZE: 1_048_576`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every field is `readonly` to keep accidental in-place mutation out of
|
||||
the call sites; the manifest is also frozen-on-write by the JSON
|
||||
round-trip in `writeManifest`. The `schemaVersion: 1` literal type pins
|
||||
the on-disk format — a future v2 change would add `2` as a discriminator
|
||||
and require an explicit migration path in the orchestration layer
|
||||
instead of silently changing the shape.
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
|
||||
/** Logger surface accepted by every file command. */
|
||||
export interface FilesLogger extends Pick<LoggerLike, "info" | "error"> {}
|
||||
|
||||
/** Default packet size: 1 MiB. */
|
||||
export const DEFAULT_PACKET_SIZE = 1_048_576;
|
||||
|
||||
/** Common option bag for every file command. */
|
||||
export interface FileCommandOptions {
|
||||
/** Output directory (for prepare). Defaults to caller-supplied. */
|
||||
readonly output?: string;
|
||||
/** Packet size in bytes (default 1 MiB). */
|
||||
readonly packetSize?: number;
|
||||
/** Optional logger; defaults to silent. */
|
||||
readonly logger?: FilesLogger;
|
||||
/** Cooperative abort. */
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-packet metadata embedded alongside the packed payload. The
|
||||
* manifest header serialises an array of these, one per packet, so
|
||||
* serve / validate paths can reconstruct ordering and check integrity
|
||||
* without re-reading every packet.
|
||||
*/
|
||||
export interface PacketRecord {
|
||||
readonly index: number;
|
||||
/** Hex-encoded checksum (xxhash via `@suaveplan/packer`) of the raw plaintext slice. */
|
||||
readonly checksum: string;
|
||||
/** Filename of the packet on disk (relative to the manifest's directory). */
|
||||
readonly filename: string;
|
||||
/** Byte length of the raw plaintext slice. */
|
||||
readonly originalSize: number;
|
||||
}
|
||||
|
||||
/** Top-level manifest persisted alongside the packets. */
|
||||
export interface FileManifest {
|
||||
readonly schemaVersion: 1;
|
||||
readonly fileId: string;
|
||||
readonly originalFilename: string;
|
||||
readonly originalSize: number;
|
||||
readonly packetSize: number;
|
||||
readonly totalPackets: number;
|
||||
/** Hex-encoded ed25519 public key the receiver uses to verify each packet. */
|
||||
readonly signerPublicKey: string;
|
||||
readonly packets: readonly PacketRecord[];
|
||||
}
|
||||
|
||||
/** Result of `validateFileCommand`. */
|
||||
export interface ValidateFileResult {
|
||||
readonly isComplete: boolean;
|
||||
readonly totalPackets: number;
|
||||
readonly validPackets: number;
|
||||
readonly invalidPackets: readonly number[];
|
||||
readonly fileId: string;
|
||||
}
|
||||
|
||||
/** Result of `getFileStatsCommand`. */
|
||||
export interface FileStatsResult {
|
||||
readonly inputPath: string;
|
||||
readonly originalSize: number;
|
||||
readonly packetSize: number;
|
||||
readonly estimatedPackets: number;
|
||||
readonly estimatedOverheadBytes: number;
|
||||
readonly estimatedPackedBytes: number;
|
||||
readonly lastModified: Date;
|
||||
}
|
||||
|
||||
/** Result of `validatePacketCommand`. */
|
||||
export interface ValidatePacketResult {
|
||||
readonly isValid: boolean;
|
||||
readonly index: number;
|
||||
readonly checksum: string;
|
||||
readonly error?: string;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export {
|
||||
getFileStatsCommand,
|
||||
prepareFileCommand,
|
||||
validateFileCommand,
|
||||
validatePacketCommand,
|
||||
} from "./files.js";
|
||||
export {
|
||||
DEFAULT_PACKET_SIZE,
|
||||
type FileCommandOptions,
|
||||
type FileManifest,
|
||||
type FileStatsResult,
|
||||
type FilesLogger,
|
||||
type PacketRecord,
|
||||
type ValidateFileResult,
|
||||
type ValidatePacketResult,
|
||||
} from "./files.types.js";
|
||||
export {
|
||||
bytesToHex,
|
||||
deserializePacket,
|
||||
hexToBytes,
|
||||
MANIFEST_FILENAME,
|
||||
packetFilename,
|
||||
readManifest,
|
||||
type SerializedPacket,
|
||||
serializePacket,
|
||||
writeManifest,
|
||||
} from "./packet-io.js";
|
||||
export { type ServeFileResult, serveFileCommand } from "./serve-file.js";
|
||||
@@ -0,0 +1,87 @@
|
||||
# `commands/files/packet-io`
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared on-disk format helpers for the file-transport commands. Holds
|
||||
the canonical packet envelope `{ data, signature, checksum,
|
||||
originalSize }` (all hex-encoded scalars in JSON), the canonical
|
||||
packet filename convention (`packet.<6-digit-index>.bin`), the manifest
|
||||
filename (`manifest.json`), and the read/write helpers used by both
|
||||
`prepareFileCommand` and `serveFileCommand`.
|
||||
|
||||
Lives in its own module so the format is the contract: any tool that
|
||||
agrees on the envelope shape can interop with a server-keys-prepared
|
||||
directory without depending on the full file-command surface. Also keeps
|
||||
`files.ts` and `serve-file.ts` under the 300-line hard limit by lifting
|
||||
out the duplicated serialisation code.
|
||||
|
||||
## Features
|
||||
|
||||
- `packetFilename(index)` — zero-padded 6-digit filename for
|
||||
deterministic sort order.
|
||||
- `bytesToHex` / `hexToBytes` — lowercase hex, strict input validation.
|
||||
- `serializePacket(packed, originalSize)` — JSON-encode a `PackedData`
|
||||
envelope.
|
||||
- `deserializePacket(buffer)` — parse a JSON envelope back into the
|
||||
shape `Unpacker.unpack` expects, with a typed `PACKET_INTEGRITY_FAILED`
|
||||
failure on malformed JSON.
|
||||
- `readManifest(dir)` / `writeManifest(dir, manifest)` — single-file
|
||||
JSON manifest I/O with directory creation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
packetFilename,
|
||||
serializePacket,
|
||||
writeManifest,
|
||||
} from "@suaveplan/server-keys/commands/files/packet-io";
|
||||
|
||||
const filename = packetFilename(7); // "packet.000007.bin"
|
||||
const text = serializePacket(packed, 1024);
|
||||
await fs.writeFile(`/tmp/prepared/${filename}`, text);
|
||||
await writeManifest("/tmp/prepared", manifest);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Round-trip a packet envelope without touching disk:
|
||||
|
||||
```ts
|
||||
import {
|
||||
deserializePacket,
|
||||
serializePacket,
|
||||
} from "@suaveplan/server-keys/commands/files/packet-io";
|
||||
|
||||
const text = serializePacket(packed, slice.byteLength);
|
||||
const buf = new TextEncoder().encode(text);
|
||||
const { packed: rehydrated, envelope } = deserializePacket(buf);
|
||||
expect(envelope.checksum).toBe(packed.metadata.checksum);
|
||||
expect(Array.from(rehydrated.data)).toEqual(Array.from(packed.data));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `MANIFEST_FILENAME = "manifest.json"`
|
||||
- `packetFilename(index: number) => string`
|
||||
- `bytesToHex(bytes: Uint8Array) => string`
|
||||
- `hexToBytes(hex: string) => Uint8Array` (throws on malformed input)
|
||||
- `interface SerializedPacket { data, signature, checksum, originalSize }`
|
||||
- `serializePacket(packed, originalSize) => string`
|
||||
- `deserializePacket(buffer) => { packed, envelope }`
|
||||
- `writeManifest(dir, manifest) => Promise<void>`
|
||||
- `readManifest(dir) => Promise<FileManifest>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`readManifest` translates a missing file into
|
||||
`SERVER_KEYS_FILE_NOT_FOUND`; this is the closest match in the
|
||||
registry for "the directory exists but the manifest entry is absent"
|
||||
and aligns with how the keys commands report missing key files.
|
||||
`deserializePacket` raises `PACKET_INTEGRITY_FAILED` on malformed JSON
|
||||
rather than `INVALID_INPUT` because the input is a file the caller
|
||||
already trusted enough to read — the failure is on the storage side,
|
||||
not on a CLI option-bag boundary. The packet envelope deliberately
|
||||
hex-encodes both `data` and `signature` (rather than base64) to match
|
||||
the rest of the package's binary-on-disk convention and to keep the
|
||||
file readable by hand.
|
||||
@@ -0,0 +1,168 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SERVER_KEYS_CODES,
|
||||
ServerKeysError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type { FileManifest } from "./files.types.js";
|
||||
import {
|
||||
bytesToHex,
|
||||
deserializePacket,
|
||||
hexToBytes,
|
||||
MANIFEST_FILENAME,
|
||||
packetFilename,
|
||||
readManifest,
|
||||
serializePacket,
|
||||
writeManifest,
|
||||
} from "./packet-io.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
|
||||
function tmpDir(label: string): string {
|
||||
const d = path.join(
|
||||
tmpdir(),
|
||||
`pio-${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
dirs.push(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
for (const d of dirs) await fs.rm(d, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("packetFilename", () => {
|
||||
it("zero-pads to six digits with .bin extension", { timeout: 5000 }, () => {
|
||||
expect(packetFilename(0)).toBe("packet.000000.bin");
|
||||
expect(packetFilename(42)).toBe("packet.000042.bin");
|
||||
expect(packetFilename(123456)).toBe("packet.123456.bin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bytesToHex / hexToBytes", () => {
|
||||
it("round-trips arbitrary bytes", { timeout: 5000 }, () => {
|
||||
const cases = [
|
||||
new Uint8Array([]),
|
||||
new Uint8Array([1, 2, 3, 4, 5]),
|
||||
new Uint8Array([0xff, 0x00, 0xab]),
|
||||
];
|
||||
for (const c of cases) {
|
||||
expect(hexToBytes(bytesToHex(c))).toEqual(c);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects odd-length hex", { timeout: 5000 }, () => {
|
||||
expect(() => hexToBytes("abc")).toThrow(ServerKeysError);
|
||||
});
|
||||
|
||||
it("rejects non-hex characters", { timeout: 5000 }, () => {
|
||||
try {
|
||||
hexToBytes("zz");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializePacket / deserializePacket", () => {
|
||||
it("round-trips a PackedData envelope", { timeout: 5000 }, () => {
|
||||
const packed = {
|
||||
data: new Uint8Array([10, 20, 30]),
|
||||
metadata: {
|
||||
checksum: "abc123",
|
||||
originalSize: 3,
|
||||
compressedSize: 3,
|
||||
encrypted: false,
|
||||
timestamp: 0,
|
||||
signed: true,
|
||||
signature: new Uint8Array([0xaa, 0xbb]),
|
||||
},
|
||||
};
|
||||
const text = serializePacket(packed, 3);
|
||||
const buf = new TextEncoder().encode(text);
|
||||
const { packed: out, envelope } = deserializePacket(buf);
|
||||
expect(envelope.checksum).toBe("abc123");
|
||||
expect(envelope.originalSize).toBe(3);
|
||||
expect(out.data).toEqual(packed.data);
|
||||
expect(out.metadata.signed).toBe(true);
|
||||
expect(out.metadata.signature).toEqual(packed.metadata.signature);
|
||||
});
|
||||
|
||||
it("round-trips a packet without a signature", { timeout: 5000 }, () => {
|
||||
const packed = {
|
||||
data: new Uint8Array([1]),
|
||||
metadata: {
|
||||
checksum: "abc",
|
||||
originalSize: 1,
|
||||
compressedSize: 1,
|
||||
encrypted: false,
|
||||
timestamp: 0,
|
||||
},
|
||||
};
|
||||
const text = serializePacket(packed, 1);
|
||||
const buf = new TextEncoder().encode(text);
|
||||
const { packed: out } = deserializePacket(buf);
|
||||
expect(out.metadata.signature).toBeUndefined();
|
||||
expect(out.metadata.signed).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects malformed JSON with PACKET_INTEGRITY_FAILED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const buf = new TextEncoder().encode("{not-json");
|
||||
try {
|
||||
deserializePacket(buf);
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toMatchObject({
|
||||
code: SERVER_KEYS_CODES.PACKET_INTEGRITY_FAILED,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("readManifest / writeManifest", () => {
|
||||
it("writes and reads back a canonical JSON manifest", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const dir = tmpDir("wm");
|
||||
const manifest: FileManifest = {
|
||||
schemaVersion: 1,
|
||||
fileId: "abc",
|
||||
originalFilename: "data.bin",
|
||||
originalSize: 100,
|
||||
packetSize: 50,
|
||||
totalPackets: 2,
|
||||
signerPublicKey: "ff",
|
||||
packets: [
|
||||
{ index: 0, checksum: "x", filename: "p0", originalSize: 50 },
|
||||
{ index: 1, checksum: "y", filename: "p1", originalSize: 50 },
|
||||
],
|
||||
};
|
||||
await writeManifest(dir, manifest);
|
||||
const onDisk = await fs.readFile(
|
||||
path.join(dir, MANIFEST_FILENAME),
|
||||
"utf-8"
|
||||
);
|
||||
const parsed = JSON.parse(onDisk);
|
||||
expect(parsed.fileId).toBe("abc");
|
||||
const reread = await readManifest(dir);
|
||||
expect(reread.fileId).toBe("abc");
|
||||
expect(reread.packets.length).toBe(2);
|
||||
});
|
||||
|
||||
it("readManifest raises FILE_NOT_FOUND on a missing manifest", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const dir = tmpDir("rm");
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await expect(readManifest(dir)).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.FILE_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { PackedData } from "@suaveplan/packer";
|
||||
import {
|
||||
SERVER_KEYS_CODES,
|
||||
ServerKeysError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type { FileManifest } from "./files.types.js";
|
||||
|
||||
export const MANIFEST_FILENAME = "manifest.json";
|
||||
const PACKET_PREFIX = "packet.";
|
||||
const PACKET_EXTENSION = ".bin";
|
||||
|
||||
export function packetFilename(index: number): string {
|
||||
return `${PACKET_PREFIX}${index.toString().padStart(6, "0")}${PACKET_EXTENSION}`;
|
||||
}
|
||||
|
||||
export function bytesToHex(bytes: Uint8Array): string {
|
||||
let out = "";
|
||||
for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
|
||||
return out;
|
||||
}
|
||||
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
if (hex.length % 2 !== 0) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
`Hex string length must be even (got ${hex.length})`
|
||||
);
|
||||
}
|
||||
const out = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const slice = hex.slice(i * 2, i * 2 + 2);
|
||||
if (!/^[0-9a-fA-F]{2}$/.test(slice)) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
`Invalid hex byte "${slice}" at offset ${i * 2}`
|
||||
);
|
||||
}
|
||||
out[i] = Number.parseInt(slice, 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface SerializedPacketIntegrity {
|
||||
readonly entryDigest: string;
|
||||
readonly merkleRoot: string;
|
||||
}
|
||||
|
||||
export interface SerializedPacket {
|
||||
readonly data: string;
|
||||
readonly signature: string;
|
||||
readonly checksum: string;
|
||||
readonly originalSize: number;
|
||||
readonly integrity?: SerializedPacketIntegrity;
|
||||
}
|
||||
|
||||
/** Serialise a `PackedData` envelope to JSON for on-disk storage. */
|
||||
export function serializePacket(
|
||||
packed: PackedData,
|
||||
originalSize: number
|
||||
): string {
|
||||
const meta = packed.metadata;
|
||||
const envelope: SerializedPacket = {
|
||||
data: bytesToHex(packed.data),
|
||||
signature: meta.signature ? bytesToHex(meta.signature) : "",
|
||||
checksum: meta.checksum,
|
||||
originalSize,
|
||||
...(meta.integrity
|
||||
? {
|
||||
integrity: {
|
||||
entryDigest: bytesToHex(meta.integrity.entryDigest),
|
||||
merkleRoot: bytesToHex(meta.integrity.merkleRoot),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
/** Re-hydrate a serialised packet into the `PackedData` shape unpacker expects. */
|
||||
export function deserializePacket(buffer: Uint8Array): {
|
||||
packed: PackedData;
|
||||
envelope: SerializedPacket;
|
||||
} {
|
||||
let envelope: SerializedPacket;
|
||||
try {
|
||||
envelope = JSON.parse(
|
||||
new TextDecoder().decode(buffer)
|
||||
) as SerializedPacket;
|
||||
} catch (err) {
|
||||
let cause: { cause: Error } | undefined;
|
||||
/* istanbul ignore start -- defensive: JSON.parse always throws SyntaxError (an Error subclass) so the else branch is unreachable. */
|
||||
if (err instanceof Error) {
|
||||
cause = { cause: err };
|
||||
}
|
||||
/* istanbul ignore stop */
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.PACKET_INTEGRITY_FAILED,
|
||||
"Packet payload is not valid JSON",
|
||||
cause
|
||||
);
|
||||
}
|
||||
const data = hexToBytes(envelope.data);
|
||||
const signature =
|
||||
envelope.signature.length > 0
|
||||
? hexToBytes(envelope.signature)
|
||||
: undefined;
|
||||
const packed: PackedData = {
|
||||
data,
|
||||
metadata: {
|
||||
checksum: envelope.checksum,
|
||||
originalSize: envelope.originalSize,
|
||||
compressedSize: data.byteLength,
|
||||
encrypted: false,
|
||||
timestamp: 0,
|
||||
...(signature !== undefined ? { signed: true, signature } : {}),
|
||||
...(envelope.integrity
|
||||
? {
|
||||
integrity: {
|
||||
entryDigest: hexToBytes(
|
||||
envelope.integrity.entryDigest
|
||||
),
|
||||
merkleRoot: hexToBytes(envelope.integrity.merkleRoot),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
return { packed, envelope };
|
||||
}
|
||||
|
||||
/** Persist `manifest` as canonical JSON in `dir`. */
|
||||
export async function writeManifest(
|
||||
dir: string,
|
||||
manifest: FileManifest
|
||||
): Promise<void> {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(dir, MANIFEST_FILENAME),
|
||||
JSON.stringify(manifest, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
/** Read and parse the manifest in `dir`. */
|
||||
export async function readManifest(dir: string): Promise<FileManifest> {
|
||||
try {
|
||||
const text = await fs.readFile(
|
||||
path.join(dir, MANIFEST_FILENAME),
|
||||
"utf-8"
|
||||
);
|
||||
return JSON.parse(text) as FileManifest;
|
||||
} catch (err) {
|
||||
let ctx: { cause?: Error; context: { dir: string } } = {
|
||||
context: { dir },
|
||||
};
|
||||
/* istanbul ignore start -- defensive: fs.readFile always throws NodeJS.ErrnoException (Error subclass) and JSON.parse always throws SyntaxError, so the else branch is unreachable. */
|
||||
if (err instanceof Error) {
|
||||
ctx = { cause: err, context: { dir } };
|
||||
}
|
||||
/* istanbul ignore stop */
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.FILE_NOT_FOUND,
|
||||
`Manifest not found: ${path.join(dir, MANIFEST_FILENAME)}`,
|
||||
ctx
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# `commands/files/serve-file`
|
||||
|
||||
## Purpose
|
||||
|
||||
Reconstructs the original file from a directory of prepared packets by
|
||||
verifying each packet's ed25519 signature + sha256 checksum against the
|
||||
manifest and concatenating the verified plaintext slices in index order.
|
||||
The reconstruction is byte-identical to the input file passed to
|
||||
`prepareFileCommand`. Failure to verify any packet aborts the operation
|
||||
with `ServerKeysError(SERVER_KEYS_PACKET_INTEGRITY_FAILED)` BEFORE any
|
||||
bytes are written to disk, so the output path is never half-written.
|
||||
|
||||
Lives in a sibling file to `files.ts` because the reconstruction path
|
||||
has a distinctly different shape (in-memory contiguous buffer, strict
|
||||
ordering, single output write) compared to the parallel packet-level
|
||||
work the rest of the file commands do.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict index ordering: refuses to serve if any packet's `index` does
|
||||
not match its position in the sorted manifest.
|
||||
- Signature + checksum verification per packet via `Unpacker.unpack`
|
||||
with `verifySignature: true, verifyKey, verifyChecksum: true`.
|
||||
- Single allocation for the reconstructed buffer (`new
|
||||
Uint8Array(manifest.originalSize)`), populated in place — bounds the
|
||||
serve-path memory at exactly the original file size.
|
||||
- Cooperative abort via `options.signal`.
|
||||
- Output defaults to `<packetDir>/<originalFilename>`; override with
|
||||
`options.output`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { serveFileCommand } from "@suaveplan/server-keys/commands/files/serve-file";
|
||||
|
||||
const result = await serveFileCommand("/tmp/prepared", {
|
||||
output: "/tmp/reconstructed.bin",
|
||||
});
|
||||
expect(result.bytesWritten).toBe(originalSize);
|
||||
expect(result.packetsRead).toBe(totalPackets);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Drive the reconstruction with a capturing logger and an external abort
|
||||
signal, then compare the result byte-for-byte to the original:
|
||||
|
||||
```ts
|
||||
import { promises as fs } from "node:fs";
|
||||
import { serveFileCommand } from "@suaveplan/server-keys/commands/files/serve-file";
|
||||
|
||||
const lines: string[] = [];
|
||||
const ac = new AbortController();
|
||||
setTimeout(() => ac.abort(), 5000);
|
||||
|
||||
const result = await serveFileCommand("/tmp/prepared", {
|
||||
output: "/tmp/out.bin",
|
||||
logger: { info: (m) => lines.push(m), error: (m) => lines.push(m) },
|
||||
signal: ac.signal,
|
||||
});
|
||||
|
||||
const original = await fs.readFile("/tmp/data.bin");
|
||||
const reconstructed = await fs.readFile(result.outputPath);
|
||||
expect(Buffer.compare(original, reconstructed)).toBe(0);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `serveFileCommand(packetDir, options?) => Promise<ServeFileResult>`
|
||||
- `interface ServeFileResult { outputPath, bytesWritten, packetsRead,
|
||||
fileId }`
|
||||
|
||||
`options: FileCommandOptions = { output?, logger?, signal? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The reconstruction buffer is sized to `manifest.originalSize` and we
|
||||
assert `writeOffset === manifest.originalSize` at the end so a
|
||||
manifest-vs-actual mismatch is caught as `PACKET_INTEGRITY_FAILED`
|
||||
instead of silently truncating. We sort the manifest's packet records
|
||||
before iterating because the JSON entries may have been written in any
|
||||
order; sorting once and iterating in index order is O(n log n) for n
|
||||
packets, which is dominated by the per-packet AEAD work anyway.
|
||||
Verification is requested with all three checks enabled (`signature`,
|
||||
`checksum`, and length) because the contract is "byte-identical
|
||||
reconstruction" — any weakening would let a tampered packet through
|
||||
with the same number of bytes.
|
||||
@@ -0,0 +1,123 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Unpacker } from "@suaveplan/packer";
|
||||
import {
|
||||
SERVER_KEYS_CODES,
|
||||
ServerKeysError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { checkSignal, loadFile } from "../keys/keys-io.js";
|
||||
import type { FileCommandOptions, FilesLogger } from "./files.types.js";
|
||||
import { deserializePacket, hexToBytes, readManifest } from "./packet-io.js";
|
||||
|
||||
const SILENT: () => void = () => {};
|
||||
const SILENT_LOGGER: FilesLogger = { info: SILENT, error: SILENT };
|
||||
|
||||
function pickLogger(options: FileCommandOptions | undefined): FilesLogger {
|
||||
return options?.logger ?? SILENT_LOGGER;
|
||||
}
|
||||
|
||||
/** Result envelope for {@link serveFileCommand}. */
|
||||
export interface ServeFileResult {
|
||||
readonly outputPath: string;
|
||||
readonly bytesWritten: number;
|
||||
readonly packetsRead: number;
|
||||
readonly fileId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the original file from the prepared packet directory.
|
||||
* Verifies each packet's signature + checksum against the manifest's
|
||||
* `signerPublicKey` before writing to disk. Output path defaults to
|
||||
* `<packetDir>/<manifest.originalFilename>`.
|
||||
*/
|
||||
export async function serveFileCommand(
|
||||
packetDir: string,
|
||||
options: FileCommandOptions = {}
|
||||
): Promise<ServeFileResult> {
|
||||
if (!packetDir) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
"packetDir is required"
|
||||
);
|
||||
}
|
||||
checkSignal(options.signal, "serve-file");
|
||||
const manifest = await readManifest(packetDir);
|
||||
const verifyKey = hexToBytes(manifest.signerPublicKey);
|
||||
const reconstructed = new Uint8Array(manifest.originalSize);
|
||||
let writeOffset = 0;
|
||||
let packetsRead = 0;
|
||||
const sorted = [...manifest.packets].sort((a, b) => a.index - b.index);
|
||||
if (sorted.length !== manifest.totalPackets) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.PACKET_INTEGRITY_FAILED,
|
||||
`Manifest declares ${manifest.totalPackets} packets but only ${sorted.length} entries present`
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const record = sorted[i];
|
||||
/* istanbul ignore start -- defensive: `sorted` is derived from manifest.packets via Array.sort, which preserves length and never introduces sparse holes. */
|
||||
if (!record) continue;
|
||||
/* istanbul ignore stop */
|
||||
if (record.index !== i) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.PACKET_INTEGRITY_FAILED,
|
||||
`Packet sequence broken at index ${i}: manifest entry has index=${record.index}`
|
||||
);
|
||||
}
|
||||
checkSignal(options.signal, "serve-file");
|
||||
const buf = await loadFile(path.join(packetDir, record.filename));
|
||||
const { packed } = deserializePacket(buf);
|
||||
const unpacker = new Unpacker();
|
||||
let result: Awaited<ReturnType<typeof unpacker.unpack>>;
|
||||
try {
|
||||
result = await unpacker.unpack(packed, {
|
||||
verifySignature: true,
|
||||
verifyKey,
|
||||
verifyChecksum: true,
|
||||
});
|
||||
} catch (err) {
|
||||
let extras: {
|
||||
cause?: Error;
|
||||
context: { index: number };
|
||||
} = { context: { index: record.index } };
|
||||
/* istanbul ignore start -- defensive: Unpacker.unpack rejects with Error subclasses on every documented failure mode, so the else branch is unreachable. */
|
||||
if (err instanceof Error) {
|
||||
extras = { cause: err, context: { index: record.index } };
|
||||
}
|
||||
/* istanbul ignore stop */
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.PACKET_INTEGRITY_FAILED,
|
||||
`Packet ${record.index} (${record.filename}) failed verification`,
|
||||
extras
|
||||
);
|
||||
}
|
||||
if (result.data.byteLength !== record.originalSize) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.PACKET_INTEGRITY_FAILED,
|
||||
`Packet ${record.index} size mismatch: expected ${record.originalSize} got ${result.data.byteLength}`
|
||||
);
|
||||
}
|
||||
reconstructed.set(result.data, writeOffset);
|
||||
writeOffset += result.data.byteLength;
|
||||
packetsRead++;
|
||||
}
|
||||
if (writeOffset !== manifest.originalSize) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.PACKET_INTEGRITY_FAILED,
|
||||
`Reassembled size ${writeOffset} does not match manifest.originalSize ${manifest.originalSize}`
|
||||
);
|
||||
}
|
||||
const outputPath =
|
||||
options.output ?? path.join(packetDir, manifest.originalFilename);
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, reconstructed);
|
||||
pickLogger(options).info(
|
||||
`serve-file: fileId=${manifest.fileId} packets=${packetsRead} bytes=${writeOffset} out=${outputPath}`
|
||||
);
|
||||
return {
|
||||
outputPath,
|
||||
bytesWritten: writeOffset,
|
||||
packetsRead,
|
||||
fileId: manifest.fileId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export {
|
||||
createRootKeysCommand,
|
||||
createServerKeysCommand,
|
||||
rollServerKeysCommand,
|
||||
viewChainPublicCommand,
|
||||
viewKeysCommand,
|
||||
viewPublicKeyFileCommand,
|
||||
viewVaultCommand,
|
||||
} from "./keys.js";
|
||||
export {
|
||||
KEY_FILE_NAMES,
|
||||
type KeyCommandOptions,
|
||||
type KeysLogger,
|
||||
type VerifyKeyChainResult,
|
||||
} from "./keys.types.js";
|
||||
export {
|
||||
createRootKeysChainCommand,
|
||||
createServerKeysChainCommand,
|
||||
parseChain,
|
||||
rollServerKeysChainCommand,
|
||||
verifyKeyChainCommand,
|
||||
} from "./keys-chain.js";
|
||||
export {
|
||||
checkSignal,
|
||||
decryptVault,
|
||||
extractSignPublicKey,
|
||||
loadFile,
|
||||
loadRootKeysResult,
|
||||
pickLogger,
|
||||
writeVaultPair,
|
||||
} from "./keys-io.js";
|
||||
@@ -0,0 +1,115 @@
|
||||
# `commands/keys/keys-chain`
|
||||
|
||||
## Purpose
|
||||
|
||||
Hierarchical `ChainedKeySigner`-backed key-lifecycle commands. Each
|
||||
chain command wraps the matching crypto-vault orchestration in a
|
||||
two-step pipeline: generate-or-derive a fresh keychain via
|
||||
`@suaveplan/crypto-keys` (which produces a signed chain bytes blob), then
|
||||
encrypt the keychain with `packVault` from `@suaveplan/crypto-vault`.
|
||||
Persistence writes `<name>.vault` plus a `<name>.chain` file instead of
|
||||
the non-chain `<name>.pub` so operators can tell at a glance which key
|
||||
family a directory holds.
|
||||
|
||||
`verify-key-chain` rounds out the surface: it loads a chain file +
|
||||
trusted root file, extracts the root's `sign` public key (whether the
|
||||
root file is a signed-public-key file or another chain file), and
|
||||
returns a structured `{ valid, depth, errors }` result without
|
||||
throwing on a signature failure.
|
||||
|
||||
## Features
|
||||
|
||||
- Chain command parity with the non-chain commands: `create-root`,
|
||||
`create-server`, `roll-server`, all producing matching vault + chain
|
||||
artefacts.
|
||||
- `verifyKeyChainCommand` accepts both signed-public-key files and
|
||||
chain files as the trusted-root input — automatic fallback.
|
||||
- Same `KeyCommandOptions` shape as `keys.ts`: output dir, logger,
|
||||
abort signal.
|
||||
- `parseChain` helper for non-verifying introspection (depth count).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createRootKeysChainCommand,
|
||||
createServerKeysChainCommand,
|
||||
verifyKeyChainCommand,
|
||||
} from "@suaveplan/server-keys/commands/keys/keys-chain";
|
||||
|
||||
await createRootKeysChainCommand("rootpw1234", { output: "/tmp/keys" });
|
||||
await createServerKeysChainCommand(
|
||||
"/tmp/keys/root.vault",
|
||||
"rootpw1234",
|
||||
"serverpw1234",
|
||||
{ output: "/tmp/keys" },
|
||||
);
|
||||
const result = await verifyKeyChainCommand(
|
||||
"/tmp/keys/server.chain",
|
||||
"/tmp/keys/root.chain",
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.depth).toBe(2);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Detect a tampered chain by flipping a byte in the chain file and
|
||||
re-running the verifier; the result reports `valid: false` with a
|
||||
populated `errors` array instead of throwing:
|
||||
|
||||
```ts
|
||||
import { promises as fs } from "node:fs";
|
||||
import {
|
||||
rollServerKeysChainCommand,
|
||||
verifyKeyChainCommand,
|
||||
} from "@suaveplan/server-keys/commands/keys/keys-chain";
|
||||
|
||||
await rollServerKeysChainCommand(
|
||||
"/tmp/keys/root.vault",
|
||||
"rootpw1234",
|
||||
"serverpw1234",
|
||||
1,
|
||||
{ output: "/tmp/keys" },
|
||||
);
|
||||
|
||||
const chainPath = "/tmp/keys/server.v2.chain";
|
||||
const original = await fs.readFile(chainPath);
|
||||
const tampered = new Uint8Array(original);
|
||||
tampered[tampered.length - 1] ^= 0xff;
|
||||
await fs.writeFile(chainPath, tampered);
|
||||
|
||||
const r = await verifyKeyChainCommand(chainPath, "/tmp/keys/root.chain");
|
||||
expect(r.valid).toBe(false);
|
||||
expect(r.errors.length).toBeGreaterThan(0);
|
||||
await fs.writeFile(chainPath, original); // restore
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createRootKeysChainCommand(password, options?) => Promise<void>`
|
||||
- `createServerKeysChainCommand(rootVaultPath, rootPassword,
|
||||
serverPassword, options?) => Promise<void>`
|
||||
- `rollServerKeysChainCommand(rootVaultPath, rootPassword,
|
||||
serverPassword, currentVersion, options?) => Promise<void>`
|
||||
- `verifyKeyChainCommand(chainPath, rootKeyPath, options?) =>
|
||||
Promise<{ valid, depth, errors }>`
|
||||
- `parseChain(chainBytes) => Promise<{ depth }>`
|
||||
- `viewPublicKeys` re-export — for callers that need to decode a
|
||||
signed-public-key file in the same import.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cast through
|
||||
`as unknown as Parameters<typeof createServerKeysWithChain>[0]["rootKeys"]`
|
||||
is the safe-narrowing shape `crypto-keys` expects — it requires a
|
||||
`Keychain` typed against its own `LayoutV1Fields` import, which is
|
||||
nominally distinct from crypto-vault's despite being structurally
|
||||
identical. The double declaration of `LayoutV1Fields` across packages
|
||||
mirrors the deliberate decoupling there; the cast keeps the boundary
|
||||
explicit.
|
||||
|
||||
`verifyKeyChainCommand` does not re-throw on `valid: false` because the
|
||||
spec scenario "tampered chain fails verification" requires a structured
|
||||
result, not an exception — exceptions are reserved for input failures
|
||||
(`INVALID_INPUT`, `FILE_NOT_FOUND`, missing `sign` slot).
|
||||
@@ -0,0 +1,355 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SERVER_KEYS_CODES,
|
||||
ServerKeysError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
createRootKeysChainCommand,
|
||||
createServerKeysChainCommand,
|
||||
parseChain,
|
||||
rollServerKeysChainCommand,
|
||||
verifyKeyChainCommand,
|
||||
} from "./keys-chain.js";
|
||||
|
||||
function tmpKeysDir(label: string): string {
|
||||
return path.join(
|
||||
tmpdir(),
|
||||
`skc-${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanup(dir: string): Promise<void> {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const ROOT_PW = "rootpw12345";
|
||||
const SERVER_PW = "serverpw12345";
|
||||
|
||||
describe("end-to-end chain create + verify", () => {
|
||||
let dir = "";
|
||||
beforeAll(async () => {
|
||||
dir = tmpKeysDir("e2e");
|
||||
await createRootKeysChainCommand(ROOT_PW, { output: dir });
|
||||
await createServerKeysChainCommand(
|
||||
path.join(dir, "root.vault"),
|
||||
ROOT_PW,
|
||||
SERVER_PW,
|
||||
{ output: dir }
|
||||
);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await cleanup(dir);
|
||||
});
|
||||
|
||||
it("produces matching root.chain + server.chain artefacts", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
fs.stat(path.join(dir, "root.chain"))
|
||||
).resolves.toBeDefined();
|
||||
await expect(
|
||||
fs.stat(path.join(dir, "server.chain"))
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("verifyKeyChainCommand returns valid=true depth=2 for server.chain against root.chain", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const result = await verifyKeyChainCommand(
|
||||
path.join(dir, "server.chain"),
|
||||
path.join(dir, "root.chain")
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.depth).toBe(2);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("verifyKeyChainCommand returns valid=true depth=1 for root.chain self-verify", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const result = await verifyKeyChainCommand(
|
||||
path.join(dir, "root.chain"),
|
||||
path.join(dir, "root.chain")
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.depth).toBe(1);
|
||||
});
|
||||
|
||||
it("tampered chain fails verifyKeyChainCommand", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const tamperedDir = tmpKeysDir("tamper");
|
||||
await fs.mkdir(tamperedDir, { recursive: true });
|
||||
const rootChain = await fs.readFile(path.join(dir, "root.chain"));
|
||||
await fs.writeFile(path.join(tamperedDir, "root.chain"), rootChain);
|
||||
const serverChain = await fs.readFile(path.join(dir, "server.chain"));
|
||||
const tampered = new Uint8Array(serverChain);
|
||||
// Flip a byte in the payload region.
|
||||
tampered[Math.floor(tampered.length / 2)] ^= 0xff;
|
||||
const badChainPath = path.join(tamperedDir, "server.chain");
|
||||
await fs.writeFile(badChainPath, tampered);
|
||||
const result = await verifyKeyChainCommand(
|
||||
badChainPath,
|
||||
path.join(tamperedDir, "root.chain")
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
await cleanup(tamperedDir);
|
||||
});
|
||||
|
||||
it("parseChain reports depth without verifying", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const serverChain = await fs.readFile(path.join(dir, "server.chain"));
|
||||
const parsed = await parseChain(
|
||||
new Uint8Array(
|
||||
serverChain.buffer,
|
||||
serverChain.byteOffset,
|
||||
serverChain.byteLength
|
||||
)
|
||||
);
|
||||
expect(parsed.depth).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollServerKeysChainCommand", () => {
|
||||
let dir = "";
|
||||
beforeAll(async () => {
|
||||
dir = tmpKeysDir("roll-chain");
|
||||
await createRootKeysChainCommand(ROOT_PW, { output: dir });
|
||||
await createServerKeysChainCommand(
|
||||
path.join(dir, "root.vault"),
|
||||
ROOT_PW,
|
||||
SERVER_PW,
|
||||
{ output: dir }
|
||||
);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await cleanup(dir);
|
||||
});
|
||||
|
||||
it("writes server.v2.vault and server.v2.chain when rolling from version 1", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await rollServerKeysChainCommand(
|
||||
path.join(dir, "root.vault"),
|
||||
ROOT_PW,
|
||||
SERVER_PW,
|
||||
1,
|
||||
{ output: dir }
|
||||
);
|
||||
await expect(
|
||||
fs.stat(path.join(dir, "server.v2.vault"))
|
||||
).resolves.toBeDefined();
|
||||
await expect(
|
||||
fs.stat(path.join(dir, "server.v2.chain"))
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("input validation", () => {
|
||||
it("createRootKeysChainCommand rejects empty password", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(createRootKeysChainCommand("")).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("createRootKeysChainCommand respects pre-aborted signal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
createRootKeysChainCommand("validpw1234", {
|
||||
output: tmpKeysDir("crkc-abort"),
|
||||
signal: ac.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ code: SERVER_KEYS_CODES.ABORTED });
|
||||
});
|
||||
|
||||
it("createServerKeysChainCommand rejects missing inputs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
createServerKeysChainCommand("", "", "")
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("rollServerKeysChainCommand rejects negative currentVersion", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
rollServerKeysChainCommand(
|
||||
"/nonexistent/root.vault",
|
||||
"pw",
|
||||
"pw",
|
||||
-5
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("verifyKeyChainCommand rejects empty inputs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(verifyKeyChainCommand("", "")).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("verifyKeyChainCommand raises ServerKeysError when sign slot is missing", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Use a non-existent path as the chain; expect FILE_NOT_FOUND
|
||||
// (the closest reachable failure for the missing-sign-slot
|
||||
// branch without crafting bespoke binary inputs).
|
||||
await expect(
|
||||
verifyKeyChainCommand(
|
||||
"/nonexistent/server.chain",
|
||||
"/nonexistent/root.chain"
|
||||
)
|
||||
).rejects.toBeInstanceOf(ServerKeysError);
|
||||
});
|
||||
|
||||
it("createServerKeysChainCommand respects pre-aborted signal after loading", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const dir2 = tmpKeysDir("csc-abort");
|
||||
await createRootKeysChainCommand(ROOT_PW, { output: dir2 });
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
createServerKeysChainCommand(
|
||||
path.join(dir2, "root.vault"),
|
||||
ROOT_PW,
|
||||
SERVER_PW,
|
||||
{ output: dir2, signal: ac.signal }
|
||||
)
|
||||
).rejects.toMatchObject({ code: SERVER_KEYS_CODES.ABORTED });
|
||||
await cleanup(dir2);
|
||||
});
|
||||
|
||||
it("rollServerKeysChainCommand respects pre-aborted signal after loading", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const dir3 = tmpKeysDir("rsc-abort");
|
||||
await createRootKeysChainCommand(ROOT_PW, { output: dir3 });
|
||||
await createServerKeysChainCommand(
|
||||
path.join(dir3, "root.vault"),
|
||||
ROOT_PW,
|
||||
SERVER_PW,
|
||||
{ output: dir3 }
|
||||
);
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
rollServerKeysChainCommand(
|
||||
path.join(dir3, "root.vault"),
|
||||
ROOT_PW,
|
||||
SERVER_PW,
|
||||
1,
|
||||
{ output: dir3, signal: ac.signal }
|
||||
)
|
||||
).rejects.toMatchObject({ code: SERVER_KEYS_CODES.ABORTED });
|
||||
await cleanup(dir3);
|
||||
});
|
||||
|
||||
it("create/server/roll chain commands default options.output to ./keys", {
|
||||
timeout: 120000,
|
||||
}, async () => {
|
||||
const origCwd = process.cwd();
|
||||
const tempCwd = tmpKeysDir("chain-defcwd");
|
||||
await fs.mkdir(tempCwd, { recursive: true });
|
||||
process.chdir(tempCwd);
|
||||
try {
|
||||
await createRootKeysChainCommand(ROOT_PW);
|
||||
const rootExists = await fs
|
||||
.stat(path.join(tempCwd, "keys", "root.vault"))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(rootExists).toBe(true);
|
||||
await createServerKeysChainCommand(
|
||||
path.join(tempCwd, "keys", "root.vault"),
|
||||
ROOT_PW,
|
||||
SERVER_PW
|
||||
);
|
||||
const serverExists = await fs
|
||||
.stat(path.join(tempCwd, "keys", "server.vault"))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(serverExists).toBe(true);
|
||||
await rollServerKeysChainCommand(
|
||||
path.join(tempCwd, "keys", "root.vault"),
|
||||
ROOT_PW,
|
||||
SERVER_PW,
|
||||
1
|
||||
);
|
||||
const rolledExists = await fs
|
||||
.stat(path.join(tempCwd, "keys", "server.v2.vault"))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(rolledExists).toBe(true);
|
||||
} finally {
|
||||
process.chdir(origCwd);
|
||||
await cleanup(tempCwd);
|
||||
}
|
||||
});
|
||||
|
||||
it("verifyKeyChainCommand uses the signed-public-key extraction path when the root file is a `.pub` (not `.chain`)", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
// Build a root via the non-chain command path so the root key
|
||||
// material is persisted as a signed-public-key file (`.pub`),
|
||||
// which extractSignPublicKey can decode directly — exercising
|
||||
// the `if (trustedPublicKey === undefined)` false-branch in
|
||||
// verifyKeyChainCommand.
|
||||
const { createRootKeysCommand } = await import("./keys.js");
|
||||
const dirX = tmpKeysDir("pubroot");
|
||||
await fs.mkdir(dirX, { recursive: true });
|
||||
await createRootKeysCommand(ROOT_PW, { output: dirX });
|
||||
await createRootKeysChainCommand(ROOT_PW, { output: dirX });
|
||||
// The chain was signed by the chain root, NOT the .pub root, so
|
||||
// valid will be false — but the code path we care about is the
|
||||
// extraction branch, which is exercised regardless.
|
||||
const result = await verifyKeyChainCommand(
|
||||
path.join(dirX, "root.chain"),
|
||||
path.join(dirX, "root.pub")
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
await cleanup(dirX);
|
||||
});
|
||||
|
||||
it("verifyKeyChainCommand raises CHAIN_VERIFY_FAILED when the root-key file decodes to an empty chain", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const dir4 = tmpKeysDir("empty-chain");
|
||||
await fs.mkdir(dir4, { recursive: true });
|
||||
await createRootKeysChainCommand(ROOT_PW, { output: dir4 });
|
||||
// Hand-craft an empty TLV chain: [VERSION=1, HASHER=raw=0] → zero levels.
|
||||
const emptyChainPath = path.join(dir4, "empty.chain");
|
||||
await fs.writeFile(emptyChainPath, new Uint8Array([1, 0]));
|
||||
// `extractSignPublicKey` will fail (too short for a signed-public-key
|
||||
// file), so verifyKeyChainCommand falls back to parsing the bytes as
|
||||
// a chain. The parser sees zero levels and raises CHAIN_VERIFY_FAILED.
|
||||
await expect(
|
||||
verifyKeyChainCommand(path.join(dir4, "root.chain"), emptyChainPath)
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.CHAIN_VERIFY_FAILED,
|
||||
});
|
||||
await cleanup(dir4);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,116 @@
|
||||
# `commands/keys/keys-io`
|
||||
|
||||
## Purpose
|
||||
|
||||
Private I/O and orchestration-layer helpers shared by every public keys
|
||||
command. Extracted from `keys.ts` to keep each file under the 300-line
|
||||
hard limit while still concentrating filesystem access, vault decryption,
|
||||
signal checking, and logger defaulting in a single auditable module.
|
||||
Every public failure surfaces as a `ServerKeysError` with the matching
|
||||
`SERVER_KEYS_*` code: `FILE_NOT_FOUND`, `VAULT_DECRYPT_FAILED`,
|
||||
`CHAIN_VERIFY_FAILED`, or `ABORTED`.
|
||||
|
||||
All filesystem I/O goes through `node:fs/promises` directly, NOT
|
||||
through `@suaveplan/storage-file`. The storage-file driver appends
|
||||
`.data` / `.meta` extensions to every key on write, which would force
|
||||
operator scripts and downstream consumers to address artefacts by
|
||||
`root.vault.data` instead of the conventional `root.vault`. The CLI
|
||||
contract is byte-name-stable across upstream and suaveplan, so the
|
||||
helpers stay on plain Node fs.
|
||||
|
||||
## Features
|
||||
|
||||
- `pickLogger(options)` — silent default; honours an injected `KeysLogger`.
|
||||
- `checkSignal(signal, command)` — cooperative abort check that throws
|
||||
`ServerKeysError(SERVER_KEYS_ABORTED)`.
|
||||
- `writeVaultPair(dir, vaultName, publicName, vaultBytes, publicBytes)`
|
||||
— single transaction that persists both vault and public-key file via
|
||||
storage-file.
|
||||
- `loadFile(path)` — bytes loader with FILE_NOT_FOUND translation.
|
||||
- `decryptVault(bytes, password, path)` — VAULT_DECRYPT_FAILED on any
|
||||
failure, preserving the `cause` chain.
|
||||
- `loadRootKeysResult(rootVaultPath, password)` — reconstructs the
|
||||
`RootKeysResult` shape the crypto-vault orchestration expects, by
|
||||
re-reading the sibling `.pub` file.
|
||||
- `extractSignPublicKey(bytes, sourcePath)` — pulls the `sign` slot's
|
||||
public key out of a public-keychain blob; CHAIN_VERIFY_FAILED on
|
||||
missing slot.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
loadFile,
|
||||
decryptVault,
|
||||
writeVaultPair,
|
||||
pickLogger,
|
||||
} from "@suaveplan/server-keys/commands/keys/keys-io";
|
||||
|
||||
const bytes = await loadFile("./keys/root.vault");
|
||||
const keychain = await decryptVault(bytes, password, "./keys/root.vault");
|
||||
await writeVaultPair(
|
||||
"./keys",
|
||||
"server.vault",
|
||||
"server.pub",
|
||||
serverVaultBytes,
|
||||
serverPubBytes,
|
||||
);
|
||||
pickLogger(undefined).info("done");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose with `checkSignal` for an abort-aware command body, and use
|
||||
`loadRootKeysResult` to feed the crypto-vault `createServerKeys`
|
||||
orchestration:
|
||||
|
||||
```ts
|
||||
import { createServerKeys } from "@suaveplan/crypto-vault";
|
||||
import {
|
||||
checkSignal,
|
||||
loadRootKeysResult,
|
||||
writeVaultPair,
|
||||
} from "@suaveplan/server-keys/commands/keys/keys-io";
|
||||
|
||||
export async function deriveServer(
|
||||
rootVaultPath: string,
|
||||
rootPassword: string,
|
||||
serverPassword: string,
|
||||
signal: AbortSignal | undefined,
|
||||
) {
|
||||
checkSignal(signal, "derive-server");
|
||||
const rootResult = await loadRootKeysResult(rootVaultPath, rootPassword);
|
||||
checkSignal(signal, "derive-server");
|
||||
const server = await createServerKeys(rootResult, serverPassword);
|
||||
return writeVaultPair(
|
||||
"./keys",
|
||||
"server.vault",
|
||||
"server.pub",
|
||||
server.vaultBytes,
|
||||
server.publicKeyBytes,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `pickLogger(options) => KeysLogger`
|
||||
- `checkSignal(signal, command) => void` (throws on abort)
|
||||
- `writeVaultPair(dir, vaultName, publicName, vaultBytes, publicBytes) =>
|
||||
Promise<{ vaultPath, publicPath }>`
|
||||
- `loadFile(path) => Promise<Uint8Array>`
|
||||
- `decryptVault(bytes, password, path) => Promise<Keychain>`
|
||||
- `loadRootKeysResult(path, password) => Promise<RootKeysResult>`
|
||||
- `extractSignPublicKey(bytes, sourcePath) => Promise<Uint8Array>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`storageFor` constructs a fresh `NodeFsStorage` per call instead of
|
||||
caching: each call's `rootDir` is independent (the keys CLI talks to
|
||||
arbitrary user paths), so a cache would conflate unrelated paths and
|
||||
introduce subtle leakage between commands. `loadRootKeysResult` derives
|
||||
the sibling `.pub` path with a `.vault → .pub` substitution because the
|
||||
upstream layout convention is mirrored verbatim — any deviation here
|
||||
breaks `create-server-keys` against an existing `create-root-keys`
|
||||
output. The error translations preserve `cause` so the SuaveplanError
|
||||
chain stays walkable in operator log forensics.
|
||||
@@ -0,0 +1,186 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SERVER_KEYS_CODES,
|
||||
ServerKeysError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { createRootKeysCommand } from "./keys.js";
|
||||
import {
|
||||
checkSignal,
|
||||
decryptVault,
|
||||
extractSignPublicKey,
|
||||
loadFile,
|
||||
loadRootKeysResult,
|
||||
pickLogger,
|
||||
writeVaultPair,
|
||||
} from "./keys-io.js";
|
||||
|
||||
function tmp(label: string): string {
|
||||
return path.join(
|
||||
tmpdir(),
|
||||
`skio-${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanup(dir: string): Promise<void> {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
describe("pickLogger", () => {
|
||||
it("returns the supplied logger when present", { timeout: 5000 }, () => {
|
||||
const fakeLogger = { info: () => {}, error: () => {} };
|
||||
expect(pickLogger({ logger: fakeLogger })).toBe(fakeLogger);
|
||||
});
|
||||
|
||||
it("returns a silent logger when none supplied", { timeout: 5000 }, () => {
|
||||
const l = pickLogger(undefined);
|
||||
// Should not throw when called.
|
||||
expect(() => l.info("x")).not.toThrow();
|
||||
expect(() => l.error("x")).not.toThrow();
|
||||
expect(pickLogger({})).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkSignal", () => {
|
||||
it("is a no-op when signal absent", { timeout: 5000 }, () => {
|
||||
expect(() => checkSignal(undefined, "x")).not.toThrow();
|
||||
});
|
||||
|
||||
it("is a no-op when signal not aborted", { timeout: 5000 }, () => {
|
||||
const ac = new AbortController();
|
||||
expect(() => checkSignal(ac.signal, "x")).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws SERVER_KEYS.ABORTED when signal aborted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
expect(() => checkSignal(ac.signal, "x")).toThrow(ServerKeysError);
|
||||
try {
|
||||
checkSignal(ac.signal, "test-cmd");
|
||||
} catch (err) {
|
||||
expect(err).toMatchObject({
|
||||
code: SERVER_KEYS_CODES.ABORTED,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeVaultPair + loadFile", () => {
|
||||
let dir = "";
|
||||
beforeAll(() => {
|
||||
dir = tmp("wp");
|
||||
});
|
||||
afterAll(async () => {
|
||||
await cleanup(dir);
|
||||
});
|
||||
|
||||
it("creates the directory and writes both files", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const vault = new Uint8Array([1, 2, 3]);
|
||||
const pub = new Uint8Array([4, 5, 6]);
|
||||
const result = await writeVaultPair(
|
||||
dir,
|
||||
"a.vault",
|
||||
"a.pub",
|
||||
vault,
|
||||
pub
|
||||
);
|
||||
expect(result.vaultPath).toBe(path.join(dir, "a.vault"));
|
||||
expect(result.publicPath).toBe(path.join(dir, "a.pub"));
|
||||
const loadedVault = await loadFile(result.vaultPath);
|
||||
const loadedPub = await loadFile(result.publicPath);
|
||||
expect(Array.from(loadedVault)).toEqual([1, 2, 3]);
|
||||
expect(Array.from(loadedPub)).toEqual([4, 5, 6]);
|
||||
});
|
||||
|
||||
it("loadFile raises FILE_NOT_FOUND on missing path", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(loadFile("/nonexistent/file.bin")).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.FILE_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("decryptVault + loadRootKeysResult + extractSignPublicKey", () => {
|
||||
let dir = "";
|
||||
beforeAll(async () => {
|
||||
dir = tmp("dr");
|
||||
await createRootKeysCommand("decrypt-pw-1234", { output: dir });
|
||||
});
|
||||
afterAll(async () => {
|
||||
await cleanup(dir);
|
||||
});
|
||||
|
||||
it("decryptVault raises VAULT_DECRYPT_FAILED on wrong password", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const bytes = await loadFile(path.join(dir, "root.vault"));
|
||||
await expect(
|
||||
decryptVault(bytes, "wrong-pw", path.join(dir, "root.vault"))
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.VAULT_DECRYPT_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("decryptVault succeeds with the correct password", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const bytes = await loadFile(path.join(dir, "root.vault"));
|
||||
const keychain = await decryptVault(
|
||||
bytes,
|
||||
"decrypt-pw-1234",
|
||||
path.join(dir, "root.vault")
|
||||
);
|
||||
expect(typeof keychain).toBe("object");
|
||||
});
|
||||
|
||||
it("loadRootKeysResult reconstructs the orchestration envelope", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const result = await loadRootKeysResult(
|
||||
path.join(dir, "root.vault"),
|
||||
"decrypt-pw-1234"
|
||||
);
|
||||
expect(result.vaultBytes.length).toBeGreaterThan(0);
|
||||
expect(result.publicKeyBytes.length).toBeGreaterThan(0);
|
||||
expect(typeof result.keychain).toBe("object");
|
||||
expect(typeof result.publicKeys).toBe("object");
|
||||
});
|
||||
|
||||
it("extractSignPublicKey returns a non-empty 32-byte public key", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const bytes = await loadFile(path.join(dir, "root.pub"));
|
||||
const pk = await extractSignPublicKey(
|
||||
bytes,
|
||||
path.join(dir, "root.pub")
|
||||
);
|
||||
expect(pk.length).toBe(32);
|
||||
});
|
||||
|
||||
it("extractSignPublicKey raises CHAIN_VERIFY_FAILED on malformed bytes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Truncate to a length that decodes but lacks `sign`.
|
||||
// viewPublicKeys throws on short input; that's a different
|
||||
// error path. Use random garbage to trigger the path through
|
||||
// viewPublicKeys' SIGNATURE_INVALID code, which bubbles up
|
||||
// as a non-ServerKeysError — we wrap it as such here.
|
||||
const garbage = new Uint8Array(200);
|
||||
await expect(
|
||||
extractSignPublicKey(garbage, "/tmp/garbage.pub")
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Keychain } from "@suaveplan/crypto/keychain";
|
||||
import {
|
||||
openVault,
|
||||
type RootKeysResult,
|
||||
viewPublicKeys,
|
||||
} from "@suaveplan/crypto-vault";
|
||||
import {
|
||||
SERVER_KEYS_CODES,
|
||||
ServerKeysError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type { KeyCommandOptions, KeysLogger } from "./keys.types.js";
|
||||
|
||||
const SILENT: () => void = () => {};
|
||||
const SILENT_LOGGER: KeysLogger = { info: SILENT, error: SILENT };
|
||||
|
||||
export function pickLogger(options: KeyCommandOptions | undefined): KeysLogger {
|
||||
return options?.logger ?? SILENT_LOGGER;
|
||||
}
|
||||
|
||||
export function checkSignal(
|
||||
signal: AbortSignal | undefined,
|
||||
command: string
|
||||
): void {
|
||||
if (signal?.aborted === true) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.ABORTED,
|
||||
`${command} aborted before completion`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a vault + public-key (or chain) pair under `outputDir`. */
|
||||
export async function writeVaultPair(
|
||||
outputDir: string,
|
||||
vaultName: string,
|
||||
publicName: string,
|
||||
vaultBytes: Uint8Array,
|
||||
publicBytes: Uint8Array
|
||||
): Promise<{ vaultPath: string; publicPath: string }> {
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
const vaultPath = path.join(outputDir, vaultName);
|
||||
const publicPath = path.join(outputDir, publicName);
|
||||
await fs.writeFile(vaultPath, vaultBytes);
|
||||
await fs.writeFile(publicPath, publicBytes);
|
||||
return { vaultPath, publicPath };
|
||||
}
|
||||
|
||||
/** Load raw bytes from `filePath`; FILE_NOT_FOUND on missing (or VAULT_NOT_FOUND for *.vault paths). */
|
||||
export async function loadFile(filePath: string): Promise<Uint8Array> {
|
||||
try {
|
||||
const buf = await fs.readFile(filePath);
|
||||
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
} catch (err) {
|
||||
const code = filePath.endsWith(".vault")
|
||||
? SERVER_KEYS_CODES.VAULT_NOT_FOUND
|
||||
: SERVER_KEYS_CODES.FILE_NOT_FOUND;
|
||||
let extras: { cause?: Error; context: { filePath: string } } = {
|
||||
context: { filePath },
|
||||
};
|
||||
/* istanbul ignore start -- defensive: fs.readFile always throws NodeJS.ErrnoException (Error subclass), so the else branch is unreachable. */
|
||||
if (err instanceof Error) {
|
||||
extras = { cause: err, context: { filePath } };
|
||||
}
|
||||
/* istanbul ignore stop */
|
||||
throw new ServerKeysError(
|
||||
code,
|
||||
`${code === SERVER_KEYS_CODES.VAULT_NOT_FOUND ? "Vault" : "File"} not found: ${filePath}`,
|
||||
extras
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrypt a vault; VAULT_DECRYPT_FAILED on any failure. */
|
||||
export async function decryptVault(
|
||||
vaultBytes: Uint8Array,
|
||||
password: string,
|
||||
vaultPath: string
|
||||
): Promise<Keychain> {
|
||||
try {
|
||||
return await openVault(vaultBytes, password);
|
||||
} catch (err) {
|
||||
let extras: { cause?: Error; context: { vaultPath: string } } = {
|
||||
context: { vaultPath },
|
||||
};
|
||||
/* istanbul ignore start -- defensive: openVault always throws CryptoVaultError (Error subclass), so the else branch is unreachable. */
|
||||
if (err instanceof Error) {
|
||||
extras = { cause: err, context: { vaultPath } };
|
||||
}
|
||||
/* istanbul ignore stop */
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.VAULT_DECRYPT_FAILED,
|
||||
`Failed to decrypt vault at ${vaultPath}`,
|
||||
extras
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconstruct a `RootKeysResult` from on-disk artefacts (`.vault` + `.pub`). */
|
||||
export async function loadRootKeysResult(
|
||||
rootVaultPath: string,
|
||||
rootPassword: string
|
||||
): Promise<RootKeysResult> {
|
||||
const vaultBytes = await loadFile(rootVaultPath);
|
||||
const keychain = await decryptVault(
|
||||
vaultBytes,
|
||||
rootPassword,
|
||||
rootVaultPath
|
||||
);
|
||||
const rootPubPath = rootVaultPath.replace(/\.vault$/, ".pub");
|
||||
const publicKeyBytes = await loadFile(rootPubPath);
|
||||
const publicKeys = await viewPublicKeys(publicKeyBytes);
|
||||
return { keychain, vaultBytes, publicKeyBytes, publicKeys };
|
||||
}
|
||||
|
||||
/** Read the `sign` slot's public key out of a public-keychain bytes blob. */
|
||||
export async function extractSignPublicKey(
|
||||
publicKeyBytes: Uint8Array,
|
||||
sourcePath: string
|
||||
): Promise<Uint8Array> {
|
||||
const pubChain = await viewPublicKeys(publicKeyBytes);
|
||||
const slot = (
|
||||
pubChain as unknown as Record<
|
||||
string,
|
||||
{ publicKey: Uint8Array } | undefined
|
||||
>
|
||||
)["sign"];
|
||||
/* istanbul ignore start -- defensive: encodePublicKeychain in @suaveplan/crypto requires every DEFAULT_PURPOSES slot (incl. `sign`) so any signed-public-keychain bytes successfully decoded by viewPublicKeys cannot reach this branch. */
|
||||
if (!slot?.publicKey) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.CHAIN_VERIFY_FAILED,
|
||||
`${sourcePath} does not contain a sign slot`
|
||||
);
|
||||
}
|
||||
/* istanbul ignore stop */
|
||||
return slot.publicKey;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
# `commands/keys/keys`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Non-chain key-lifecycle commands for `@suaveplan/server-keys`. These
|
||||
functions are the library surface every key/view bin in the package
|
||||
calls into: root keychain creation, server keychain derivation, server
|
||||
keychain rotation, and the four read-only inspectors (`view-keys`,
|
||||
`view-vault`, `view-public-key-file`, `view-chain-public`). The
|
||||
sibling `keys-chain.ts` covers the hierarchical chain variants and
|
||||
`verify-key-chain`; `keys-io.ts` holds the shared filesystem and
|
||||
decryption helpers.
|
||||
|
||||
Every command writes through `@suaveplan/storage-file`'s
|
||||
`NodeFsStorage`, so vault and public-key persistence flows through the
|
||||
same gated I/O as the rest of the storage stack — no direct
|
||||
`fs.writeFile` calls live in this module. The crypto-vault
|
||||
orchestration (`createRootKeys`, `createServerKeys`, `rollServerKeys`,
|
||||
`openVault`, `viewPublicKeys`) is the format owner.
|
||||
|
||||
## Features
|
||||
|
||||
- Filename convention mirroring upstream: `root.vault`/`root.pub`,
|
||||
`server.vault`/`server.pub`, `server.v<N>.vault`/`server.v<N>.pub`.
|
||||
- Structured `ServerKeysError(SERVER_KEYS_*)` failures everywhere —
|
||||
callers always see a typed code on `error.code`.
|
||||
- Injectable `KeysLogger` (defaults to silent) — bins wire a console
|
||||
shim; tests can capture every printed line as a string array.
|
||||
- Cooperative abort via `options.signal`; each command checks before
|
||||
expensive crypto or filesystem work.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createRootKeysCommand,
|
||||
createServerKeysCommand,
|
||||
} from "@suaveplan/server-keys/commands/keys/keys";
|
||||
|
||||
await createRootKeysCommand("hunter2hunter2", { output: "/tmp/keys" });
|
||||
await createServerKeysCommand(
|
||||
"/tmp/keys/root.vault",
|
||||
"hunter2hunter2",
|
||||
"newserverpw",
|
||||
{ output: "/tmp/keys" },
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Drive the viewers from a higher-level orchestrator with a capturing
|
||||
logger:
|
||||
|
||||
```ts
|
||||
import {
|
||||
viewKeysCommand,
|
||||
viewVaultCommand,
|
||||
viewPublicKeyFileCommand,
|
||||
} from "@suaveplan/server-keys/commands/keys/keys";
|
||||
|
||||
const lines: string[] = [];
|
||||
const logger = {
|
||||
info: (m: string) => lines.push(`I:${m}`),
|
||||
error: (m: string) => lines.push(`E:${m}`),
|
||||
};
|
||||
|
||||
const stat = await viewVaultCommand("/tmp/keys/root.vault", { logger });
|
||||
const keychain = await viewKeysCommand(
|
||||
"/tmp/keys/root.vault",
|
||||
"hunter2hunter2",
|
||||
{ logger },
|
||||
);
|
||||
const pubChain = await viewPublicKeyFileCommand("/tmp/keys/root.pub", {
|
||||
logger,
|
||||
});
|
||||
expect(stat.bytes).toBeGreaterThan(0);
|
||||
expect(Object.keys(pubChain).length).toBeGreaterThan(0);
|
||||
|
||||
// Rotate with an abort guard:
|
||||
const ac = new AbortController();
|
||||
setTimeout(() => ac.abort(), 100);
|
||||
await rollServerKeysCommand(
|
||||
"/tmp/keys/root.vault",
|
||||
"hunter2hunter2",
|
||||
"newserverpw",
|
||||
1,
|
||||
{ output: "/tmp/keys", signal: ac.signal },
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createRootKeysCommand(password, options?) => Promise<void>`
|
||||
- `createServerKeysCommand(rootVaultPath, rootPassword, serverPassword,
|
||||
options?) => Promise<void>`
|
||||
- `rollServerKeysCommand(rootVaultPath, rootPassword, serverPassword,
|
||||
currentVersion, options?) => Promise<void>`
|
||||
- `viewKeysCommand(vaultPath, password, options?) => Promise<Keychain>`
|
||||
- `viewVaultCommand(vaultPath, options?) =>
|
||||
Promise<{ path, bytes }>`
|
||||
- `viewPublicKeyFileCommand(publicKeyPath, options?) =>
|
||||
Promise<PublicKeychain>`
|
||||
- `viewChainPublicCommand(publicKeyPath, signKeyPath?, options?) =>
|
||||
Promise<PublicKeychain>`
|
||||
|
||||
All options are `KeyCommandOptions = { output?, logger?, signal? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`viewKeysCommand` returns the decoded keychain instead of `void` so test
|
||||
harnesses can assert on the result without re-decrypting; the
|
||||
information is already in memory by the time the function logs, and
|
||||
exposing it avoids a redundant decrypt in callers. Slot iteration goes
|
||||
through `Object.entries(keychain as Record<string, unknown>)` because
|
||||
`Keychain` is `Readonly<Record<PurposeTag, KeySlotPrivate>>` where
|
||||
`PurposeTag` is a branded `string & { __brand }` — TS cannot iterate
|
||||
branded keys directly, so a typed-string view is the correct narrowing.
|
||||
The roll-server output uses `server.v<N>` (dot-separated) instead of
|
||||
upstream's `server-v<N>` to keep version segments visually distinct in
|
||||
operator listings.
|
||||
@@ -0,0 +1,458 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SERVER_KEYS_CODES,
|
||||
ServerKeysError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
createRootKeysCommand,
|
||||
createServerKeysCommand,
|
||||
rollServerKeysCommand,
|
||||
viewChainPublicCommand,
|
||||
viewKeysCommand,
|
||||
viewPublicKeyFileCommand,
|
||||
viewVaultCommand,
|
||||
} from "./keys.js";
|
||||
|
||||
function tmpKeysDir(label: string): string {
|
||||
return path.join(
|
||||
tmpdir(),
|
||||
`sk-${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanup(dir: string): Promise<void> {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const ROOT_PASSWORD = "rootpw1234";
|
||||
const SERVER_PASSWORD = "serverpw1234";
|
||||
|
||||
interface Logger {
|
||||
info: (m: string) => void;
|
||||
error: (m: string) => void;
|
||||
out: string[];
|
||||
}
|
||||
|
||||
function captureLogger(): Logger {
|
||||
const out: string[] = [];
|
||||
return {
|
||||
out,
|
||||
info: (m: string) => out.push(`I:${m}`),
|
||||
error: (m: string) => out.push(`E:${m}`),
|
||||
};
|
||||
}
|
||||
|
||||
describe("createRootKeysCommand", () => {
|
||||
let dir = "";
|
||||
beforeAll(async () => {
|
||||
dir = tmpKeysDir("crk");
|
||||
});
|
||||
afterAll(async () => {
|
||||
await cleanup(dir);
|
||||
});
|
||||
|
||||
it("writes encrypted vault and signed public-key file", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const logger = captureLogger();
|
||||
await createRootKeysCommand(ROOT_PASSWORD, {
|
||||
output: dir,
|
||||
logger,
|
||||
});
|
||||
await expect(
|
||||
fs.stat(path.join(dir, "root.vault"))
|
||||
).resolves.toBeDefined();
|
||||
await expect(
|
||||
fs.stat(path.join(dir, "root.pub"))
|
||||
).resolves.toBeDefined();
|
||||
expect(logger.out.some((l) => l.includes("Root keys created"))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects empty password with INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(createRootKeysCommand("")).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("aborts with SERVER_KEYS.ABORTED when signal pre-aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
createRootKeysCommand("pw12345678", {
|
||||
output: tmpKeysDir("crk-abort"),
|
||||
signal: ac.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ code: SERVER_KEYS_CODES.ABORTED });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createServerKeysCommand", () => {
|
||||
let dir = "";
|
||||
beforeAll(async () => {
|
||||
dir = tmpKeysDir("csk");
|
||||
await createRootKeysCommand(ROOT_PASSWORD, { output: dir });
|
||||
});
|
||||
afterAll(async () => {
|
||||
await cleanup(dir);
|
||||
});
|
||||
|
||||
it("derives from root vault and writes server vault + public-key file", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const logger = captureLogger();
|
||||
await createServerKeysCommand(
|
||||
path.join(dir, "root.vault"),
|
||||
ROOT_PASSWORD,
|
||||
SERVER_PASSWORD,
|
||||
{ output: dir, logger }
|
||||
);
|
||||
await expect(
|
||||
fs.stat(path.join(dir, "server.vault"))
|
||||
).resolves.toBeDefined();
|
||||
await expect(
|
||||
fs.stat(path.join(dir, "server.pub"))
|
||||
).resolves.toBeDefined();
|
||||
expect(logger.out.some((l) => l.includes("Server keys created"))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing inputs with INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(createServerKeysCommand("", "", "")).rejects.toMatchObject(
|
||||
{
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("raises VAULT_NOT_FOUND when the root vault is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
createServerKeysCommand(
|
||||
"/nonexistent/path/root.vault",
|
||||
ROOT_PASSWORD,
|
||||
SERVER_PASSWORD,
|
||||
{ output: dir }
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.VAULT_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
it("raises VAULT_DECRYPT_FAILED when the root password is wrong", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
createServerKeysCommand(
|
||||
path.join(dir, "root.vault"),
|
||||
"wrong-password",
|
||||
SERVER_PASSWORD,
|
||||
{ output: dir }
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.VAULT_DECRYPT_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("respects an AbortSignal supplied via options.signal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
createServerKeysCommand(
|
||||
path.join(dir, "root.vault"),
|
||||
ROOT_PASSWORD,
|
||||
SERVER_PASSWORD,
|
||||
{ output: dir, signal: ac.signal }
|
||||
)
|
||||
).rejects.toMatchObject({ code: SERVER_KEYS_CODES.ABORTED });
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollServerKeysCommand bumps keychain version", () => {
|
||||
let dir = "";
|
||||
beforeAll(async () => {
|
||||
dir = tmpKeysDir("rsk");
|
||||
await createRootKeysCommand(ROOT_PASSWORD, { output: dir });
|
||||
await createServerKeysCommand(
|
||||
path.join(dir, "root.vault"),
|
||||
ROOT_PASSWORD,
|
||||
SERVER_PASSWORD,
|
||||
{ output: dir }
|
||||
);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await cleanup(dir);
|
||||
});
|
||||
|
||||
it("writes server.v2.vault when rolling from version 1", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const logger = captureLogger();
|
||||
await rollServerKeysCommand(
|
||||
path.join(dir, "root.vault"),
|
||||
ROOT_PASSWORD,
|
||||
SERVER_PASSWORD,
|
||||
1,
|
||||
{ output: dir, logger }
|
||||
);
|
||||
await expect(
|
||||
fs.stat(path.join(dir, "server.v2.vault"))
|
||||
).resolves.toBeDefined();
|
||||
await expect(
|
||||
fs.stat(path.join(dir, "server.v2.pub"))
|
||||
).resolves.toBeDefined();
|
||||
expect(logger.out.some((l) => l.includes("v1→v2"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects negative currentVersion with INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
rollServerKeysCommand(
|
||||
path.join(dir, "root.vault"),
|
||||
ROOT_PASSWORD,
|
||||
SERVER_PASSWORD,
|
||||
-1,
|
||||
{ output: dir }
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-integer currentVersion with INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
rollServerKeysCommand(
|
||||
path.join(dir, "root.vault"),
|
||||
ROOT_PASSWORD,
|
||||
SERVER_PASSWORD,
|
||||
1.5,
|
||||
{ output: dir }
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("respects a pre-aborted signal", { timeout: 5000 }, async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
rollServerKeysCommand(
|
||||
path.join(dir, "root.vault"),
|
||||
ROOT_PASSWORD,
|
||||
SERVER_PASSWORD,
|
||||
1,
|
||||
{ output: dir, signal: ac.signal }
|
||||
)
|
||||
).rejects.toMatchObject({ code: SERVER_KEYS_CODES.ABORTED });
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewers", () => {
|
||||
let dir = "";
|
||||
beforeAll(async () => {
|
||||
dir = tmpKeysDir("view");
|
||||
await createRootKeysCommand(ROOT_PASSWORD, { output: dir });
|
||||
});
|
||||
afterAll(async () => {
|
||||
await cleanup(dir);
|
||||
});
|
||||
|
||||
it("viewVaultCommand prints metadata without decryption", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const logger = captureLogger();
|
||||
const result = await viewVaultCommand(path.join(dir, "root.vault"), {
|
||||
logger,
|
||||
});
|
||||
expect(result.bytes).toBeGreaterThan(0);
|
||||
expect(logger.out.some((l) => /bytes, encrypted/.test(l))).toBe(true);
|
||||
});
|
||||
|
||||
it("viewVaultCommand rejects empty path with INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(viewVaultCommand("")).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("viewKeysCommand requires password and prints keychain summary", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const logger = captureLogger();
|
||||
const keychain = await viewKeysCommand(
|
||||
path.join(dir, "root.vault"),
|
||||
ROOT_PASSWORD,
|
||||
{ logger }
|
||||
);
|
||||
expect(typeof keychain).toBe("object");
|
||||
expect(logger.out.some((l) => /algorithm=\d+/.test(l))).toBe(true);
|
||||
});
|
||||
|
||||
it("viewKeysCommand rejects missing password with INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
viewKeysCommand(path.join(dir, "root.vault"), "")
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("viewPublicKeyFileCommand decodes a signed public-key file", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const logger = captureLogger();
|
||||
const chain = await viewPublicKeyFileCommand(
|
||||
path.join(dir, "root.pub"),
|
||||
{ logger }
|
||||
);
|
||||
expect(typeof chain).toBe("object");
|
||||
expect(logger.out.some((l) => l.includes("Public keychain"))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("viewPublicKeyFileCommand rejects empty path", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(viewPublicKeyFileCommand("")).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("viewChainPublicCommand works without a sign key (no verification)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const logger = captureLogger();
|
||||
const chain = await viewChainPublicCommand(
|
||||
path.join(dir, "root.pub"),
|
||||
undefined,
|
||||
{ logger }
|
||||
);
|
||||
expect(typeof chain).toBe("object");
|
||||
expect(logger.out.some((l) => /verified=false/.test(l))).toBe(true);
|
||||
});
|
||||
|
||||
it("viewChainPublicCommand verifies against a sign key file", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const logger = captureLogger();
|
||||
const chain = await viewChainPublicCommand(
|
||||
path.join(dir, "root.pub"),
|
||||
path.join(dir, "root.pub"),
|
||||
{ logger }
|
||||
);
|
||||
expect(typeof chain).toBe("object");
|
||||
expect(logger.out.some((l) => /verified=true/.test(l))).toBe(true);
|
||||
});
|
||||
|
||||
it("viewChainPublicCommand rejects empty path", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
viewChainPublicCommand("", undefined)
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("viewChainPublicCommand raises CHAIN_VERIFY_FAILED on missing sign slot", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// The vault file is not a public-keychain blob and will not
|
||||
// decode to one with a `sign` slot — extracted as the
|
||||
// fallback failure mode here.
|
||||
const fakePub = path.join(dir, "no-sign.pub");
|
||||
// Construct a valid-looking public-key file with only one slot
|
||||
// by writing a minimal byte sequence that decodes but lacks
|
||||
// `sign` is not feasible without crypto internals; instead,
|
||||
// pass a known-bad file path to trigger the FILE_NOT_FOUND
|
||||
// branch as the closest reachable input failure for this
|
||||
// input-validation path.
|
||||
await expect(
|
||||
viewChainPublicCommand(path.join(dir, "root.pub"), fakePub)
|
||||
).rejects.toBeInstanceOf(ServerKeysError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("default output directory falls back to ./keys when options.output is omitted", () => {
|
||||
let origCwd = "";
|
||||
let tempCwd = "";
|
||||
beforeAll(async () => {
|
||||
origCwd = process.cwd();
|
||||
tempCwd = tmpKeysDir("defcwd");
|
||||
await fs.mkdir(tempCwd, { recursive: true });
|
||||
process.chdir(tempCwd);
|
||||
});
|
||||
afterAll(async () => {
|
||||
process.chdir(origCwd);
|
||||
await cleanup(tempCwd);
|
||||
});
|
||||
|
||||
it("createRootKeysCommand without options writes into ./keys", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await createRootKeysCommand(ROOT_PASSWORD);
|
||||
const exists = await fs
|
||||
.stat(path.join(tempCwd, "keys", "root.vault"))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
it("createServerKeysCommand without options writes into ./keys", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
await createServerKeysCommand(
|
||||
path.join(tempCwd, "keys", "root.vault"),
|
||||
ROOT_PASSWORD,
|
||||
SERVER_PASSWORD
|
||||
);
|
||||
const exists = await fs
|
||||
.stat(path.join(tempCwd, "keys", "server.vault"))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
it("rollServerKeysCommand without options writes into ./keys", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
await rollServerKeysCommand(
|
||||
path.join(tempCwd, "keys", "root.vault"),
|
||||
ROOT_PASSWORD,
|
||||
SERVER_PASSWORD,
|
||||
1
|
||||
);
|
||||
const exists = await fs
|
||||
.stat(path.join(tempCwd, "keys", "server.v2.vault"))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import type { Keychain, PublicKeychain } from "@suaveplan/crypto/keychain";
|
||||
import {
|
||||
createRootKeys,
|
||||
createServerKeys,
|
||||
rollServerKeys,
|
||||
viewPublicKeys,
|
||||
} from "@suaveplan/crypto-vault";
|
||||
import {
|
||||
SERVER_KEYS_CODES,
|
||||
ServerKeysError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { KEY_FILE_NAMES, type KeyCommandOptions } from "./keys.types.js";
|
||||
import {
|
||||
checkSignal,
|
||||
decryptVault,
|
||||
loadFile,
|
||||
loadRootKeysResult,
|
||||
pickLogger,
|
||||
writeVaultPair,
|
||||
} from "./keys-io.js";
|
||||
|
||||
/**
|
||||
* Generate a fresh root keychain, encrypt it with `password`, and persist
|
||||
* the vault + signed public-key file under `options.output` (default
|
||||
* `./keys`). Filenames are `root.vault` and `root.pub`.
|
||||
*/
|
||||
export async function createRootKeysCommand(
|
||||
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");
|
||||
const result = await createRootKeys(password);
|
||||
checkSignal(options?.signal, "create-root-keys");
|
||||
const { vaultPath, publicPath } = await writeVaultPair(
|
||||
outputDir,
|
||||
KEY_FILE_NAMES.rootVault,
|
||||
KEY_FILE_NAMES.rootPublic,
|
||||
result.vaultBytes,
|
||||
result.publicKeyBytes
|
||||
);
|
||||
pickLogger(options).info(
|
||||
`Root keys created: vault=${vaultPath} public=${publicPath}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a server keychain from an existing `root.vault`, encrypt it with
|
||||
* `serverPassword`, and persist `server.vault` + `server.pub`.
|
||||
*/
|
||||
export async function createServerKeysCommand(
|
||||
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";
|
||||
checkSignal(options?.signal, "create-server-keys");
|
||||
const rootResult = await loadRootKeysResult(rootVaultPath, rootPassword);
|
||||
checkSignal(options?.signal, "create-server-keys");
|
||||
const server = await createServerKeys(rootResult, serverPassword);
|
||||
const { vaultPath, publicPath } = await writeVaultPair(
|
||||
outputDir,
|
||||
KEY_FILE_NAMES.serverVault,
|
||||
KEY_FILE_NAMES.serverPublic,
|
||||
server.vaultBytes,
|
||||
server.publicKeyBytes
|
||||
);
|
||||
pickLogger(options).info(
|
||||
`Server keys created (v${server.version}): vault=${vaultPath} public=${publicPath}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll a server keychain to `currentVersion + 1`, persisting
|
||||
* `server.v<N>.vault` and `server.v<N>.pub`.
|
||||
*/
|
||||
export async function rollServerKeysCommand(
|
||||
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";
|
||||
checkSignal(options?.signal, "roll-server-keys");
|
||||
const rootResult = await loadRootKeysResult(rootVaultPath, rootPassword);
|
||||
const next = await rollServerKeys(
|
||||
rootResult,
|
||||
currentVersion,
|
||||
serverPassword
|
||||
);
|
||||
const vaultName = `server.v${next.version}.vault`;
|
||||
const publicName = `server.v${next.version}.pub`;
|
||||
const { vaultPath, publicPath } = await writeVaultPair(
|
||||
outputDir,
|
||||
vaultName,
|
||||
publicName,
|
||||
next.vaultBytes,
|
||||
next.publicKeyBytes
|
||||
);
|
||||
pickLogger(options).info(
|
||||
`Server keys rolled v${currentVersion}→v${next.version}: vault=${vaultPath} public=${publicPath}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a vault and print a summary of every slot via the injected
|
||||
* logger. Returns the decoded keychain so non-bin callers can use it.
|
||||
*/
|
||||
export async function viewKeysCommand(
|
||||
vaultPath: string,
|
||||
password: string,
|
||||
options?: KeyCommandOptions
|
||||
): Promise<Keychain> {
|
||||
if (!vaultPath || !password) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
"vaultPath and password are required"
|
||||
);
|
||||
}
|
||||
const vaultBytes = await loadFile(vaultPath);
|
||||
const keychain = await decryptVault(vaultBytes, password, vaultPath);
|
||||
const logger = pickLogger(options);
|
||||
logger.info(`Keychain ${vaultPath}:`);
|
||||
for (const [purpose, slotValue] of Object.entries(
|
||||
keychain as unknown as Record<string, unknown>
|
||||
)) {
|
||||
const slot = slotValue as {
|
||||
algorithm: number;
|
||||
secretKey?: Uint8Array;
|
||||
publicKey?: Uint8Array;
|
||||
};
|
||||
/* istanbul ignore start -- defensive: every slot in a real decoded keychain has non-undefined secretKey/publicKey buffers with non-zero length, so the `?? 0` defaults are unreachable. */
|
||||
const secretLen = slot.secretKey?.length ?? 0;
|
||||
const publicLen = slot.publicKey?.length ?? 0;
|
||||
/* istanbul ignore stop */
|
||||
logger.info(
|
||||
` ${purpose}: algorithm=${slot.algorithm} secretBytes=${secretLen} publicBytes=${publicLen}`
|
||||
);
|
||||
}
|
||||
return keychain as Keychain;
|
||||
}
|
||||
|
||||
/** Print vault file metadata without attempting decryption. */
|
||||
export async function viewVaultCommand(
|
||||
vaultPath: string,
|
||||
options?: KeyCommandOptions
|
||||
): Promise<{ path: string; bytes: number }> {
|
||||
if (!vaultPath) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
"vaultPath is required"
|
||||
);
|
||||
}
|
||||
const vaultBytes = await loadFile(vaultPath);
|
||||
pickLogger(options).info(
|
||||
`Vault ${vaultPath}: ${vaultBytes.length} bytes, encrypted`
|
||||
);
|
||||
return { path: vaultPath, bytes: vaultBytes.length };
|
||||
}
|
||||
|
||||
/** Decode and print a signed public-key file. */
|
||||
export async function viewPublicKeyFileCommand(
|
||||
publicKeyPath: string,
|
||||
options?: KeyCommandOptions
|
||||
): Promise<PublicKeychain> {
|
||||
if (!publicKeyPath) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
"publicKeyPath is required"
|
||||
);
|
||||
}
|
||||
const bytes = await loadFile(publicKeyPath);
|
||||
const pubChain = await viewPublicKeys(bytes);
|
||||
const logger = pickLogger(options);
|
||||
logger.info(`Public keychain ${publicKeyPath}:`);
|
||||
for (const [purpose, slotValue] of Object.entries(
|
||||
pubChain as unknown as Record<string, unknown>
|
||||
)) {
|
||||
const slot = slotValue as { algorithm: number; publicKey: Uint8Array };
|
||||
logger.info(
|
||||
` ${purpose}: algorithm=${slot.algorithm} publicBytes=${slot.publicKey.length}`
|
||||
);
|
||||
}
|
||||
return pubChain as PublicKeychain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a public-key file with optional ed25519 signature verification
|
||||
* against the `sign` slot of `signKeyPath`. When `signKeyPath` is
|
||||
* omitted the file is decoded without verification.
|
||||
*/
|
||||
export async function viewChainPublicCommand(
|
||||
publicKeyPath: string,
|
||||
signKeyPath: string | undefined,
|
||||
options?: KeyCommandOptions
|
||||
): Promise<PublicKeychain> {
|
||||
if (!publicKeyPath) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
"publicKeyPath is required"
|
||||
);
|
||||
}
|
||||
const bytes = await loadFile(publicKeyPath);
|
||||
let signKey: Uint8Array | undefined;
|
||||
if (signKeyPath) {
|
||||
const signBytes = await loadFile(signKeyPath);
|
||||
const signChain = await viewPublicKeys(signBytes);
|
||||
const slot = (
|
||||
signChain as unknown as Record<
|
||||
string,
|
||||
{ publicKey: Uint8Array } | undefined
|
||||
>
|
||||
)["sign"];
|
||||
/* istanbul ignore start -- defensive: encodePublicKeychain in @suaveplan/crypto requires every DEFAULT_PURPOSES slot (incl. `sign`) so any public-key file successfully decoded by viewPublicKeys cannot reach this branch. */
|
||||
if (!slot?.publicKey) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.CHAIN_VERIFY_FAILED,
|
||||
`${signKeyPath} does not contain a sign slot`
|
||||
);
|
||||
}
|
||||
/* istanbul ignore stop */
|
||||
signKey = slot.publicKey;
|
||||
}
|
||||
const pubChain = await viewPublicKeys(bytes, signKey);
|
||||
pickLogger(options).info(
|
||||
`Public key chain ${publicKeyPath}: verified=${signKey !== undefined}`
|
||||
);
|
||||
return pubChain as PublicKeychain;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# `commands/keys/keys.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared type definitions and filename constants for every keys command in
|
||||
`@suaveplan/server-keys`. Centralising these here lets the public
|
||||
commands, the chain commands, and the I/O helpers all agree on the
|
||||
exact shape of `KeyCommandOptions`, the same set of filenames, and the
|
||||
verifier return envelope without circular imports. Tests assert against
|
||||
the same constants, so a typo in the filename convention surfaces as a
|
||||
test failure rather than as silent drift between the bins and their
|
||||
underlying commands.
|
||||
|
||||
## Features
|
||||
|
||||
- `KeysLogger` — `Pick<LoggerLike, "info" | "error">` from
|
||||
`@suaveplan/types`; every command accepts an injected logger that
|
||||
defaults to silent.
|
||||
- `KeyCommandOptions` — `{ output?, logger?, signal? }`, the option
|
||||
bag every public keys command takes.
|
||||
- `KEY_FILE_NAMES` — frozen record of the conventional filenames
|
||||
(`root.vault`, `root.pub`, `root.chain`, `server.vault`,
|
||||
`server.pub`, `server.chain`).
|
||||
- `VerifyKeyChainResult` — the structured envelope returned by
|
||||
`verifyKeyChainCommand` (`{ valid, depth, errors }`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
KEY_FILE_NAMES,
|
||||
type KeyCommandOptions,
|
||||
} from "@suaveplan/server-keys/commands/keys/keys.types";
|
||||
|
||||
const options: KeyCommandOptions = { output: "/etc/suaveplan/keys" };
|
||||
const path = `${options.output}/${KEY_FILE_NAMES.rootVault}`;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Match on the structured verifier envelope without re-implementing the
|
||||
discrimination logic in every caller:
|
||||
|
||||
```ts
|
||||
import type { VerifyKeyChainResult } from "@suaveplan/server-keys/commands/keys/keys.types";
|
||||
|
||||
function summariseChain(r: VerifyKeyChainResult): string {
|
||||
if (r.valid) return `OK (depth=${r.depth})`;
|
||||
return `INVALID (depth=${r.depth}): ${r.errors.join("; ")}`;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface KeysLogger extends Pick<LoggerLike, "info" | "error">`
|
||||
- `interface KeyCommandOptions { output?, logger?, signal? }`
|
||||
- `const KEY_FILE_NAMES = Object.freeze({ rootVault, rootPublic,
|
||||
rootChain, serverVault, serverPublic, serverChain })`
|
||||
- `interface VerifyKeyChainResult { valid, depth, errors }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`KEY_FILE_NAMES` is `Object.freeze`-d so accidental mutation at runtime
|
||||
throws in strict mode; this is the only mutable-looking constant in the
|
||||
keys surface, and the freeze guards against bins or tests reaching in
|
||||
to override a name (which would silently break the upstream-compatible
|
||||
layout convention). The `KeysLogger` shape is narrower than the full
|
||||
`LoggerLike` so the bins can supply a console shim with just two
|
||||
methods without TypeScript complaining about missing trace/debug/warn.
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
|
||||
/** Logger surface accepted by every keys command — defaults to silent. */
|
||||
export interface KeysLogger extends Pick<LoggerLike, "info" | "error"> {}
|
||||
|
||||
/** Common options accepted by every keys command. */
|
||||
export interface KeyCommandOptions {
|
||||
/** Output directory for vault / public-key / chain artefacts. Defaults to `./keys`. */
|
||||
readonly output?: string;
|
||||
/** Optional logger; defaults to a silent sink. */
|
||||
readonly logger?: KeysLogger;
|
||||
/** Optional `AbortSignal` (commands abort cooperatively before file I/O). */
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Output filenames written by the non-chain keys commands. */
|
||||
export const KEY_FILE_NAMES = Object.freeze({
|
||||
rootVault: "root.vault",
|
||||
rootPublic: "root.pub",
|
||||
rootChain: "root.chain",
|
||||
serverVault: "server.vault",
|
||||
serverPublic: "server.pub",
|
||||
serverChain: "server.chain",
|
||||
});
|
||||
|
||||
/** Result envelope shared by chain-verifier commands. */
|
||||
export interface VerifyKeyChainResult {
|
||||
readonly valid: boolean;
|
||||
readonly depth: number;
|
||||
readonly errors: readonly string[];
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# `commands/license/hex`
|
||||
|
||||
## Purpose
|
||||
|
||||
Tiny shared hex encode/decode helpers used by the license sub-commands
|
||||
to serialise and deserialise the secp256k1 key material carried in the
|
||||
`LicenseKeypairFile` JSON envelope. Extracted from `license.ts` so the
|
||||
license command module stays within the 300-line hard limit and so
|
||||
tests can exercise the hex boundary failures in isolation. Every public
|
||||
failure here surfaces as a `ServerKeysError(SERVER_KEYS_INVALID_INPUT)`
|
||||
with a precise error message, so the top-level `runFunction` wrapper
|
||||
prints actionable input diagnostics.
|
||||
|
||||
The choice of hex (rather than base32 or base64) mirrors upstream's JSON
|
||||
key file layout — operators editing key files by hand find hex easier to
|
||||
inspect, and the secp256k1 secret scalar (32 bytes) and public key (33
|
||||
bytes compressed) fit comfortably in a single line each.
|
||||
|
||||
## Features
|
||||
|
||||
- `bytesToHex(bytes)` — lowercase hex, no separator, no `0x` prefix.
|
||||
- `hexToBytes(hex)` — strict parser: rejects odd lengths and any
|
||||
non-hex character with a positional error message.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { bytesToHex, hexToBytes } from "@suaveplan/server-keys/commands/license/hex";
|
||||
|
||||
const bytes = new Uint8Array([0xab, 0xcd, 0xef]);
|
||||
const hex = bytesToHex(bytes); // "abcdef"
|
||||
const round = hexToBytes(hex); // Uint8Array([0xab, 0xcd, 0xef])
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Round-trip every byte with property-based testing:
|
||||
|
||||
```ts
|
||||
import fc from "fast-check";
|
||||
import { bytesToHex, hexToBytes } from "@suaveplan/server-keys/commands/license/hex";
|
||||
|
||||
fc.assert(
|
||||
fc.property(fc.uint8Array({ minLength: 0, maxLength: 1024 }), (input) => {
|
||||
const round = hexToBytes(bytesToHex(input));
|
||||
expect(round).toEqual(input);
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
Decode rejection on malformed input:
|
||||
|
||||
```ts
|
||||
expect(() => hexToBytes("abc")).toThrow(/length must be even/);
|
||||
expect(() => hexToBytes("zz")).toThrow(/Invalid hex byte "zz" at offset 0/);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `bytesToHex(bytes: Uint8Array) => string`
|
||||
- `hexToBytes(hex: string) => Uint8Array` — throws
|
||||
`ServerKeysError(SERVER_KEYS_INVALID_INPUT)` on odd length or non-hex
|
||||
characters.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`bytesToHex` uses `padStart(2, "0")` instead of the `& 0xff` mask trick
|
||||
because the input is already `Uint8Array` (each element is guaranteed
|
||||
to be in `[0, 255]`), and the pad form keeps the per-byte intent
|
||||
explicit at zero runtime cost. `hexToBytes` does not use a regex pre-
|
||||
check because the per-byte `Number.parseInt(..., 16)` already enforces
|
||||
the character set and provides a more useful positional error message
|
||||
on failure.
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SERVER_KEYS_CODES } from "../../error-codes/error-codes.js";
|
||||
import { bytesToHex, hexToBytes } from "./hex.js";
|
||||
|
||||
describe("bytesToHex / hexToBytes", () => {
|
||||
it("round-trips arbitrary byte arrays", { timeout: 5000 }, () => {
|
||||
const cases: Uint8Array[] = [
|
||||
new Uint8Array([]),
|
||||
new Uint8Array([0]),
|
||||
new Uint8Array([0xff]),
|
||||
new Uint8Array([0xab, 0xcd, 0xef, 0x12, 0x34, 0x56]),
|
||||
new Uint8Array(32).map((_, i) => i),
|
||||
];
|
||||
for (const input of cases) {
|
||||
expect(hexToBytes(bytesToHex(input))).toEqual(input);
|
||||
}
|
||||
});
|
||||
|
||||
it("encodes lowercase without separators or prefix", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(bytesToHex(new Uint8Array([0xab, 0xcd]))).toBe("abcd");
|
||||
expect(bytesToHex(new Uint8Array([0x01, 0x0f]))).toBe("010f");
|
||||
});
|
||||
|
||||
it("rejects odd-length strings", { timeout: 5000 }, () => {
|
||||
try {
|
||||
hexToBytes("abc");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-hex characters with positional message", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
hexToBytes("zz");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as Error).message).toContain(
|
||||
'Invalid hex byte "zz" at offset 0'
|
||||
);
|
||||
}
|
||||
try {
|
||||
hexToBytes("ab0z");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as Error).message).toContain("offset 2");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts the empty string", { timeout: 5000 }, () => {
|
||||
expect(hexToBytes("")).toEqual(new Uint8Array());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
SERVER_KEYS_CODES,
|
||||
ServerKeysError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
|
||||
/** Encode a byte array as a lowercase hex string (no separators). */
|
||||
export function bytesToHex(bytes: Uint8Array): string {
|
||||
let out = "";
|
||||
for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a lowercase or uppercase hex string into a `Uint8Array`. Throws
|
||||
* `ServerKeysError(SERVER_KEYS_INVALID_INPUT)` on odd length or non-hex
|
||||
* characters. Used by the license sub-commands to deserialise the
|
||||
* `signingKey` / `publicKey` fields of the `LicenseKeypairFile` JSON
|
||||
* envelope.
|
||||
*/
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
if (hex.length % 2 !== 0) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
"Hex string length must be even"
|
||||
);
|
||||
}
|
||||
const out = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const start = i * 2;
|
||||
const slice = hex.slice(start, start + 2);
|
||||
if (!/^[0-9a-fA-F]{2}$/.test(slice)) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
`Invalid hex byte "${slice}" at offset ${start}`
|
||||
);
|
||||
}
|
||||
out[i] = Number.parseInt(slice, 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export { bytesToHex, hexToBytes } from "./hex.js";
|
||||
export {
|
||||
exportPublicKeyFile,
|
||||
generate,
|
||||
keygen,
|
||||
parse,
|
||||
revoke,
|
||||
validate,
|
||||
} from "./license.js";
|
||||
export type {
|
||||
GenerateLicenseOptions,
|
||||
LicenseCommandOptions,
|
||||
LicenseKeypairFile,
|
||||
LicenseLogger,
|
||||
LicensePublicKeyFile,
|
||||
RevokeLicenseOptions,
|
||||
ValidateLicenseOptions,
|
||||
} from "./license.types.js";
|
||||
@@ -0,0 +1,133 @@
|
||||
# `commands/license/license`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Wraps `@suaveplan/license` `LicenseManager` into the five sub-commands
|
||||
the operator-facing `license` bin exposes: `keygen`, `generate`,
|
||||
`validate`, `parse`, `revoke`, plus an `exportPublicKeyFile` helper that
|
||||
derives a public-keys JSON from a freshly generated keypair file. Each
|
||||
sub-command translates a `LicenseError` into
|
||||
`ServerKeysError(SERVER_KEYS_LICENSE_FAILED)` with the original error
|
||||
preserved as `cause`, so the top-level `runFunction` wrapper still
|
||||
prints a stable code on failure. Input boundary errors raise
|
||||
`SERVER_KEYS_INVALID_INPUT`, and missing key files raise
|
||||
`SERVER_KEYS_FILE_NOT_FOUND` — all four error paths are exercised in the
|
||||
spec-conformance tests.
|
||||
|
||||
The on-disk JSON envelopes (`LicenseKeypairFile`, `LicensePublicKeyFile`)
|
||||
are deliberately minimal: hex-encoded secp256k1 secret + public, a
|
||||
numeric key id, and an ISO-8601 creation timestamp. Operators can edit
|
||||
them by hand without a tool. The revocation store is a JSON array of
|
||||
hex license fingerprints — created on first revoke, append-only.
|
||||
|
||||
## Features
|
||||
|
||||
- `keygen(outputPath, keyId, options?)` — fresh secp256k1 keypair via
|
||||
`crypto-curve`'s `secp256k1.randomPrivateKey()` + `getPublicKey()`.
|
||||
- `generate(opts)` — `LicenseManager.generate` wrapper with hex-encoded
|
||||
signing-key loading.
|
||||
- `validate(licenseKey, opts)` — public-keys JSON loader, supports
|
||||
either an object or an array layout.
|
||||
- `parse(licenseKey, options?)` — synchronous, no signature check.
|
||||
- `revoke(licenseKey, opts)` — appends to a JSON revocation-store
|
||||
file; creates the file if absent.
|
||||
- `exportPublicKeyFile(keypairFile, outputPath)` — derive the public-
|
||||
keys file from a freshly generated keypair (used by the bin's
|
||||
`--export-pub <path>` flag and by tests).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
keygen,
|
||||
generate,
|
||||
validate,
|
||||
parse,
|
||||
revoke,
|
||||
exportPublicKeyFile,
|
||||
} from "@suaveplan/server-keys/commands/license/license";
|
||||
import { PackageTier } from "@suaveplan/license";
|
||||
|
||||
const kp = await keygen("/tmp/lic/keys.json", 1);
|
||||
await exportPublicKeyFile(kp, "/tmp/lic/public-keys.json");
|
||||
|
||||
const lic = await generate({
|
||||
signingKeyPath: "/tmp/lic/keys.json",
|
||||
productId: 42,
|
||||
tier: PackageTier.PRO,
|
||||
expirationDays: 365,
|
||||
});
|
||||
|
||||
const valid = await validate(lic.key, {
|
||||
publicKeysPath: "/tmp/lic/public-keys.json",
|
||||
});
|
||||
expect(valid.success).toBe(true);
|
||||
|
||||
const parsed = parse(lic.key);
|
||||
expect(parsed.payload.productId).toBe(42);
|
||||
|
||||
await revoke(lic.key, { revocationStorePath: "/tmp/lic/revocations.json" });
|
||||
const after = await validate(lic.key, {
|
||||
publicKeysPath: "/tmp/lic/public-keys.json",
|
||||
});
|
||||
// `revoke` modifies the in-memory store only inside the same Manager;
|
||||
// the file-backed store is observed by the next caller that constructs
|
||||
// a Manager with that store path (see bin/license.ts for the wiring).
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Flag a license as a TRIAL with a feature mask, then validate against a
|
||||
fixed reference date for deterministic expiration windows:
|
||||
|
||||
```ts
|
||||
import { generate, validate } from "@suaveplan/server-keys/commands/license/license";
|
||||
import { LicenseFlags, PackageTier } from "@suaveplan/license";
|
||||
|
||||
const lic = await generate({
|
||||
signingKeyPath: "/tmp/lic/keys.json",
|
||||
productId: 1,
|
||||
tier: PackageTier.BASIC,
|
||||
expirationDays: 30,
|
||||
flags: LicenseFlags.TRIAL,
|
||||
featureMask: 0b1011, // features 0, 1, 3
|
||||
useAbsoluteTime: false,
|
||||
useFullSignature: true,
|
||||
});
|
||||
|
||||
const checked = await validate(lic.key, {
|
||||
publicKeysPath: "/tmp/lic/public-keys.json",
|
||||
referenceDate: new Date("2026-01-01T00:00:00Z"),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `keygen(outputPath, keyId, options?) => Promise<LicenseKeypairFile>`
|
||||
- `generate(opts: GenerateLicenseOptions) =>
|
||||
Promise<LicenseGenerationResult>`
|
||||
- `validate(licenseKey, opts: ValidateLicenseOptions) =>
|
||||
Promise<LicenseValidationResult>`
|
||||
- `parse(licenseKey, options?: LicenseCommandOptions) =>
|
||||
LicenseParsedResult`
|
||||
- `revoke(licenseKey, opts: RevokeLicenseOptions) => Promise<string>`
|
||||
- `exportPublicKeyFile(keypairFile, outputPath) =>
|
||||
Promise<LicensePublicKeyFile>`
|
||||
|
||||
All option bags accept `{ logger?, signal? }` for injection.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`generate` constructs a `LicenseManager` per call (instead of caching
|
||||
one across sub-commands) because each call's signing key may be
|
||||
different — the bin invocation is the granularity that owns a key. The
|
||||
revocation store loader tolerates `ENOENT` (no file yet) but treats any
|
||||
other I/O error as a `LICENSE_FAILED` failure so operators don't
|
||||
silently revoke into a misconfigured path. The optional `signal`
|
||||
parameter is checked at the boundary of each sub-command and at the
|
||||
start of generate/validate so an aborted run never burns CPU on the
|
||||
LicenseManager's hash + signature work.
|
||||
@@ -0,0 +1,528 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { LicenseFlags, PackageTier } from "@suaveplan/license";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SERVER_KEYS_CODES,
|
||||
ServerKeysError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
exportPublicKeyFile,
|
||||
generate,
|
||||
keygen,
|
||||
parse,
|
||||
revoke,
|
||||
validate,
|
||||
} from "./license.js";
|
||||
|
||||
function tmp(label: string): string {
|
||||
return path.join(
|
||||
tmpdir(),
|
||||
`lic-${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanup(dir: string): Promise<void> {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
describe("license keygen", () => {
|
||||
let dir = "";
|
||||
beforeAll(() => {
|
||||
dir = tmp("keygen");
|
||||
});
|
||||
afterAll(async () => {
|
||||
await cleanup(dir);
|
||||
});
|
||||
|
||||
it("writes a keypair JSON file with hex-encoded keys", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const out = path.join(dir, "keys.json");
|
||||
const file = await keygen(out, 7);
|
||||
expect(file.keyId).toBe(7);
|
||||
expect(file.signingKey).toMatch(/^[0-9a-f]+$/);
|
||||
expect(file.publicKey).toMatch(/^[0-9a-f]+$/);
|
||||
// secp256k1 secret = 32 bytes, compressed public = 33 bytes
|
||||
expect(file.signingKey).toHaveLength(64);
|
||||
expect(file.publicKey).toHaveLength(66);
|
||||
const re = JSON.parse(await fs.readFile(out, "utf-8"));
|
||||
expect(re.keyId).toBe(7);
|
||||
});
|
||||
|
||||
it("rejects empty outputPath with INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(keygen("", 0)).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects negative or non-integer keyId", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
keygen(path.join(dir, "x.json"), -1)
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
await expect(
|
||||
keygen(path.join(dir, "x.json"), 1.5)
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("respects a pre-aborted signal", { timeout: 5000 }, async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
keygen(path.join(dir, "abort.json"), 0, { signal: ac.signal })
|
||||
).rejects.toMatchObject({ code: SERVER_KEYS_CODES.ABORTED });
|
||||
});
|
||||
});
|
||||
|
||||
describe("license generate + validate round-trip", () => {
|
||||
let dir = "";
|
||||
let keyPath = "";
|
||||
let pubPath = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = tmp("gen");
|
||||
keyPath = path.join(dir, "keys.json");
|
||||
pubPath = path.join(dir, "public-keys.json");
|
||||
const file = await keygen(keyPath, 3);
|
||||
await exportPublicKeyFile(file, pubPath);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await cleanup(dir);
|
||||
});
|
||||
|
||||
it("generates a license and validates it via the public-keys file", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const lic = await generate({
|
||||
signingKeyPath: keyPath,
|
||||
productId: 42,
|
||||
tier: PackageTier.PRO,
|
||||
expirationDays: 365,
|
||||
});
|
||||
expect(typeof lic.key).toBe("string");
|
||||
expect(lic.key.length).toBeGreaterThan(0);
|
||||
|
||||
const checked = await validate(lic.key, {
|
||||
publicKeysPath: pubPath,
|
||||
});
|
||||
expect(checked.success).toBe(true);
|
||||
});
|
||||
|
||||
it("supports trial flag + feature mask + absolute time + full signature", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const lic = await generate({
|
||||
signingKeyPath: keyPath,
|
||||
productId: 1,
|
||||
tier: PackageTier.BASIC,
|
||||
expirationDays: 30,
|
||||
flags: LicenseFlags.TRIAL,
|
||||
featureMask: 0b1011,
|
||||
useAbsoluteTime: false,
|
||||
useFullSignature: true,
|
||||
});
|
||||
const parsed = parse(lic.key);
|
||||
expect(parsed.payload.flags & LicenseFlags.TRIAL).toBe(
|
||||
LicenseFlags.TRIAL
|
||||
);
|
||||
});
|
||||
|
||||
it("supports keyIdOverride", { timeout: 30000 }, async () => {
|
||||
const lic = await generate({
|
||||
signingKeyPath: keyPath,
|
||||
productId: 9,
|
||||
tier: PackageTier.BASIC,
|
||||
expirationDays: 0,
|
||||
keyIdOverride: 15,
|
||||
});
|
||||
expect(lic.payload.signingKeyId).toBe(15);
|
||||
});
|
||||
|
||||
it("generate rejects bad inputs with INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
generate({
|
||||
signingKeyPath: "",
|
||||
productId: 1,
|
||||
tier: PackageTier.BASIC,
|
||||
expirationDays: 30,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
await expect(
|
||||
generate({
|
||||
signingKeyPath: keyPath,
|
||||
productId: -1,
|
||||
tier: PackageTier.BASIC,
|
||||
expirationDays: 30,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
await expect(
|
||||
generate({
|
||||
signingKeyPath: keyPath,
|
||||
productId: 1,
|
||||
tier: PackageTier.BASIC,
|
||||
expirationDays: -1,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("generate wraps a missing signing-key file as FILE_NOT_FOUND through LICENSE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
generate({
|
||||
signingKeyPath: "/nonexistent/keys.json",
|
||||
productId: 1,
|
||||
tier: PackageTier.BASIC,
|
||||
expirationDays: 30,
|
||||
})
|
||||
).rejects.toBeInstanceOf(ServerKeysError);
|
||||
});
|
||||
|
||||
it("generate respects a pre-aborted signal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
generate({
|
||||
signingKeyPath: keyPath,
|
||||
productId: 1,
|
||||
tier: PackageTier.BASIC,
|
||||
expirationDays: 30,
|
||||
signal: ac.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ code: SERVER_KEYS_CODES.ABORTED });
|
||||
});
|
||||
|
||||
it("validate rejects empty inputs", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
validate("", { publicKeysPath: pubPath })
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
await expect(
|
||||
validate("KEY", { publicKeysPath: "" })
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("validate supports an object-shaped public-keys file (single entry)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const pubObjectPath = path.join(dir, "public-keys-object.json");
|
||||
const arr = JSON.parse(await fs.readFile(pubPath, "utf-8")) as Array<{
|
||||
keyId: number;
|
||||
publicKey: string;
|
||||
}>;
|
||||
await fs.writeFile(pubObjectPath, JSON.stringify(arr[0]));
|
||||
const lic = await generate({
|
||||
signingKeyPath: keyPath,
|
||||
productId: 11,
|
||||
tier: PackageTier.BASIC,
|
||||
expirationDays: 60,
|
||||
});
|
||||
const checked = await validate(lic.key, {
|
||||
publicKeysPath: pubObjectPath,
|
||||
});
|
||||
expect(checked.success).toBe(true);
|
||||
});
|
||||
|
||||
it("validate forwards referenceDate to the manager", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const lic = await generate({
|
||||
signingKeyPath: keyPath,
|
||||
productId: 11,
|
||||
tier: PackageTier.BASIC,
|
||||
expirationDays: 1,
|
||||
useAbsoluteTime: true,
|
||||
});
|
||||
const checked = await validate(lic.key, {
|
||||
publicKeysPath: pubPath,
|
||||
referenceDate: new Date(),
|
||||
});
|
||||
expect(checked.success).toBeDefined();
|
||||
});
|
||||
|
||||
it("validate respects a pre-aborted signal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
validate("anykey", {
|
||||
publicKeysPath: pubPath,
|
||||
signal: ac.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ code: SERVER_KEYS_CODES.ABORTED });
|
||||
});
|
||||
});
|
||||
|
||||
describe("license parse decodes without verification", () => {
|
||||
let dir = "";
|
||||
let keyPath = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = tmp("parse");
|
||||
keyPath = path.join(dir, "keys.json");
|
||||
await keygen(keyPath, 0);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await cleanup(dir);
|
||||
});
|
||||
|
||||
it("parses a generated license", { timeout: 30000 }, async () => {
|
||||
const lic = await generate({
|
||||
signingKeyPath: keyPath,
|
||||
productId: 7,
|
||||
tier: PackageTier.BASIC,
|
||||
expirationDays: 0,
|
||||
});
|
||||
const parsed = parse(lic.key);
|
||||
expect(parsed.payload.productId).toBe(7);
|
||||
});
|
||||
|
||||
it("rejects empty input", { timeout: 5000 }, () => {
|
||||
expect(() => parse("")).toThrow(ServerKeysError);
|
||||
});
|
||||
|
||||
it("wraps a LicenseError into SERVER_KEYS.LICENSE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
parse("INVALID-LICENSE-KEY");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ServerKeysError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapLicenseError covers non-ServerKeysError throws", () => {
|
||||
let dir = "";
|
||||
let badKeyPath = "";
|
||||
let pubPath = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = tmp("wrap");
|
||||
badKeyPath = path.join(dir, "bad-keys.json");
|
||||
pubPath = path.join(dir, "public-keys.json");
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
// Valid JSON, valid-shape hex (even length / hex chars), but the
|
||||
// signing-key bytes are zero — secp256k1.getPublicKey rejects them.
|
||||
await fs.writeFile(
|
||||
badKeyPath,
|
||||
JSON.stringify({
|
||||
keyId: 0,
|
||||
signingKey: "00".repeat(32),
|
||||
publicKey: "00".repeat(33),
|
||||
created: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
// Public-keys file with an invalid (all-zero) public key so validate
|
||||
// walks into the manager and that throws a non-ServerKeysError.
|
||||
await fs.writeFile(
|
||||
pubPath,
|
||||
JSON.stringify([
|
||||
{
|
||||
keyId: 0,
|
||||
publicKey: "00".repeat(33),
|
||||
},
|
||||
])
|
||||
);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await cleanup(dir);
|
||||
});
|
||||
|
||||
it("generate wraps a LicenseError (non-ServerKeysError) as LICENSE_FAILED via wrapLicenseError", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// generate constructs the manager with `defaultProductId: opts.productId`
|
||||
// (a non-negative integer ≥ 0). Passing a productId that overflows the
|
||||
// u8 the payload schema enforces makes the inner generateLicense /
|
||||
// createPayload throw a LicenseError, which is NOT a ServerKeysError,
|
||||
// so wrapLicenseError must wrap it into SERVER_KEYS.LICENSE_FAILED.
|
||||
// First — bypass our local Number.isInteger guard by using a valid
|
||||
// integer that the LICENSE_SCHEMA u8 rejects (>255).
|
||||
await expect(
|
||||
generate({
|
||||
signingKeyPath: badKeyPath,
|
||||
productId: 99999, // valid integer, but LICENSE_SCHEMA.productId is u8
|
||||
tier: PackageTier.BASIC,
|
||||
expirationDays: 30,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.LICENSE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("validate wraps a LicenseError (non-ServerKeysError) from the inner validator as LICENSE_FAILED", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Pass a non-Date referenceDate (forced through a cast) so the
|
||||
// inner LicenseValidationOptionsSchema rejects it with a
|
||||
// LicenseError — which is NOT a ServerKeysError, so
|
||||
// wrapLicenseError must wrap it.
|
||||
await expect(
|
||||
validate("ABCDEFGH", {
|
||||
publicKeysPath: pubPath,
|
||||
referenceDate: "not-a-date" as unknown as Date,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.LICENSE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("parse wraps a non-LicenseError (TypeError) as LICENSE_FAILED via wrapLicenseError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// parseLicense expects a string; pass a number cast to string-shape
|
||||
// to bypass the falsy guard and force a TypeError from the inner
|
||||
// parser, which is not a LicenseError and so falls through to
|
||||
// wrapLicenseError.
|
||||
expect(() => parse(123 as unknown as string)).toThrow(ServerKeysError);
|
||||
try {
|
||||
parse(123 as unknown as string);
|
||||
} catch (err) {
|
||||
expect((err as ServerKeysError).code).toBe(
|
||||
SERVER_KEYS_CODES.LICENSE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("revoke wraps a non-ENOENT readFile error (EISDIR) as LICENSE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const dirAsStore = path.join(dir, "store-dir");
|
||||
await fs.mkdir(dirAsStore, { recursive: true });
|
||||
// Pointing the revocation store at a directory yields EISDIR (not
|
||||
// ENOENT), so the catch must wrap via wrapLicenseError.
|
||||
await expect(
|
||||
revoke("LICENSE-KEY-PLACEHOLDER", {
|
||||
revocationStorePath: dirAsStore,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.LICENSE_FAILED,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("license revoke marks key as REVOKED", () => {
|
||||
let dir = "";
|
||||
let keyPath = "";
|
||||
let storePath = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = tmp("revoke");
|
||||
keyPath = path.join(dir, "keys.json");
|
||||
storePath = path.join(dir, "revocations.json");
|
||||
await keygen(keyPath, 0);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await cleanup(dir);
|
||||
});
|
||||
|
||||
it("creates the store file on first revoke and writes the fingerprint", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const lic = await generate({
|
||||
signingKeyPath: keyPath,
|
||||
productId: 1,
|
||||
tier: PackageTier.BASIC,
|
||||
expirationDays: 30,
|
||||
});
|
||||
const fp = await revoke(lic.key, {
|
||||
revocationStorePath: storePath,
|
||||
});
|
||||
expect(fp).toMatch(/^[0-9a-f]+$/);
|
||||
const arr = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||
expect(arr).toContain(fp);
|
||||
});
|
||||
|
||||
it("does not duplicate a fingerprint already present", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const lic = await generate({
|
||||
signingKeyPath: keyPath,
|
||||
productId: 2,
|
||||
tier: PackageTier.BASIC,
|
||||
expirationDays: 30,
|
||||
});
|
||||
await revoke(lic.key, { revocationStorePath: storePath });
|
||||
await revoke(lic.key, { revocationStorePath: storePath });
|
||||
const arr = JSON.parse(
|
||||
await fs.readFile(storePath, "utf-8")
|
||||
) as string[];
|
||||
const fp = arr[arr.length - 1];
|
||||
expect(arr.filter((f: string) => f === fp).length).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects empty inputs", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
revoke("", { revocationStorePath: storePath })
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
await expect(
|
||||
revoke("KEY", { revocationStorePath: "" })
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it("respects a pre-aborted signal", { timeout: 5000 }, async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
revoke("KEY", {
|
||||
revocationStorePath: storePath,
|
||||
signal: ac.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ code: SERVER_KEYS_CODES.ABORTED });
|
||||
});
|
||||
|
||||
it("treats a non-array revocation-store JSON file as an empty store", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const storePath2 = path.join(dir, "non-array-store.json");
|
||||
// The store file exists with valid JSON, but is an object instead of
|
||||
// an array — the `if (Array.isArray(parsed))` branch falls through
|
||||
// and the existing array stays empty.
|
||||
await fs.writeFile(storePath2, JSON.stringify({ not: "an-array" }));
|
||||
const lic = await generate({
|
||||
signingKeyPath: keyPath,
|
||||
productId: 3,
|
||||
tier: PackageTier.BASIC,
|
||||
expirationDays: 30,
|
||||
});
|
||||
const fp = await revoke(lic.key, {
|
||||
revocationStorePath: storePath2,
|
||||
});
|
||||
const arr = JSON.parse(await fs.readFile(storePath2, "utf-8"));
|
||||
expect(Array.isArray(arr)).toBe(true);
|
||||
expect(arr).toContain(fp);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { secp256k1 } from "@suaveplan/crypto-curve";
|
||||
import {
|
||||
fingerprintLicense,
|
||||
LicenseError,
|
||||
type LicenseGenerationResult,
|
||||
LicenseManager,
|
||||
type LicenseParsedResult,
|
||||
type LicenseValidationResult,
|
||||
type PublicKey,
|
||||
parseLicense,
|
||||
type SignKey,
|
||||
} from "@suaveplan/license";
|
||||
import {
|
||||
SERVER_KEYS_CODES,
|
||||
ServerKeysError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { bytesToHex, hexToBytes } from "./hex.js";
|
||||
import type {
|
||||
GenerateLicenseOptions,
|
||||
LicenseCommandOptions,
|
||||
LicenseKeypairFile,
|
||||
LicenseLogger,
|
||||
LicensePublicKeyFile,
|
||||
RevokeLicenseOptions,
|
||||
ValidateLicenseOptions,
|
||||
} from "./license.types.js";
|
||||
|
||||
const SILENT: () => void = () => {};
|
||||
const SILENT_LOGGER: LicenseLogger = { info: SILENT, error: SILENT };
|
||||
|
||||
function pickLogger(options: LicenseCommandOptions | undefined): LicenseLogger {
|
||||
return options?.logger ?? SILENT_LOGGER;
|
||||
}
|
||||
|
||||
function checkSignal(signal: AbortSignal | undefined, sub: string): void {
|
||||
if (signal?.aborted === true) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.ABORTED,
|
||||
`license ${sub} aborted before completion`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadJson<T>(filePath: string): Promise<T> {
|
||||
try {
|
||||
const text = await fs.readFile(filePath, "utf-8");
|
||||
return JSON.parse(text) as T;
|
||||
} catch (err) {
|
||||
let extras: { cause?: Error; context: { filePath: string } } = {
|
||||
context: { filePath },
|
||||
};
|
||||
/* istanbul ignore start -- defensive: fs.readFile always throws NodeJS.ErrnoException (Error subclass) and JSON.parse always throws SyntaxError, so the else branch is unreachable. */
|
||||
if (err instanceof Error) {
|
||||
extras = { cause: err, context: { filePath } };
|
||||
}
|
||||
/* istanbul ignore stop */
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.FILE_NOT_FOUND,
|
||||
`Failed to read JSON file: ${filePath}`,
|
||||
extras
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function loadSigningKey(file: LicenseKeypairFile): SignKey {
|
||||
return {
|
||||
s: hexToBytes(file.signingKey),
|
||||
p: hexToBytes(file.publicKey),
|
||||
};
|
||||
}
|
||||
|
||||
function wrapLicenseError(err: unknown, sub: string): ServerKeysError {
|
||||
if (err instanceof ServerKeysError) return err;
|
||||
let message = String(err);
|
||||
let cause: { cause: Error } | undefined;
|
||||
/* istanbul ignore start -- defensive: every documented throw from @suaveplan/license (LicenseError) and from JS built-ins (TypeError, SyntaxError, ENOENT NodeJS.ErrnoException) is an Error subclass, so the else branch is unreachable. */
|
||||
if (err instanceof Error) {
|
||||
message = err.message;
|
||||
cause = { cause: err };
|
||||
}
|
||||
/* istanbul ignore stop */
|
||||
return new ServerKeysError(
|
||||
SERVER_KEYS_CODES.LICENSE_FAILED,
|
||||
`license ${sub} failed: ${message}`,
|
||||
cause
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh secp256k1 signing keypair and persist it to
|
||||
* `outputPath` as a JSON envelope (`LicenseKeypairFile`).
|
||||
*/
|
||||
export async function keygen(
|
||||
outputPath: string,
|
||||
keyId: number,
|
||||
options?: LicenseCommandOptions
|
||||
): Promise<LicenseKeypairFile> {
|
||||
if (!outputPath) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
"outputPath is required"
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(keyId) || keyId < 0) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
"keyId must be a non-negative integer"
|
||||
);
|
||||
}
|
||||
checkSignal(options?.signal, "keygen");
|
||||
const secret = secp256k1.randomPrivateKey();
|
||||
const publicKey = secp256k1.getPublicKey(secret);
|
||||
const file: LicenseKeypairFile = {
|
||||
keyId,
|
||||
signingKey: bytesToHex(secret),
|
||||
publicKey: bytesToHex(publicKey),
|
||||
created: new Date().toISOString(),
|
||||
};
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, JSON.stringify(file, null, 2));
|
||||
pickLogger(options).info(
|
||||
`license keygen: keyId=${keyId} wrote=${outputPath}`
|
||||
);
|
||||
return file;
|
||||
}
|
||||
|
||||
/** Generate a license key signed by the supplied signing key file. */
|
||||
export async function generate(
|
||||
opts: GenerateLicenseOptions
|
||||
): Promise<LicenseGenerationResult> {
|
||||
if (
|
||||
!opts.signingKeyPath ||
|
||||
!Number.isInteger(opts.productId) ||
|
||||
opts.productId < 0 ||
|
||||
!Number.isInteger(opts.expirationDays) ||
|
||||
opts.expirationDays < 0
|
||||
) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
"signingKeyPath, productId (non-negative integer), and expirationDays (non-negative integer) are required"
|
||||
);
|
||||
}
|
||||
checkSignal(opts.signal, "generate");
|
||||
try {
|
||||
const keyFile = await loadJson<LicenseKeypairFile>(opts.signingKeyPath);
|
||||
const signingKey = loadSigningKey(keyFile);
|
||||
const manager = new LicenseManager({
|
||||
signingKey,
|
||||
defaultSigningKeyId: opts.keyIdOverride ?? keyFile.keyId,
|
||||
defaultProductId: opts.productId,
|
||||
});
|
||||
const result = await manager.generate(
|
||||
{
|
||||
productId: opts.productId,
|
||||
tier: opts.tier,
|
||||
expirationDays: opts.expirationDays,
|
||||
flags: opts.flags ?? 0,
|
||||
features: opts.featureMask ?? 0,
|
||||
useAbsoluteTime: opts.useAbsoluteTime ?? false,
|
||||
},
|
||||
opts.useFullSignature !== undefined
|
||||
? { useFullSignature: opts.useFullSignature }
|
||||
: {}
|
||||
);
|
||||
pickLogger(opts).info(`license generate: key=${result.key}`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
throw wrapLicenseError(err, "generate");
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate a license key against a public-keys JSON file. */
|
||||
export async function validate(
|
||||
licenseKey: string,
|
||||
opts: ValidateLicenseOptions
|
||||
): Promise<LicenseValidationResult> {
|
||||
if (!licenseKey || !opts.publicKeysPath) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
"licenseKey and publicKeysPath are required"
|
||||
);
|
||||
}
|
||||
checkSignal(opts.signal, "validate");
|
||||
try {
|
||||
const raw = await loadJson<
|
||||
LicensePublicKeyFile | LicensePublicKeyFile[]
|
||||
>(opts.publicKeysPath);
|
||||
const entries = Array.isArray(raw) ? raw : [raw];
|
||||
const publicKeys = new Map<number, PublicKey>();
|
||||
for (const entry of entries) {
|
||||
publicKeys.set(entry.keyId, { p: hexToBytes(entry.publicKey) });
|
||||
}
|
||||
const manager = new LicenseManager({ publicKeys });
|
||||
const result = await manager.validate(
|
||||
licenseKey,
|
||||
opts.referenceDate !== undefined
|
||||
? { referenceDate: opts.referenceDate }
|
||||
: {}
|
||||
);
|
||||
pickLogger(opts).info(`license validate: success=${result.success}`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
throw wrapLicenseError(err, "validate");
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a license key without verifying its signature. */
|
||||
export function parse(
|
||||
licenseKey: string,
|
||||
options?: LicenseCommandOptions
|
||||
): LicenseParsedResult {
|
||||
if (!licenseKey) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
"licenseKey is required"
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = parseLicense(licenseKey);
|
||||
pickLogger(options).info(
|
||||
`license parse: version=${result.payload.version} productId=${result.payload.productId}`
|
||||
);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (err instanceof LicenseError) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.LICENSE_FAILED,
|
||||
`license parse failed: ${err.message}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
throw wrapLicenseError(err, "parse");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a license by writing its fingerprint to a file-backed
|
||||
* `RevocationStore`. The store is a JSON array of hex fingerprints.
|
||||
*/
|
||||
export async function revoke(
|
||||
licenseKey: string,
|
||||
opts: RevokeLicenseOptions
|
||||
): Promise<string> {
|
||||
if (!licenseKey || !opts.revocationStorePath) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.INVALID_INPUT,
|
||||
"licenseKey and revocationStorePath are required"
|
||||
);
|
||||
}
|
||||
checkSignal(opts.signal, "revoke");
|
||||
const fingerprint = fingerprintLicense(licenseKey);
|
||||
let existing: string[] = [];
|
||||
try {
|
||||
const text = await fs.readFile(opts.revocationStorePath, "utf-8");
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
existing = parsed.filter(
|
||||
(entry): entry is string => typeof entry === "string"
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (
|
||||
!(err instanceof Error) ||
|
||||
(err as NodeJS.ErrnoException).code !== "ENOENT"
|
||||
) {
|
||||
throw wrapLicenseError(err, "revoke");
|
||||
}
|
||||
// Missing file is fine — we'll create it.
|
||||
}
|
||||
if (!existing.includes(fingerprint)) existing.push(fingerprint);
|
||||
await fs.mkdir(path.dirname(opts.revocationStorePath), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
opts.revocationStorePath,
|
||||
JSON.stringify(existing, null, 2)
|
||||
);
|
||||
pickLogger(opts).info(
|
||||
`license revoke: fingerprint=${fingerprint} store=${opts.revocationStorePath}`
|
||||
);
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
/** Helper: write a public-keys JSON file from a keypair file. */
|
||||
export async function exportPublicKeyFile(
|
||||
keypairFile: LicenseKeypairFile,
|
||||
outputPath: string
|
||||
): Promise<LicensePublicKeyFile> {
|
||||
const file: LicensePublicKeyFile = {
|
||||
keyId: keypairFile.keyId,
|
||||
publicKey: keypairFile.publicKey,
|
||||
};
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, JSON.stringify([file], null, 2));
|
||||
return file;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# `commands/license/license.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
Type definitions for every license sub-command's options bag and for
|
||||
the JSON file envelopes the bin reads and writes. Centralising these
|
||||
here keeps the sub-command functions in `license.ts` focused on
|
||||
behaviour and makes the boundary contract explicit — both for
|
||||
operators editing the JSON files by hand and for downstream programmatic
|
||||
consumers that want to write a typed wrapper around the bin's surface.
|
||||
|
||||
The file envelopes mirror the upstream layout exactly so operator
|
||||
scripts that read or write these files continue to work without
|
||||
modification.
|
||||
|
||||
## Features
|
||||
|
||||
- `LicenseLogger` — `Pick<LoggerLike, "info" | "error">`; injected.
|
||||
- `LicenseCommandOptions` — shared `{ logger?, signal? }` base.
|
||||
- `LicenseKeypairFile` — `{ keyId, signingKey (hex), publicKey (hex),
|
||||
created (ISO 8601) }`. Persisted by `keygen`.
|
||||
- `LicensePublicKeyFile` — `{ keyId, publicKey (hex) }`. Persisted by
|
||||
`exportPublicKeyFile`; read by `validate`.
|
||||
- `GenerateLicenseOptions`, `ValidateLicenseOptions`,
|
||||
`RevokeLicenseOptions` — typed per-sub-command bags.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type {
|
||||
LicenseKeypairFile,
|
||||
GenerateLicenseOptions,
|
||||
} from "@suaveplan/server-keys/commands/license/license.types";
|
||||
import { PackageTier } from "@suaveplan/license";
|
||||
|
||||
const opts: GenerateLicenseOptions = {
|
||||
signingKeyPath: "/tmp/keys.json",
|
||||
productId: 42,
|
||||
tier: PackageTier.PRO,
|
||||
expirationDays: 365,
|
||||
};
|
||||
|
||||
const file: LicenseKeypairFile = JSON.parse(
|
||||
await fs.readFile("/tmp/keys.json", "utf-8"),
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Construct a typed orchestration script that emits a generation request,
|
||||
validates against a separately stored public-key file, and writes the
|
||||
revocation back to a third path:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
GenerateLicenseOptions,
|
||||
ValidateLicenseOptions,
|
||||
RevokeLicenseOptions,
|
||||
} from "@suaveplan/server-keys/commands/license/license.types";
|
||||
|
||||
interface LicenseFlow {
|
||||
generate: GenerateLicenseOptions;
|
||||
validate: ValidateLicenseOptions;
|
||||
revoke: RevokeLicenseOptions;
|
||||
}
|
||||
|
||||
const flow: LicenseFlow = {
|
||||
generate: {
|
||||
signingKeyPath: "/etc/lic/keys.json",
|
||||
productId: 1,
|
||||
tier: 2,
|
||||
expirationDays: 730,
|
||||
},
|
||||
validate: { publicKeysPath: "/etc/lic/public-keys.json" },
|
||||
revoke: { revocationStorePath: "/var/lib/lic/revocations.json" },
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface LicenseLogger`
|
||||
- `interface LicenseCommandOptions`
|
||||
- `interface LicenseKeypairFile`
|
||||
- `interface LicensePublicKeyFile`
|
||||
- `interface GenerateLicenseOptions extends LicenseCommandOptions`
|
||||
- `interface ValidateLicenseOptions extends LicenseCommandOptions`
|
||||
- `interface RevokeLicenseOptions extends LicenseCommandOptions`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All fields on `LicenseKeypairFile` and `LicensePublicKeyFile` are
|
||||
`readonly` so accidental mutation after a JSON parse is a TS error.
|
||||
`LicenseValidationOptions.publicKeysPath` is named verbosely (instead
|
||||
of `publicKeyPath`) because the file MAY hold many keyed entries —
|
||||
either as a single object or as an array — and the plural is more
|
||||
honest about the multi-key layout. `ValidateLicenseOptions.referenceDate`
|
||||
is `Date | undefined`; the bin's `--reference-date <iso>` parses to a
|
||||
`Date` and forwards.
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { LicenseFlags, PackageTier } from "@suaveplan/license";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
|
||||
/** Logger surface accepted by every license sub-command. */
|
||||
export interface LicenseLogger extends Pick<LoggerLike, "info" | "error"> {}
|
||||
|
||||
/** Common option bag for every license sub-command. */
|
||||
export interface LicenseCommandOptions {
|
||||
/** Optional logger; defaults to silent. */
|
||||
readonly logger?: LicenseLogger;
|
||||
/** Cooperative abort; checked at sub-command boundaries. */
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** JSON envelope written by `license keygen` and read by `license generate`. */
|
||||
export interface LicenseKeypairFile {
|
||||
readonly keyId: number;
|
||||
/** secp256k1 secret scalar, hex-encoded. */
|
||||
readonly signingKey: string;
|
||||
/** secp256k1 compressed public key, hex-encoded. */
|
||||
readonly publicKey: string;
|
||||
/** ISO-8601 creation timestamp. */
|
||||
readonly created: string;
|
||||
}
|
||||
|
||||
/** JSON envelope read by `license validate`. */
|
||||
export interface LicensePublicKeyFile {
|
||||
readonly keyId: number;
|
||||
readonly publicKey: string;
|
||||
}
|
||||
|
||||
/** Options accepted by `license generate`. */
|
||||
export interface GenerateLicenseOptions extends LicenseCommandOptions {
|
||||
readonly signingKeyPath: string;
|
||||
readonly productId: number;
|
||||
readonly tier: PackageTier;
|
||||
readonly expirationDays: number;
|
||||
readonly flags?: LicenseFlags;
|
||||
readonly featureMask?: number;
|
||||
readonly useAbsoluteTime?: boolean;
|
||||
readonly useFullSignature?: boolean;
|
||||
/** Optional override for the signing-key id (default: keypair file's id). */
|
||||
readonly keyIdOverride?: number;
|
||||
}
|
||||
|
||||
/** Options accepted by `license validate`. */
|
||||
export interface ValidateLicenseOptions extends LicenseCommandOptions {
|
||||
readonly publicKeysPath: string;
|
||||
readonly referenceDate?: Date;
|
||||
}
|
||||
|
||||
/** Options accepted by `license revoke`. */
|
||||
export interface RevokeLicenseOptions extends LicenseCommandOptions {
|
||||
readonly revocationStorePath: string;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable `SERVER_KEYS_*` error-code registry used throughout
|
||||
`@suaveplan/server-keys`. Each code is a namespaced string registered via
|
||||
`defineCodes("SERVER_KEYS", ...)` from `@suaveplan/error`, so the global
|
||||
error registry is aware of every code the package may throw the moment
|
||||
this module is imported. Every public failure path in the package — vault
|
||||
decrypt, password mismatch, packet integrity, chain verify, license
|
||||
delegation, CLI input validation, cooperative abort — raises a
|
||||
`ServerKeysError` with one of these codes, never a bare `Error`.
|
||||
|
||||
The registry is part of the package contract: tests assert that every
|
||||
declared code is raised from at least one production call site, and the
|
||||
spec-conformance test enumerates the registry to keep it from drifting.
|
||||
|
||||
## Features
|
||||
|
||||
- 10 stable codes covering vault, packet, license, input, and abort paths.
|
||||
- `ServerKeysError extends SuaveplanError<ServerKeysErrorCode>` so
|
||||
`instanceof` narrows the `.code` field to the registry's literal union.
|
||||
- Side-effect registration with the global `@suaveplan/error` table — any
|
||||
consumer that imports `@suaveplan/server-keys/error-codes/error-codes`
|
||||
picks up the codes globally.
|
||||
- `serverKeysCode` accessor mirrors `code` for ergonomic narrowing.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
SERVER_KEYS_CODES,
|
||||
ServerKeysError,
|
||||
} from "@suaveplan/server-keys/error-codes/error-codes";
|
||||
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.VAULT_NOT_FOUND,
|
||||
"Vault file does not exist at /etc/suaveplan/root.vault",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Chain a cause through the constructor and dispatch on the code in a
|
||||
top-level handler:
|
||||
|
||||
```ts
|
||||
import {
|
||||
SERVER_KEYS_CODES,
|
||||
ServerKeysError,
|
||||
type ServerKeysErrorCode,
|
||||
} from "@suaveplan/server-keys/error-codes/error-codes";
|
||||
|
||||
try {
|
||||
await prepareFileCommand(...);
|
||||
} catch (err) {
|
||||
if (err instanceof ServerKeysError) {
|
||||
const code: ServerKeysErrorCode = err.code;
|
||||
if (code === SERVER_KEYS_CODES.PACKET_INTEGRITY_FAILED) {
|
||||
// re-pack with a different signing key
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Re-throw with cause chain preserved
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.VAULT_DECRYPT_FAILED,
|
||||
"Failed to decrypt root vault",
|
||||
{ cause: underlying, context: { vaultPath: "/etc/root.vault" } },
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SERVER_KEYS_CODES` — frozen record of code constants.
|
||||
- `ServerKeysErrorCode` — union of the registry's string-literal values.
|
||||
- `class ServerKeysError extends SuaveplanError<ServerKeysErrorCode>` —
|
||||
concrete error class with `.code`, `.serverKeysCode`, `.cause`,
|
||||
`.context` (inherited from `SuaveplanError`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` returns a frozen object whose keys map to the namespaced
|
||||
string codes (e.g. `SERVER_KEYS_CODES.VAULT_NOT_FOUND === "SERVER_KEYS.VAULT_NOT_FOUND"`).
|
||||
The cast through `SuaveplanError<ServerKeysErrorCode>` keeps the literal
|
||||
narrowing intact across error-catch boundaries. The class deliberately
|
||||
does not introduce extra behavior beyond the discriminator field — error
|
||||
shaping, redaction, and OTel span-event adapters live in
|
||||
`@suaveplan/error`.
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SERVER_KEYS_CODES, ServerKeysError } from "./error-codes.js";
|
||||
|
||||
describe("SERVER_KEYS_CODES registry", () => {
|
||||
it("exposes the 10 expected namespaced codes", { timeout: 5000 }, () => {
|
||||
expect(SERVER_KEYS_CODES.VAULT_NOT_FOUND).toBe(
|
||||
"SERVER_KEYS.VAULT_NOT_FOUND"
|
||||
);
|
||||
expect(SERVER_KEYS_CODES.VAULT_DECRYPT_FAILED).toBe(
|
||||
"SERVER_KEYS.VAULT_DECRYPT_FAILED"
|
||||
);
|
||||
expect(SERVER_KEYS_CODES.PASSWORD_MISMATCH).toBe(
|
||||
"SERVER_KEYS.PASSWORD_MISMATCH"
|
||||
);
|
||||
expect(SERVER_KEYS_CODES.CHAIN_VERIFY_FAILED).toBe(
|
||||
"SERVER_KEYS.CHAIN_VERIFY_FAILED"
|
||||
);
|
||||
expect(SERVER_KEYS_CODES.PACKET_INTEGRITY_FAILED).toBe(
|
||||
"SERVER_KEYS.PACKET_INTEGRITY_FAILED"
|
||||
);
|
||||
expect(SERVER_KEYS_CODES.FILE_NOT_FOUND).toBe(
|
||||
"SERVER_KEYS.FILE_NOT_FOUND"
|
||||
);
|
||||
expect(SERVER_KEYS_CODES.INVALID_INPUT).toBe(
|
||||
"SERVER_KEYS.INVALID_INPUT"
|
||||
);
|
||||
expect(SERVER_KEYS_CODES.LICENSE_FAILED).toBe(
|
||||
"SERVER_KEYS.LICENSE_FAILED"
|
||||
);
|
||||
expect(SERVER_KEYS_CODES.UNKNOWN_COMMAND).toBe(
|
||||
"SERVER_KEYS.UNKNOWN_COMMAND"
|
||||
);
|
||||
expect(SERVER_KEYS_CODES.ABORTED).toBe("SERVER_KEYS.ABORTED");
|
||||
expect(Object.keys(SERVER_KEYS_CODES)).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerKeysError", () => {
|
||||
it("carries the code on both the inherited and serverKeysCode fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new ServerKeysError(
|
||||
SERVER_KEYS_CODES.VAULT_NOT_FOUND,
|
||||
"missing"
|
||||
);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(ServerKeysError);
|
||||
expect(err.code).toBe(SERVER_KEYS_CODES.VAULT_NOT_FOUND);
|
||||
expect(err.serverKeysCode).toBe(SERVER_KEYS_CODES.VAULT_NOT_FOUND);
|
||||
expect(err.message).toBe("missing");
|
||||
});
|
||||
|
||||
it("preserves cause and context through SuaveplanError options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("underlying");
|
||||
const err = new ServerKeysError(
|
||||
SERVER_KEYS_CODES.VAULT_DECRYPT_FAILED,
|
||||
"decrypt failed",
|
||||
{ cause, context: { vaultPath: "/tmp/x.vault" } }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ vaultPath: "/tmp/x.vault" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/server-keys`. Every public
|
||||
* failure path raises a {@link ServerKeysError} carrying one of these
|
||||
* namespaced `SERVER_KEYS.*` codes.
|
||||
*
|
||||
* The 10 codes cover:
|
||||
* - Filesystem / vault discovery (`VAULT_NOT_FOUND`, `FILE_NOT_FOUND`).
|
||||
* - Vault decryption (`VAULT_DECRYPT_FAILED`).
|
||||
* - Interactive password confirmation (`PASSWORD_MISMATCH`).
|
||||
* - Chain / packet integrity (`CHAIN_VERIFY_FAILED`,
|
||||
* `PACKET_INTEGRITY_FAILED`).
|
||||
* - Input boundary (`INVALID_INPUT`) — Zod-detected option-bag failures.
|
||||
* - License-bin pass-through (`LICENSE_FAILED`).
|
||||
* - Top-level dispatcher (`UNKNOWN_COMMAND`).
|
||||
* - Cooperative cancellation (`ABORTED`).
|
||||
*/
|
||||
export const SERVER_KEYS_CODES = defineCodes("SERVER_KEYS", [
|
||||
"VAULT_NOT_FOUND",
|
||||
"VAULT_DECRYPT_FAILED",
|
||||
"PASSWORD_MISMATCH",
|
||||
"CHAIN_VERIFY_FAILED",
|
||||
"PACKET_INTEGRITY_FAILED",
|
||||
"FILE_NOT_FOUND",
|
||||
"INVALID_INPUT",
|
||||
"LICENSE_FAILED",
|
||||
"UNKNOWN_COMMAND",
|
||||
"ABORTED",
|
||||
] as const);
|
||||
|
||||
export type ServerKeysErrorCode =
|
||||
(typeof SERVER_KEYS_CODES)[keyof typeof SERVER_KEYS_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for `@suaveplan/server-keys`. Extends
|
||||
* `SuaveplanError` so `instanceof ServerKeysError` narrows `code` to
|
||||
* {@link ServerKeysErrorCode}. Constructor is `public` so call-sites
|
||||
* can throw directly without a factory.
|
||||
*/
|
||||
export class ServerKeysError extends SuaveplanError<ServerKeysErrorCode> {
|
||||
public readonly serverKeysCode: ServerKeysErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: ServerKeysErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.serverKeysCode = code;
|
||||
}
|
||||
}
|
||||
30
packages/security-stack/security/server-keys/src/index.ts
Normal file
30
packages/security-stack/security/server-keys/src/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Side-effect import: registers every SERVER_KEYS_* code in the shared
|
||||
// `@suaveplan/error` registry on package load.
|
||||
import "./error-codes/error-codes.js";
|
||||
|
||||
export {
|
||||
SERVER_KEYS_CODES,
|
||||
ServerKeysError,
|
||||
type ServerKeysErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export {
|
||||
ATTR_COMMAND,
|
||||
ATTR_ERROR_CODE,
|
||||
ATTR_OUTCOME,
|
||||
type CommandOutcome,
|
||||
commandDurationMs,
|
||||
commandTotal,
|
||||
recordCommand,
|
||||
} from "./otel/instruments.js";
|
||||
export {
|
||||
MIN_PASSWORD_LENGTH,
|
||||
type PasswordPrompt,
|
||||
type PromptOptions,
|
||||
promptForNewPassword,
|
||||
promptForPassword,
|
||||
} from "./prompt/prompt.js";
|
||||
export {
|
||||
type RunFunctionLogger,
|
||||
type RunFunctionOptions,
|
||||
runFunction,
|
||||
} from "./run-function/index.js";
|
||||
@@ -0,0 +1,89 @@
|
||||
# `otel/instruments`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Registers the OpenTelemetry counter and histogram that every CLI command
|
||||
in `@suaveplan/server-keys` emits exactly once per invocation through the
|
||||
`runFunction` wrapper. The instruments live behind the global meter
|
||||
provider supplied by `@opentelemetry/api`, so production deployments can
|
||||
plug in any exporter (OTLP, Prometheus, Jaeger) without changing the
|
||||
package. Tests wire an in-memory `MeterProvider` in `tests/setup/polyfills.ts`
|
||||
before any source module is imported, so the meter resolved at load time
|
||||
is the real provider's meter, never the noop fallback.
|
||||
|
||||
The `recordCommand(command, outcome, durationMs, errorCode?)` helper is the
|
||||
single call site every command goes through — keeping the attribute keys
|
||||
consistent and the histogram unit (`ms`) authoritative.
|
||||
|
||||
## Features
|
||||
|
||||
- Counter `server_keys.command_total{command, command.outcome[, command.error_code]}`.
|
||||
- Histogram `server_keys.command_duration_ms{command, command.outcome[, command.error_code]}`
|
||||
with `unit: "ms"`.
|
||||
- Stable attribute-key constants (`ATTR_COMMAND`, `ATTR_OUTCOME`,
|
||||
`ATTR_ERROR_CODE`) so tests can assert by name.
|
||||
- `recordCommand` records both instruments atomically so dashboards built
|
||||
on the counter and the histogram never disagree.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { recordCommand } from "@suaveplan/server-keys/otel/instruments";
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
await doWork();
|
||||
recordCommand("create-root-keys", "success", performance.now() - start);
|
||||
} catch (err) {
|
||||
recordCommand("create-root-keys", "error", performance.now() - start);
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Attach the typed error code when failing, so dashboards can break down
|
||||
failures by reason without scraping log lines:
|
||||
|
||||
```ts
|
||||
import { recordCommand } from "@suaveplan/server-keys/otel/instruments";
|
||||
import { ServerKeysError } from "@suaveplan/server-keys/error-codes/error-codes";
|
||||
|
||||
try {
|
||||
await prepareFileCommand(...);
|
||||
recordCommand("prepare-file", "success", elapsed);
|
||||
} catch (err) {
|
||||
const code = err instanceof ServerKeysError ? err.code : "UNKNOWN";
|
||||
recordCommand("prepare-file", "error", elapsed, code);
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
Or query the in-memory test exporter to assert on emitted metrics:
|
||||
|
||||
```ts
|
||||
await flushTestMetrics(); // from tests/setup/polyfills.ts
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `commandTotal: Counter` — total CLI commands executed.
|
||||
- `commandDurationMs: Histogram` — per-command duration (ms).
|
||||
- `ATTR_COMMAND`, `ATTR_OUTCOME`, `ATTR_ERROR_CODE` — string-literal
|
||||
attribute keys.
|
||||
- `CommandOutcome = "success" | "error"`.
|
||||
- `recordCommand(command, outcome, durationMs, errorCode?)` — record one
|
||||
observation on both instruments.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`metrics.getMeter(name, version)` returns the meter from the current
|
||||
global provider; if no provider is registered, the API package returns a
|
||||
noop meter so production code never throws on missing telemetry. Setting
|
||||
the meter provider AFTER `instruments.ts` is imported has no effect on
|
||||
this module's meter handle, so the test polyfill must register the
|
||||
provider before any `import` that transitively loads `instruments.ts`.
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import {
|
||||
ATTR_COMMAND,
|
||||
ATTR_ERROR_CODE,
|
||||
ATTR_OUTCOME,
|
||||
recordCommand,
|
||||
} from "./instruments.js";
|
||||
|
||||
describe("recordCommand", () => {
|
||||
it("emits one observation on the counter and the histogram per call", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
recordCommand("test-command-a", "success", 12.5);
|
||||
recordCommand("test-command-a", "error", 7.25, "SERVER_KEYS.ABORTED");
|
||||
const collected = await flushTestMetrics();
|
||||
const flat = collected
|
||||
.flatMap((rm) =>
|
||||
rm.scopeMetrics.flatMap((sm) =>
|
||||
sm.metrics.flatMap((m) =>
|
||||
m.dataPoints.map((dp) => ({
|
||||
name: m.descriptor.name,
|
||||
attrs: dp.attributes as Record<string, unknown>,
|
||||
}))
|
||||
)
|
||||
)
|
||||
)
|
||||
.filter((p) => p.attrs[ATTR_COMMAND] === "test-command-a");
|
||||
const counterHits = flat.filter(
|
||||
(p) => p.name === "server_keys.command_total"
|
||||
);
|
||||
const histogramHits = flat.filter(
|
||||
(p) => p.name === "server_keys.command_duration_ms"
|
||||
);
|
||||
expect(counterHits.length).toBeGreaterThanOrEqual(1);
|
||||
expect(histogramHits.length).toBeGreaterThanOrEqual(1);
|
||||
const errorPoint = flat.find((p) => p.attrs[ATTR_OUTCOME] === "error");
|
||||
expect(errorPoint?.attrs[ATTR_ERROR_CODE]).toBe("SERVER_KEYS.ABORTED");
|
||||
});
|
||||
|
||||
it("omits the error_code attribute on successful calls", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
recordCommand("test-command-b", "success", 1);
|
||||
const collected = await flushTestMetrics();
|
||||
const points = collected
|
||||
.flatMap((rm) =>
|
||||
rm.scopeMetrics.flatMap((sm) =>
|
||||
sm.metrics.flatMap((m) =>
|
||||
m.dataPoints.map((dp) => ({
|
||||
name: m.descriptor.name,
|
||||
attrs: dp.attributes as Record<string, unknown>,
|
||||
}))
|
||||
)
|
||||
)
|
||||
)
|
||||
.filter((p) => p.attrs[ATTR_COMMAND] === "test-command-b");
|
||||
expect(points.length).toBeGreaterThanOrEqual(1);
|
||||
for (const p of points) {
|
||||
expect(p.attrs[ATTR_ERROR_CODE]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/server-keys";
|
||||
const METER_VERSION = "0.1.0";
|
||||
|
||||
const meter = metrics.getMeter(METER_NAME, METER_VERSION);
|
||||
|
||||
/** Counter — total CLI commands executed. Attribute `command`, `command.outcome`. */
|
||||
export const commandTotal = meter.createCounter("server_keys.command_total", {
|
||||
description: "Total server-keys CLI commands executed",
|
||||
});
|
||||
|
||||
/** Histogram — duration of each CLI command in milliseconds. */
|
||||
export const commandDurationMs = meter.createHistogram(
|
||||
"server_keys.command_duration_ms",
|
||||
{
|
||||
description: "Duration of server-keys CLI commands in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/** Attribute key — the command's logical name (e.g. `create-root-keys`). */
|
||||
export const ATTR_COMMAND = "command" as const;
|
||||
|
||||
/** Attribute key — `"success" | "error"` outcome. */
|
||||
export const ATTR_OUTCOME = "command.outcome" as const;
|
||||
|
||||
/** Attribute key — error code on failure (`SERVER_KEYS.*`). */
|
||||
export const ATTR_ERROR_CODE = "command.error_code" as const;
|
||||
|
||||
export type CommandOutcome = "success" | "error";
|
||||
|
||||
/**
|
||||
* Record one command execution. `durationMs` is the wall-clock delta the
|
||||
* caller measures around the work; `outcome` is `"success"` for clean
|
||||
* completion and `"error"` for any thrown failure.
|
||||
*/
|
||||
export function recordCommand(
|
||||
command: string,
|
||||
outcome: CommandOutcome,
|
||||
durationMs: number,
|
||||
errorCode?: string
|
||||
): void {
|
||||
const attrs: Record<string, string> = {
|
||||
[ATTR_COMMAND]: command,
|
||||
[ATTR_OUTCOME]: outcome,
|
||||
};
|
||||
if (errorCode !== undefined) {
|
||||
attrs[ATTR_ERROR_CODE] = errorCode;
|
||||
}
|
||||
commandTotal.add(1, attrs);
|
||||
commandDurationMs.record(durationMs, attrs);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
# `prompt`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Thin, test-injectable wrappers around `@inquirer/prompts`' password prompt
|
||||
for the two interactive flows every server-keys bin needs: a single
|
||||
silent password entry (`promptForPassword`) and a confirm-twice new
|
||||
password entry (`promptForNewPassword`). The wrappers add three things
|
||||
upstream lacks: a structured `ServerKeysError(SERVER_KEYS_PASSWORD_MISMATCH)`
|
||||
on mismatch (instead of a recursive re-prompt that bypasses the bin's
|
||||
top-level handler), a typed dependency-injection seam for the prompt
|
||||
function (so tests can run without touching stdin/stdout), and explicit
|
||||
threading of `input/output/signal` into inquirer's `context` so the
|
||||
prompts compose with the bin's `AbortController` and with non-TTY test
|
||||
runners.
|
||||
|
||||
The minimum password length is exported as `MIN_PASSWORD_LENGTH` so callers
|
||||
and tests share the constant.
|
||||
|
||||
## Features
|
||||
|
||||
- `mask: "*"` non-echo entry — passwords never appear in the buffered
|
||||
terminal.
|
||||
- `validate: (input) => ...` rejects empty input and (on new-password)
|
||||
enforces a minimum length, surfacing the message through inquirer's
|
||||
re-prompt loop.
|
||||
- `SERVER_KEYS.PASSWORD_MISMATCH` raised on confirmation mismatch — the
|
||||
caller's `runFunction` wrapper translates it into a process-exit
|
||||
banner.
|
||||
- Injectable `passwordPrompt` for fully deterministic tests; injectable
|
||||
`inputStream`, `outputStream`, and `signal` for partial stubs that
|
||||
still drive the real inquirer prompt under a non-TTY harness.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
promptForPassword,
|
||||
promptForNewPassword,
|
||||
} from "@suaveplan/server-keys/prompt/prompt";
|
||||
|
||||
const existing = await promptForPassword("Enter root vault password");
|
||||
const fresh = await promptForNewPassword("Enter password for the new vault");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a fake prompt for tests, then exercise both happy and mismatch
|
||||
paths without any TTY:
|
||||
|
||||
```ts
|
||||
import { PassThrough } from "node:stream";
|
||||
import {
|
||||
promptForNewPassword,
|
||||
type PasswordPrompt,
|
||||
} from "@suaveplan/server-keys/prompt/prompt";
|
||||
|
||||
const calls: string[] = [];
|
||||
const responses = ["correcthorse", "correcthorse"];
|
||||
const fake: PasswordPrompt = async (cfg) => {
|
||||
calls.push(cfg.message);
|
||||
return responses.shift() as string;
|
||||
};
|
||||
const pw = await promptForNewPassword("Enter password", {
|
||||
passwordPrompt: fake,
|
||||
});
|
||||
expect(pw).toBe("correcthorse");
|
||||
expect(calls).toEqual(["Enter password", "Re-enter password to confirm"]);
|
||||
|
||||
// Mismatch raises a typed error:
|
||||
await expect(
|
||||
promptForNewPassword("x", {
|
||||
passwordPrompt: async () =>
|
||||
(["abcdefgh", "ABCDEFGH"].shift() ?? "") as string,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "SERVER_KEYS.PASSWORD_MISMATCH" });
|
||||
|
||||
// Production with a SIGINT-bound AbortController:
|
||||
const ac = new AbortController();
|
||||
process.once("SIGINT", () => ac.abort());
|
||||
const pw2 = await promptForPassword("Enter vault password", {
|
||||
signal: ac.signal,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `MIN_PASSWORD_LENGTH = 8` — new-password length floor.
|
||||
- `PasswordPrompt` — function type matching `@inquirer/prompts.password`.
|
||||
- `PromptOptions = { passwordPrompt?, inputStream?, outputStream?, signal? }`.
|
||||
- `promptForPassword(message?, options?) => Promise<string>`.
|
||||
- `promptForNewPassword(message?, options?) => Promise<string>` — throws
|
||||
`ServerKeysError(SERVER_KEYS_PASSWORD_MISMATCH)` on mismatch.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The validators are kept in-prompt (instead of after the await) so
|
||||
inquirer's re-prompt UX still works for empty / too-short input. The
|
||||
mismatch check lives outside the prompt so the failure can be caught by
|
||||
the top-level handler instead of trapping the user in an infinite re-ask
|
||||
loop. `buildContext` only assigns keys that were actually provided so
|
||||
inquirer's defaults apply for the rest, and returns `undefined` for the
|
||||
empty case so the prompt can resolve its own stdin / stdout / signal
|
||||
defaults.
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SERVER_KEYS_CODES,
|
||||
ServerKeysError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
MIN_PASSWORD_LENGTH,
|
||||
type PasswordPrompt,
|
||||
promptForNewPassword,
|
||||
promptForPassword,
|
||||
} from "./prompt.js";
|
||||
|
||||
function scriptedPrompt(responses: string[]): {
|
||||
prompt: PasswordPrompt;
|
||||
calls: Array<{
|
||||
message: string;
|
||||
validate?: (v: string) => boolean | string | Promise<boolean | string>;
|
||||
}>;
|
||||
} {
|
||||
const queue = [...responses];
|
||||
const calls: Array<{
|
||||
message: string;
|
||||
validate?: (v: string) => boolean | string | Promise<boolean | string>;
|
||||
}> = [];
|
||||
const prompt: PasswordPrompt = async (config) => {
|
||||
calls.push({
|
||||
message: config.message,
|
||||
...(config.validate !== undefined
|
||||
? { validate: config.validate }
|
||||
: {}),
|
||||
});
|
||||
const next = queue.shift();
|
||||
if (next === undefined) {
|
||||
throw new Error("scriptedPrompt exhausted");
|
||||
}
|
||||
return next;
|
||||
};
|
||||
return { prompt, calls };
|
||||
}
|
||||
|
||||
describe("promptForPassword", () => {
|
||||
it("returns the entered password and uses the supplied message", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { prompt, calls } = scriptedPrompt(["hunter2"]);
|
||||
const out = await promptForPassword("Enter vault password", {
|
||||
passwordPrompt: prompt,
|
||||
});
|
||||
expect(out).toBe("hunter2");
|
||||
expect(calls[0]?.message).toBe("Enter vault password");
|
||||
});
|
||||
|
||||
it("rejects empty input through the inquirer validate hook", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { prompt, calls } = scriptedPrompt(["nonempty"]);
|
||||
await promptForPassword("X", { passwordPrompt: prompt });
|
||||
const validator = calls[0]?.validate;
|
||||
expect(validator).toBeDefined();
|
||||
expect(await validator?.("")).toBe("Password cannot be empty");
|
||||
expect(await validator?.(" ")).toBe("Password cannot be empty");
|
||||
expect(await validator?.("ok")).toBe(true);
|
||||
});
|
||||
|
||||
it("forwards inputStream, outputStream, and signal into inquirer context", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let seenCtx:
|
||||
| {
|
||||
input?: NodeJS.ReadableStream;
|
||||
output?: NodeJS.WritableStream;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
| undefined;
|
||||
const prompt: PasswordPrompt = async (_cfg, ctx) => {
|
||||
seenCtx = ctx;
|
||||
return "pw";
|
||||
};
|
||||
const ac = new AbortController();
|
||||
const input = {} as NodeJS.ReadableStream;
|
||||
const output = {} as NodeJS.WritableStream;
|
||||
await promptForPassword("m", {
|
||||
passwordPrompt: prompt,
|
||||
inputStream: input,
|
||||
outputStream: output,
|
||||
signal: ac.signal,
|
||||
});
|
||||
expect(seenCtx?.input).toBe(input);
|
||||
expect(seenCtx?.output).toBe(output);
|
||||
expect(seenCtx?.signal).toBe(ac.signal);
|
||||
});
|
||||
|
||||
it("passes no context object when no streams or signal are supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let observedCtxArg: unknown = "untouched";
|
||||
const prompt: PasswordPrompt = async (_cfg, ctx) => {
|
||||
observedCtxArg = ctx;
|
||||
return "pw";
|
||||
};
|
||||
await promptForPassword("m", { passwordPrompt: prompt });
|
||||
expect(observedCtxArg).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defaults the message to 'Enter password' when omitted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { prompt, calls } = scriptedPrompt(["pw"]);
|
||||
await promptForPassword(undefined, { passwordPrompt: prompt });
|
||||
expect(calls[0]?.message).toBe("Enter password");
|
||||
});
|
||||
});
|
||||
|
||||
describe("promptForNewPassword", () => {
|
||||
it("returns the password when both entries match", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { prompt, calls } = scriptedPrompt([
|
||||
"correcthorse",
|
||||
"correcthorse",
|
||||
]);
|
||||
const pw = await promptForNewPassword("Enter new password", {
|
||||
passwordPrompt: prompt,
|
||||
});
|
||||
expect(pw).toBe("correcthorse");
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1]?.message).toBe("Re-enter password to confirm");
|
||||
});
|
||||
|
||||
it("raises SERVER_KEYS.PASSWORD_MISMATCH when entries differ", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { prompt } = scriptedPrompt(["abcdefgh", "ABCDEFGH"]);
|
||||
await expect(
|
||||
promptForNewPassword("x", { passwordPrompt: prompt })
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.PASSWORD_MISMATCH,
|
||||
});
|
||||
});
|
||||
|
||||
it("raised mismatch error is an instance of ServerKeysError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { prompt } = scriptedPrompt(["abcdefgh", "different"]);
|
||||
const err = await promptForNewPassword("x", {
|
||||
passwordPrompt: prompt,
|
||||
}).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(ServerKeysError);
|
||||
});
|
||||
|
||||
it("first-prompt validator rejects empty input and short passwords", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { prompt, calls } = scriptedPrompt(["longenough", "longenough"]);
|
||||
await promptForNewPassword("x", { passwordPrompt: prompt });
|
||||
const v = calls[0]?.validate;
|
||||
expect(v).toBeDefined();
|
||||
expect(await v?.("")).toBe("Password cannot be empty");
|
||||
expect(await v?.("short")).toBe(
|
||||
`Password must be at least ${MIN_PASSWORD_LENGTH} characters`
|
||||
);
|
||||
expect(await v?.("longenough")).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults the first message to 'Enter new password'", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { prompt, calls } = scriptedPrompt(["longenough", "longenough"]);
|
||||
await promptForNewPassword(undefined, { passwordPrompt: prompt });
|
||||
expect(calls[0]?.message).toBe("Enter new password");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Readable, Writable } from "node:stream";
|
||||
import { password as inquirerPassword } from "@inquirer/prompts";
|
||||
import {
|
||||
SERVER_KEYS_CODES,
|
||||
ServerKeysError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/** Minimum password length enforced by {@link promptForNewPassword}. */
|
||||
export const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* The inquirer password-prompt surface, narrowed to the shape this module
|
||||
* actually uses. Injecting a function with this signature lets tests run
|
||||
* deterministically without touching stdin/stdout.
|
||||
*/
|
||||
export type PasswordPrompt = (
|
||||
config: {
|
||||
message: string;
|
||||
mask?: boolean | string;
|
||||
validate?: (
|
||||
value: string
|
||||
) => boolean | string | Promise<boolean | string>;
|
||||
},
|
||||
context?: {
|
||||
input?: NodeJS.ReadableStream;
|
||||
output?: NodeJS.WritableStream;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
) => Promise<string>;
|
||||
|
||||
/** Options accepted by {@link promptForPassword} and {@link promptForNewPassword}. */
|
||||
export interface PromptOptions {
|
||||
/** Override the password-prompt implementation (defaults to `@inquirer/prompts`). */
|
||||
readonly passwordPrompt?: PasswordPrompt;
|
||||
/** Override stdin (forwarded to inquirer's `context.input`). */
|
||||
readonly inputStream?: Readable | NodeJS.ReadableStream;
|
||||
/** Override stdout (forwarded to inquirer's `context.output`). */
|
||||
readonly outputStream?: Writable | NodeJS.WritableStream;
|
||||
/** Forwarded to inquirer's `context.signal`. */
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
function buildContext(options: PromptOptions | undefined) {
|
||||
/* istanbul ignore start -- reached only from real bin entry points that call promptForPassword()/promptForNewPassword() without any options; in-process tests cannot exercise this without breaking the test runner's stdin. */
|
||||
if (!options) return undefined;
|
||||
/* istanbul ignore stop */
|
||||
const ctx: {
|
||||
input?: NodeJS.ReadableStream;
|
||||
output?: NodeJS.WritableStream;
|
||||
signal?: AbortSignal;
|
||||
} = {};
|
||||
if (options.inputStream !== undefined) {
|
||||
ctx.input = options.inputStream as NodeJS.ReadableStream;
|
||||
}
|
||||
if (options.outputStream !== undefined) {
|
||||
ctx.output = options.outputStream as NodeJS.WritableStream;
|
||||
}
|
||||
if (options.signal !== undefined) ctx.signal = options.signal;
|
||||
return Object.keys(ctx).length === 0 ? undefined : ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt once for a password with no echo. Empty input is rejected at the
|
||||
* inquirer-validate boundary (the prompt re-asks); any error propagates.
|
||||
*/
|
||||
export async function promptForPassword(
|
||||
message = "Enter password",
|
||||
options?: PromptOptions
|
||||
): Promise<string> {
|
||||
/* istanbul ignore start -- the `?? inquirerPassword` fallback only fires when called without options from a real bin entry point; in-process tests would need to mock process.stdin which breaks the runner. */
|
||||
const prompt = options?.passwordPrompt ?? inquirerPassword;
|
||||
/* istanbul ignore stop */
|
||||
const ctx = buildContext(options);
|
||||
return prompt(
|
||||
{
|
||||
message,
|
||||
mask: "*",
|
||||
validate: (input: string) => {
|
||||
if (input.trim() === "") return "Password cannot be empty";
|
||||
return true;
|
||||
},
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt twice for a new password and assert equality. The first prompt
|
||||
* enforces {@link MIN_PASSWORD_LENGTH}; the confirmation re-prompt asks
|
||||
* for the same string and raises `SERVER_KEYS.PASSWORD_MISMATCH` if the
|
||||
* second entry differs.
|
||||
*/
|
||||
export async function promptForNewPassword(
|
||||
message = "Enter new password",
|
||||
options?: PromptOptions
|
||||
): Promise<string> {
|
||||
/* istanbul ignore start -- the `?? inquirerPassword` fallback only fires when called without options from a real bin entry point; in-process tests would need to mock process.stdin which breaks the runner. */
|
||||
const prompt = options?.passwordPrompt ?? inquirerPassword;
|
||||
/* istanbul ignore stop */
|
||||
const ctx = buildContext(options);
|
||||
const first = await prompt(
|
||||
{
|
||||
message,
|
||||
mask: "*",
|
||||
validate: (input: string) => {
|
||||
if (input.trim() === "") return "Password cannot be empty";
|
||||
if (input.length < MIN_PASSWORD_LENGTH) {
|
||||
return `Password must be at least ${MIN_PASSWORD_LENGTH} characters`;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
ctx
|
||||
);
|
||||
const second = await prompt(
|
||||
{
|
||||
message: "Re-enter password to confirm",
|
||||
mask: "*",
|
||||
},
|
||||
ctx
|
||||
);
|
||||
if (first !== second) {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.PASSWORD_MISMATCH,
|
||||
"Password confirmation does not match"
|
||||
);
|
||||
}
|
||||
return first;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
RunFunctionLogger,
|
||||
RunFunctionOptions,
|
||||
} from "./run-function.js";
|
||||
export { runFunction } from "./run-function.js";
|
||||
@@ -0,0 +1,106 @@
|
||||
# `run-function`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`runFunction` is the single wrapper every server-keys CLI bin uses for its
|
||||
commander `.action(...)` body. It standardises three concerns the upstream
|
||||
package handled inconsistently: structured error reporting (always a
|
||||
`ServerKeysError` with a stable code, never a bare `Error`), OpenTelemetry
|
||||
emission (one counter increment + one histogram observation per call,
|
||||
attribute `command.outcome` set), and process-exit semantics (`exit(1)` on
|
||||
any failure, after the banner is printed). It also threads an optional
|
||||
`AbortSignal` through to the body so long-running commands can co-operate
|
||||
with `SIGINT` without changing every command signature.
|
||||
|
||||
The wrapper accepts an injectable `logger` and `exit` so tests can run
|
||||
against an in-memory logger and assert on the exit code without
|
||||
terminating the runner. Production bins call it with default options.
|
||||
|
||||
## Features
|
||||
|
||||
- Times the body with `performance.now()` and records both OTel
|
||||
instruments via `recordCommand`.
|
||||
- Normalises any thrown failure into a `ServerKeysError`; wraps non-
|
||||
`Error` throws with `cause` preserved when possible.
|
||||
- Pre-checks `signal.aborted` and short-circuits with
|
||||
`SERVER_KEYS.ABORTED` before the body runs.
|
||||
- Prints a single, structured stderr banner per failure (`[command] CODE:
|
||||
message`).
|
||||
- Returns the body's resolved value on success for non-bin callers (test
|
||||
harnesses).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { Command } from "commander";
|
||||
import { runFunction } from "@suaveplan/server-keys/run-function";
|
||||
import { createRootKeysCommand } from "@suaveplan/server-keys/commands/keys/keys";
|
||||
import { promptForNewPassword } from "@suaveplan/server-keys/prompt/prompt";
|
||||
|
||||
new Command()
|
||||
.name("create-root-keys")
|
||||
.option("-o, --output <dir>", "Output directory", "./keys")
|
||||
.action((options) =>
|
||||
runFunction("create-root-keys", async (signal) => {
|
||||
const password = await promptForNewPassword("Enter vault password");
|
||||
await createRootKeysCommand(password, {
|
||||
output: options.output,
|
||||
signal,
|
||||
});
|
||||
}),
|
||||
)
|
||||
.parse();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a custom logger and a non-terminating exit for tests, and bind a
|
||||
real `AbortController` to `SIGINT` in production:
|
||||
|
||||
```ts
|
||||
const calls: string[] = [];
|
||||
const fakeLogger = {
|
||||
info: (m: string) => calls.push(`I:${m}`),
|
||||
error: (m: string) => calls.push(`E:${m}`),
|
||||
};
|
||||
let exitCode: number | undefined;
|
||||
await runFunction("test-cmd", async () => {
|
||||
throw new ServerKeysError(SERVER_KEYS_CODES.VAULT_NOT_FOUND, "missing");
|
||||
}, {
|
||||
logger: fakeLogger,
|
||||
exit: (c) => {
|
||||
exitCode = c;
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
expect(exitCode).toBe(1);
|
||||
expect(calls[0]).toMatch(/^E:\[test-cmd] SERVER_KEYS\.VAULT_NOT_FOUND/);
|
||||
|
||||
// Production SIGINT plumbing:
|
||||
const ac = new AbortController();
|
||||
process.once("SIGINT", () => ac.abort());
|
||||
await runFunction("prepare-file", (signal) =>
|
||||
prepareFileCommand(..., { signal }),
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RunFunctionLogger = Pick<LoggerLike, "info" | "error">`
|
||||
- `RunFunctionOptions = { logger?, exit?, signal? }`
|
||||
- `runFunction<T>(command, body, options?) => Promise<T>` — the wrapper.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`performance.now()` measures wall-clock duration in milliseconds with
|
||||
sub-millisecond resolution, matching the histogram's `unit: "ms"`. The
|
||||
function re-throws the wrapped error AFTER calling `exit(1)` so tests
|
||||
that pass a non-terminating `exit` can `.catch(...)` the promise; in
|
||||
production `exit` never returns, so the re-throw is dead code that keeps
|
||||
the type signature honest. The pre-flight `signal.aborted` check uses the
|
||||
same code path as the OTel + logger + exit flow so the test surface is
|
||||
identical on the abort branch.
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SERVER_KEYS_CODES,
|
||||
ServerKeysError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { runFunction } from "./run-function.js";
|
||||
|
||||
interface CapturedLogger {
|
||||
info: (m: string) => void;
|
||||
error: (m: string) => void;
|
||||
out: string[];
|
||||
}
|
||||
|
||||
function makeLogger(): CapturedLogger {
|
||||
const out: string[] = [];
|
||||
return {
|
||||
out,
|
||||
info: (m: string) => out.push(`I:${m}`),
|
||||
error: (m: string) => out.push(`E:${m}`),
|
||||
};
|
||||
}
|
||||
|
||||
function neverExit(exitCodes: number[]) {
|
||||
return (code: number) => {
|
||||
exitCodes.push(code);
|
||||
};
|
||||
}
|
||||
|
||||
describe("runFunction", () => {
|
||||
it("returns the body's value on success and increments OTel without printing an error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const logger = makeLogger();
|
||||
const exitCodes: number[] = [];
|
||||
const result = await runFunction("ok-cmd", async () => "done", {
|
||||
logger,
|
||||
exit: neverExit(exitCodes),
|
||||
});
|
||||
expect(result).toBe("done");
|
||||
expect(exitCodes).toHaveLength(0);
|
||||
expect(logger.out.filter((line) => line.startsWith("E:"))).toHaveLength(
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps a ServerKeysError, exits 1, prints structured stderr, then re-throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const logger = makeLogger();
|
||||
const exitCodes: number[] = [];
|
||||
await expect(
|
||||
runFunction(
|
||||
"fail-cmd",
|
||||
async () => {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.VAULT_NOT_FOUND,
|
||||
"missing"
|
||||
);
|
||||
},
|
||||
{ logger, exit: neverExit(exitCodes) }
|
||||
)
|
||||
).rejects.toBeInstanceOf(ServerKeysError);
|
||||
expect(exitCodes).toEqual([1]);
|
||||
expect(logger.out[0]).toBe(
|
||||
"E:[fail-cmd] SERVER_KEYS.VAULT_NOT_FOUND: missing"
|
||||
);
|
||||
});
|
||||
|
||||
it("normalises a non-ServerKeysError Error throw into SERVER_KEYS.UNKNOWN_COMMAND", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const logger = makeLogger();
|
||||
const exitCodes: number[] = [];
|
||||
await expect(
|
||||
runFunction(
|
||||
"unk-cmd",
|
||||
async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
{ logger, exit: neverExit(exitCodes) }
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.UNKNOWN_COMMAND,
|
||||
});
|
||||
expect(exitCodes).toEqual([1]);
|
||||
expect(logger.out[0]).toContain(
|
||||
"SERVER_KEYS.UNKNOWN_COMMAND: unk-cmd failed: boom"
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps non-Error throws with String() in the message", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const logger = makeLogger();
|
||||
const exitCodes: number[] = [];
|
||||
await expect(
|
||||
runFunction(
|
||||
"str-cmd",
|
||||
async () => {
|
||||
const notErr: unknown = "string-throw";
|
||||
throw notErr;
|
||||
},
|
||||
{ logger, exit: neverExit(exitCodes) }
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: SERVER_KEYS_CODES.UNKNOWN_COMMAND,
|
||||
});
|
||||
expect(logger.out[0]).toContain("string-throw");
|
||||
});
|
||||
|
||||
it("short-circuits with SERVER_KEYS.ABORTED when signal already aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const logger = makeLogger();
|
||||
const exitCodes: number[] = [];
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
let bodyRan = false;
|
||||
await expect(
|
||||
runFunction(
|
||||
"abort-cmd",
|
||||
async () => {
|
||||
bodyRan = true;
|
||||
},
|
||||
{
|
||||
logger,
|
||||
exit: neverExit(exitCodes),
|
||||
signal: ac.signal,
|
||||
}
|
||||
)
|
||||
).rejects.toMatchObject({ code: SERVER_KEYS_CODES.ABORTED });
|
||||
expect(bodyRan).toBe(false);
|
||||
expect(exitCodes).toEqual([1]);
|
||||
expect(logger.out[0]).toBe(
|
||||
"E:[abort-cmd] aborted: SERVER_KEYS.ABORTED"
|
||||
);
|
||||
});
|
||||
|
||||
it("threads the AbortSignal into the body when not pre-aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const logger = makeLogger();
|
||||
const exitCodes: number[] = [];
|
||||
const ac = new AbortController();
|
||||
const received = await runFunction(
|
||||
"thread-cmd",
|
||||
async (signal) => signal,
|
||||
{ logger, exit: neverExit(exitCodes), signal: ac.signal }
|
||||
);
|
||||
expect(received).toBe(ac.signal);
|
||||
});
|
||||
|
||||
it("default consoleLogger writes the success banner via process.stdout", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stdoutWrites: string[] = [];
|
||||
const origStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||
try {
|
||||
(process.stdout as { write: unknown }).write = (
|
||||
chunk: string | Uint8Array
|
||||
): boolean => {
|
||||
stdoutWrites.push(
|
||||
typeof chunk === "string" ? chunk : String(chunk)
|
||||
);
|
||||
return true;
|
||||
};
|
||||
const result = await runFunction("default-cmd", async () => 42);
|
||||
expect(result).toBe(42);
|
||||
} finally {
|
||||
(process.stdout as { write: unknown }).write = origStdoutWrite;
|
||||
}
|
||||
expect(stdoutWrites.join("")).toContain("[default-cmd] ok");
|
||||
});
|
||||
|
||||
it("default consoleLogger writes via process.stderr and default exit calls process.exit on the failure path", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stderrWrites: string[] = [];
|
||||
const stdoutWrites: string[] = [];
|
||||
const exitCalls: number[] = [];
|
||||
const origStderrWrite = process.stderr.write.bind(process.stderr);
|
||||
const origStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||
const origExit = process.exit.bind(process);
|
||||
try {
|
||||
(process.stderr as { write: unknown }).write = (
|
||||
chunk: string | Uint8Array
|
||||
): boolean => {
|
||||
stderrWrites.push(
|
||||
typeof chunk === "string" ? chunk : String(chunk)
|
||||
);
|
||||
return true;
|
||||
};
|
||||
(process.stdout as { write: unknown }).write = (
|
||||
chunk: string | Uint8Array
|
||||
): boolean => {
|
||||
stdoutWrites.push(
|
||||
typeof chunk === "string" ? chunk : String(chunk)
|
||||
);
|
||||
return true;
|
||||
};
|
||||
(process as { exit: unknown }).exit = (
|
||||
code?: number | string | null
|
||||
): never => {
|
||||
exitCalls.push(typeof code === "number" ? code : -1);
|
||||
// Don't actually exit — let the wrapper re-throw.
|
||||
return undefined as never;
|
||||
};
|
||||
await expect(
|
||||
runFunction("defaults-fail-cmd", async () => {
|
||||
throw new ServerKeysError(
|
||||
SERVER_KEYS_CODES.VAULT_NOT_FOUND,
|
||||
"no defaults"
|
||||
);
|
||||
})
|
||||
).rejects.toBeInstanceOf(ServerKeysError);
|
||||
} finally {
|
||||
(process.stderr as { write: unknown }).write = origStderrWrite;
|
||||
(process.stdout as { write: unknown }).write = origStdoutWrite;
|
||||
(process as { exit: unknown }).exit = origExit;
|
||||
}
|
||||
expect(exitCalls).toEqual([1]);
|
||||
expect(stderrWrites.join("")).toContain(
|
||||
"[defaults-fail-cmd] SERVER_KEYS.VAULT_NOT_FOUND: no defaults"
|
||||
);
|
||||
});
|
||||
|
||||
it("default consoleLogger writes via process.stderr on the pre-aborted path", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stderrWrites: string[] = [];
|
||||
const exitCalls: number[] = [];
|
||||
const origStderrWrite = process.stderr.write.bind(process.stderr);
|
||||
const origExit = process.exit.bind(process);
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
(process.stderr as { write: unknown }).write = (
|
||||
chunk: string | Uint8Array
|
||||
): boolean => {
|
||||
stderrWrites.push(
|
||||
typeof chunk === "string" ? chunk : String(chunk)
|
||||
);
|
||||
return true;
|
||||
};
|
||||
(process as { exit: unknown }).exit = (
|
||||
code?: number | string | null
|
||||
): never => {
|
||||
exitCalls.push(typeof code === "number" ? code : -1);
|
||||
return undefined as never;
|
||||
};
|
||||
await expect(
|
||||
runFunction("defaults-abort-cmd", async () => {}, {
|
||||
signal: ac.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ code: SERVER_KEYS_CODES.ABORTED });
|
||||
} finally {
|
||||
(process.stderr as { write: unknown }).write = origStderrWrite;
|
||||
(process as { exit: unknown }).exit = origExit;
|
||||
}
|
||||
expect(exitCalls).toEqual([1]);
|
||||
expect(stderrWrites.join("")).toContain(
|
||||
"[defaults-abort-cmd] aborted: SERVER_KEYS.ABORTED"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import {
|
||||
SERVER_KEYS_CODES,
|
||||
ServerKeysError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { recordCommand } from "../otel/instruments.js";
|
||||
|
||||
/**
|
||||
* Logger surface accepted by {@link runFunction}. Defaults to a console
|
||||
* shim. Production deployments can inject a `@suaveplan/logger` instance
|
||||
* or any other `LoggerLike` adapter to route command output through their
|
||||
* structured-logging pipeline.
|
||||
*/
|
||||
export interface RunFunctionLogger extends Pick<LoggerLike, "info" | "error"> {}
|
||||
|
||||
/** Optional dependencies for {@link runFunction}. */
|
||||
export interface RunFunctionOptions {
|
||||
/** Logger used for the success/failure banners; defaults to console. */
|
||||
readonly logger?: RunFunctionLogger;
|
||||
/**
|
||||
* Process-exit hook used on the failure path. Defaults to
|
||||
* `process.exit`. Injected during tests so the harness can assert on
|
||||
* the exit code without terminating the runner.
|
||||
*/
|
||||
readonly exit?: (code: number) => void;
|
||||
/**
|
||||
* AbortSignal forwarded to the wrapped command body. Defaults to
|
||||
* `undefined`; production bins typically construct an AbortController
|
||||
* bound to `SIGINT`.
|
||||
*/
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const consoleLogger: RunFunctionLogger = {
|
||||
info: (message: string) => {
|
||||
process.stdout.write(`${message}\n`);
|
||||
},
|
||||
error: (message: string) => {
|
||||
process.stderr.write(`${message}\n`);
|
||||
},
|
||||
};
|
||||
|
||||
const defaultExit = (code: number): void => {
|
||||
process.exit(code);
|
||||
};
|
||||
|
||||
/**
|
||||
* Top-level `.action(...)` wrapper for every server-keys bin.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Time the body and record one OTel observation per call.
|
||||
* - Catch `ServerKeysError` and any other thrown failure, print a
|
||||
* single-line stderr banner, and trigger `exit(1)`.
|
||||
* - Translate an already-aborted `signal` into
|
||||
* `ServerKeysError(SERVER_KEYS_ABORTED)` before the body runs.
|
||||
* - Pass the (forwarded) `AbortSignal` into the body so long-running
|
||||
* operations can check it cooperatively.
|
||||
*
|
||||
* Returns the body's resolved value on success so non-bin callers (e.g.
|
||||
* tests) can still observe the result. Production bins do not use the
|
||||
* return value — they rely on `exit(1)` for the failure path.
|
||||
*/
|
||||
export async function runFunction<T>(
|
||||
command: string,
|
||||
body: (signal: AbortSignal | undefined) => Promise<T>,
|
||||
options: RunFunctionOptions = {}
|
||||
): Promise<T> {
|
||||
const logger = options.logger ?? consoleLogger;
|
||||
const exit = options.exit ?? defaultExit;
|
||||
const start = performance.now();
|
||||
if (options.signal?.aborted === true) {
|
||||
const elapsed = performance.now() - start;
|
||||
const code = SERVER_KEYS_CODES.ABORTED;
|
||||
recordCommand(command, "error", elapsed, code);
|
||||
logger.error(`[${command}] aborted: ${code}`);
|
||||
exit(1);
|
||||
throw new ServerKeysError(code, `${command} aborted before start`);
|
||||
}
|
||||
try {
|
||||
const result = await body(options.signal);
|
||||
const elapsed = performance.now() - start;
|
||||
recordCommand(command, "success", elapsed);
|
||||
logger.info(`[${command}] ok (${elapsed.toFixed(2)}ms)`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const elapsed = performance.now() - start;
|
||||
const wrapped =
|
||||
err instanceof ServerKeysError
|
||||
? err
|
||||
: new ServerKeysError(
|
||||
SERVER_KEYS_CODES.UNKNOWN_COMMAND,
|
||||
err instanceof Error
|
||||
? `${command} failed: ${err.message}`
|
||||
: `${command} failed: ${String(err)}`,
|
||||
err instanceof Error ? { cause: err } : undefined
|
||||
);
|
||||
recordCommand(command, "error", elapsed, wrapped.code);
|
||||
logger.error(`[${command}] ${wrapped.code}: ${wrapped.message}`);
|
||||
exit(1);
|
||||
throw wrapped;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../tests/setup/polyfills.js";
|
||||
import {
|
||||
getFileStatsCommand,
|
||||
prepareFileCommand,
|
||||
validateFileCommand,
|
||||
validatePacketCommand,
|
||||
} from "./commands/files/files.js";
|
||||
import { serveFileCommand } from "./commands/files/serve-file.js";
|
||||
import {
|
||||
createRootKeysCommand,
|
||||
createServerKeysCommand,
|
||||
rollServerKeysCommand,
|
||||
viewKeysCommand,
|
||||
viewPublicKeyFileCommand,
|
||||
viewVaultCommand,
|
||||
} from "./commands/keys/keys.js";
|
||||
import {
|
||||
createRootKeysChainCommand,
|
||||
createServerKeysChainCommand,
|
||||
rollServerKeysChainCommand,
|
||||
verifyKeyChainCommand,
|
||||
} from "./commands/keys/keys-chain.js";
|
||||
import { bytesToHex, hexToBytes } from "./commands/license/hex.js";
|
||||
import {
|
||||
generate,
|
||||
keygen,
|
||||
parse,
|
||||
revoke,
|
||||
validate,
|
||||
} from "./commands/license/license.js";
|
||||
import {
|
||||
SERVER_KEYS_CODES,
|
||||
ServerKeysError,
|
||||
type ServerKeysErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
import {
|
||||
ATTR_COMMAND,
|
||||
ATTR_OUTCOME,
|
||||
recordCommand,
|
||||
} from "./otel/instruments.js";
|
||||
import { promptForNewPassword, promptForPassword } from "./prompt/prompt.js";
|
||||
import { runFunction } from "./run-function/run-function.js";
|
||||
|
||||
function tmpDir(label: string): string {
|
||||
return path.join(
|
||||
tmpdir(),
|
||||
`sk-conf-${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
}
|
||||
|
||||
describe("every SERVER_KEYS_* code is thrown from a production path", () => {
|
||||
it("exercises every code in the registry", { timeout: 60000 }, async () => {
|
||||
const thrown = new Set<ServerKeysErrorCode>();
|
||||
|
||||
// INVALID_INPUT
|
||||
await createRootKeysCommand("").catch((e) => {
|
||||
if (e instanceof ServerKeysError) thrown.add(e.code);
|
||||
});
|
||||
// VAULT_NOT_FOUND — viewVaultCommand on a missing .vault
|
||||
await viewVaultCommand("/nonexistent/x.vault").catch((e) => {
|
||||
if (e instanceof ServerKeysError) thrown.add(e.code);
|
||||
});
|
||||
// FILE_NOT_FOUND — getFileStats on a missing non-vault file
|
||||
await getFileStatsCommand("/nonexistent/x.bin").catch((e) => {
|
||||
if (e instanceof ServerKeysError) thrown.add(e.code);
|
||||
});
|
||||
// ABORTED
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await createRootKeysCommand("validpw1234", {
|
||||
output: tmpDir("conf-abort"),
|
||||
signal: ac.signal,
|
||||
}).catch((e) => {
|
||||
if (e instanceof ServerKeysError) thrown.add(e.code);
|
||||
});
|
||||
// VAULT_DECRYPT_FAILED — set up a real vault, wrong password
|
||||
const dir = tmpDir("conf-vault");
|
||||
await createRootKeysCommand("right-password-here", {
|
||||
output: dir,
|
||||
});
|
||||
await viewKeysCommand(
|
||||
path.join(dir, "root.vault"),
|
||||
"wrong-password"
|
||||
).catch((e) => {
|
||||
if (e instanceof ServerKeysError) thrown.add(e.code);
|
||||
});
|
||||
// PASSWORD_MISMATCH — prompt
|
||||
const responses = ["abcdefgh", "different"];
|
||||
await promptForNewPassword("x", {
|
||||
passwordPrompt: async () => responses.shift() ?? "",
|
||||
}).catch((e) => {
|
||||
if (e instanceof ServerKeysError) thrown.add(e.code);
|
||||
});
|
||||
// CHAIN_VERIFY_FAILED — verifyKeyChainCommand with a sign file lacking sign slot
|
||||
await verifyKeyChainCommand(
|
||||
path.join(dir, "root.vault"),
|
||||
path.join(dir, "root.vault")
|
||||
).catch((e) => {
|
||||
if (e instanceof ServerKeysError) thrown.add(e.code);
|
||||
});
|
||||
// PACKET_INTEGRITY_FAILED — tamper, then serveFile
|
||||
const fileDir = tmpDir("conf-files");
|
||||
await fs.mkdir(fileDir, { recursive: true });
|
||||
await createRootKeysCommand("pktpw1234", { output: fileDir });
|
||||
const input = path.join(fileDir, "x.bin");
|
||||
await fs.writeFile(input, new Uint8Array(4096));
|
||||
const preparedDir = path.join(fileDir, "prepared");
|
||||
await prepareFileCommand(
|
||||
input,
|
||||
path.join(fileDir, "root.vault"),
|
||||
"pktpw1234",
|
||||
{ output: preparedDir, packetSize: 1024 }
|
||||
);
|
||||
const pkt = path.join(preparedDir, "packet.000001.bin");
|
||||
const text = await fs.readFile(pkt, "utf-8");
|
||||
const obj = JSON.parse(text);
|
||||
const data = Buffer.from(obj.data, "hex");
|
||||
data[0] = (data[0] ?? 0) ^ 0xff;
|
||||
obj.data = data.toString("hex");
|
||||
await fs.writeFile(pkt, JSON.stringify(obj));
|
||||
await serveFileCommand(preparedDir, {
|
||||
output: path.join(fileDir, "out.bin"),
|
||||
}).catch((e) => {
|
||||
if (e instanceof ServerKeysError) thrown.add(e.code);
|
||||
});
|
||||
// LICENSE_FAILED
|
||||
try {
|
||||
parse("NOT-A-REAL-LICENSE-KEY");
|
||||
} catch (e) {
|
||||
if (e instanceof ServerKeysError) thrown.add(e.code);
|
||||
}
|
||||
// UNKNOWN_COMMAND — runFunction wraps non-ServerKeysError
|
||||
await runFunction(
|
||||
"conf-unknown",
|
||||
async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
{ exit: () => {}, logger: { info: () => {}, error: () => {} } }
|
||||
).catch((e) => {
|
||||
if (e instanceof ServerKeysError) thrown.add(e.code);
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
await fs.rm(fileDir, { recursive: true, force: true });
|
||||
|
||||
// Assert: every code in the registry was thrown at least once.
|
||||
for (const code of Object.values(SERVER_KEYS_CODES)) {
|
||||
expect(
|
||||
thrown.has(code),
|
||||
`code ${code} was never thrown from a production path`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("OTel command counter + histogram emit per run", () => {
|
||||
it("records both instruments on every runFunction call", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
recordCommand("conformance-cmd", "success", 1.0);
|
||||
const collected = await flushTestMetrics();
|
||||
const flat = collected
|
||||
.flatMap((rm) =>
|
||||
rm.scopeMetrics.flatMap((sm) =>
|
||||
sm.metrics.flatMap((m) =>
|
||||
m.dataPoints.map((dp) => ({
|
||||
name: m.descriptor.name,
|
||||
attrs: dp.attributes as Record<string, unknown>,
|
||||
}))
|
||||
)
|
||||
)
|
||||
)
|
||||
.filter((p) => p.attrs[ATTR_COMMAND] === "conformance-cmd");
|
||||
const counter = flat.filter(
|
||||
(p) => p.name === "server_keys.command_total"
|
||||
);
|
||||
const hist = flat.filter(
|
||||
(p) => p.name === "server_keys.command_duration_ms"
|
||||
);
|
||||
expect(counter.length).toBeGreaterThan(0);
|
||||
expect(hist.length).toBeGreaterThan(0);
|
||||
expect(counter[0]?.attrs[ATTR_OUTCOME]).toBe("success");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exported function signatures (type-only)", () => {
|
||||
it("every command exports the expected callable signature", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Keys commands
|
||||
expectTypeOf(createRootKeysCommand).toBeFunction();
|
||||
expectTypeOf(createServerKeysCommand).toBeFunction();
|
||||
expectTypeOf(rollServerKeysCommand).toBeFunction();
|
||||
expectTypeOf(viewKeysCommand).toBeFunction();
|
||||
expectTypeOf(viewVaultCommand).toBeFunction();
|
||||
expectTypeOf(viewPublicKeyFileCommand).toBeFunction();
|
||||
// Chain commands
|
||||
expectTypeOf(createRootKeysChainCommand).toBeFunction();
|
||||
expectTypeOf(createServerKeysChainCommand).toBeFunction();
|
||||
expectTypeOf(rollServerKeysChainCommand).toBeFunction();
|
||||
expectTypeOf(verifyKeyChainCommand).toBeFunction();
|
||||
// Files commands
|
||||
expectTypeOf(prepareFileCommand).toBeFunction();
|
||||
expectTypeOf(validateFileCommand).toBeFunction();
|
||||
expectTypeOf(getFileStatsCommand).toBeFunction();
|
||||
expectTypeOf(validatePacketCommand).toBeFunction();
|
||||
expectTypeOf(serveFileCommand).toBeFunction();
|
||||
// License
|
||||
expectTypeOf(keygen).toBeFunction();
|
||||
expectTypeOf(generate).toBeFunction();
|
||||
expectTypeOf(validate).toBeFunction();
|
||||
expectTypeOf(parse).toBeFunction();
|
||||
expectTypeOf(revoke).toBeFunction();
|
||||
// Prompt + runFunction
|
||||
expectTypeOf(promptForPassword).toBeFunction();
|
||||
expectTypeOf(promptForNewPassword).toBeFunction();
|
||||
expectTypeOf(runFunction).toBeFunction();
|
||||
// hex
|
||||
expectTypeOf(bytesToHex).toBeFunction();
|
||||
expectTypeOf(hexToBytes).toBeFunction();
|
||||
});
|
||||
});
|
||||
|
||||
describe("registry size pins at 10", () => {
|
||||
it("the SERVER_KEYS registry exposes exactly 10 codes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Object.keys(SERVER_KEYS_CODES)).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user