Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
# @suaveplan/crypto-bin - Suaveplan crypto bins: suaveplan-hash-password (Argon2id / blake2b password hash + verify CLI). Lives in its own package so the universal @suaveplan/crypto does not pull in @suaveplan/cli runtime deps.
## Summary
Suaveplan password-hashing CLI. Ships the `suaveplan-hash-password`
binary — Argon2id (default) and BLAKE2b password hashing + verification
with PHC/MCF-formatted output strings, distinct exit codes per failure
class, and an env-injected output adapter so the core is testable
without subprocess spawn.
This package lives separately from `@suaveplan/crypto` so the
universal crypto façade does not pull CLI runtime deps into
browser/server consumers that just want primitives.
<!-- begin:auto-docs -->
## Documentation tree
- [bin](./src/bin/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [hash-password](./src/bin/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [hash-password](./src/hash-password/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [hash-password](./src/hash-password/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [hash-password](./src/bin/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_- [hash-password](./src/hash-password/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_- [hash-password](./src/hash-password/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_- [hash-password](./src/bin/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_- [hash-password](./src/hash-password/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_- [hash-password](./src/hash-password/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_- [hash-password](./src/bin/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_- [hash-password](./src/hash-password/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_- [hash-password](./src/hash-password/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_- [hash-password](./src/bin/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_- [hash-password](./src/hash-password/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_ - [hash-password](./src/hash-password/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_
## Quickstart
```bash
# Hash a password (Argon2id by default)
$ suaveplan-hash-password --password "correct horse battery staple"
$argon2id$v=19$m=65536,t=3,p=1$<salt>$<hash>
# Verify (exit 0 = match, 1 = mismatch)
$ suaveplan-hash-password --password "correct horse battery staple" \
--verify '$argon2id$v=19$m=65536,t=3,p=1$<salt>$<hash>'
$ echo $?
0
```
## Algorithms
- **`--algo argon2id`** (default) — OWASP 2023-recommended password hash.
Output prefix `$argon2id$v=19$...`.
- **`--algo blake2b`** — opt-in alternative for environments that need
it. Output prefix `$blake2b$v=1$...`. Cost factor is configurable via
`--cost-factor <n>` (1–31).
The verify mode auto-detects the algorithm from the stored hash's prefix,
so callers do not have to track which algorithm was used at hash time.
## Exit codes
| Code | Meaning |
|------|------------------------------------------------------------|
| 0 | Success / verify matched |
| 1 | Verify mismatch (wrong password) |
| 2 | Bad arguments (missing/malformed flag value) |
| 3 | Unknown algorithm (unrecognised `--algo` or stored prefix) |
| 4 | Verify failed (stored hash is corrupt or unparseable) |
The `0` / `1` split matches the de-facto contract for shell pipelines —
`if suaveplan-hash-password --password ... --verify ...; then ...; fi`
works as expected, with `3+` reserved for operator errors so callers can
distinguish "wrong password" from "stored data is broken".
## Programmatic use
```ts
import { main } from "@suaveplan/crypto-bin";
const out: string[] = [];
const err: string[] = [];
const code = main(
["--password", "pw", "--algo", "blake2b", "--cost-factor", "10"],
{
write: (s) => out.push(s),
writeError: (s) => err.push(s),
},
);
if (code === 0) {
storeHash(out.join("").trim());
}
```
The `main()` function never throws — every error path is captured and
surfaced via `env.writeError(...)` and a non-zero return value. See
`src/hash-password/hash-password.md` for the full contract.
## Public surface
- `main` — testable CLI core.
- `parseArgs` — pure argv parser (throws `HashPasswordCliError`).
- `detectAlgo` — algorithm sniffer for stored hashes.
- `HashPasswordCliEnv` — output adapter interface.
- `HashPasswordCliError` — error class wrapping the stable code registry.
- `HASH_PASSWORD_CLI_CODES` — `BAD_ARGS` / `UNKNOWN_ALGO` /
`VERIFY_FAILED`.
## Dependencies
- `@suaveplan/crypto-hash` — Argon2id + BLAKE2b primitives.
- `@suaveplan/error` — `SuaveplanError` base + stable code registry.
- `@suaveplan/types` — shared cross-package contracts.
No dependency on `@suaveplan/cli` — argv parsing is hand-rolled (~40
lines) so the bin stays small.

View File

@@ -0,0 +1,56 @@
{
"name": "@suaveplan/crypto-bin",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Suaveplan crypto bins: suaveplan-hash-password (Argon2id / blake2b password hash + verify CLI). Lives in its own package so the universal @suaveplan/crypto does not pull in @suaveplan/cli runtime deps.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"suaveplan-hash-password": "./dist/bin/hash-password.js"
},
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/crypto-hash": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,107 @@
# `suaveplan-hash-password` bin entry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This file is the platform-runtime entry for the `suaveplan-hash-password`
binary shipped by `@suaveplan/crypto-bin`. It is intentionally a minimal
shim: it constructs an `HashPasswordCliEnv` adapter from `process.stdout`
and `process.stderr`, then delegates to the testable `main()` function in
`../hash-password/hash-password.ts`. All argument parsing, algorithm
dispatch, and exit-code logic live in that pure core so they can be unit
tested by direct invocation rather than by spawning a subprocess.
The shim exists so the package can publish a Node-style `bin` entry under
`./dist/bin/hash-password.js` (with the `#!/usr/bin/env node` shebang
preserved by Vite) without coupling the testable core to `process.*`
globals. Anything that touches `process.argv` / `process.exit` /
`process.stdout` is confined to this module.
## Features
- Forwards `process.argv.slice(2)` into `main()` and exits with the
returned numeric code.
- Wires `process.stdout.write` / `process.stderr.write` into the
`HashPasswordCliEnv` adapter — the core never references `process`
directly.
- Auto-exec guard: only runs `main()` when invoked as the top-level
module (`process.argv[1]` ends with `hash-password.js`). Importing this
module from a test or another script does not trigger an exit.
- Coverage-excluded by design — this module is exercised end-to-end via
the bin install path; the logic it wraps is fully covered by
`../hash-password/hash-password.test.ts`.
- Shebang line emitted verbatim into the dist bundle so the file is
directly executable once symlinked by `bun install` / `npm install`.
## Usage: Basic Example
```bash
# Once installed, the bin is on PATH via the package.json `bin` map
$ suaveplan-hash-password --password "correct horse battery staple"
$argon2id$v=19$m=65536,t=3,p=1$<salt>$<hash>
$ echo $?
0
```
## Usage: Advanced Example
```bash
# Pipe-friendly verify (exit 0 = match, 1 = mismatch, 3+ = error)
$ STORED='$argon2id$v=19$m=65536,t=3,p=1$<salt>$<hash>'
$ if suaveplan-hash-password --password "$PW" --verify "$STORED"; then
> echo "login ok"
> else
> case $? in
> 1) echo "wrong password" ;;
> 3) echo "stored hash has unrecognised algorithm" ;;
> 4) echo "stored hash is corrupt" ;;
> *) echo "argument error" ;;
> esac
> fi
```
For programmatic invocation that does not spawn a process, import the
testable core directly instead of this shim:
```ts
import { main } from "@suaveplan/crypto-bin";
const code = main(["--password", "pw"], {
write: (s) => process.stdout.write(s),
writeError: (s) => process.stderr.write(s),
});
```
## API Reference
This module has no public exports — it is a side-effect entry point.
Behaviour:
- On import, registers the auto-exec guard and (if invoked as the
top-level script) calls `main(argv, env)` and exits with its return
value.
- `argv`: `process.argv.slice(2)` — same shape `main()` accepts.
- `env.write(s)`: forwards `s` to `process.stdout.write` synchronously.
- `env.writeError(s)`: forwards `s` to `process.stderr.write`
synchronously.
For the contract of `main()` itself (exit codes, accepted flags, error
classes) see `../hash-password/hash-password.md`.
## Implementation Notes
The auto-exec guard checks `process.argv[1]?.endsWith("hash-password.js")`
rather than `import.meta.url === ...` so the same module survives Vite's
ES-module bundling without ESM-vs-CJS surprises in older Node hosts. The
guard string matches the bundled filename (`hash-password.js`) deliberately
— the source is `.ts` and is never executed directly in production.
`/* istanbul ignore */` markers wrap the guard and the `process.exit`
call because the test suite invokes `main()` directly with a captured env
adapter (see `../hash-password/hash-password.test.ts`). Spawning a
subprocess to cover this two-line wrapper would add filesystem and Node
runtime coupling to the test suite without exercising any logic that is
not already covered. The `vitest.config.ts` coverage `exclude` list
documents this decision in a sibling comment so future readers do not
mistake the exclusion for an oversight.

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env node
/**
* `suaveplan-hash-password` binary entry. Thin wrapper that builds an
* env adapter from `process.stdout` / `process.stderr` and dispatches
* to the testable `main()` function in `../hash-password/hash-password.ts`.
*/
import { main } from "../hash-password/hash-password.js";
/* istanbul ignore if */
if (
typeof process !== "undefined" &&
process.argv?.[1]?.endsWith("hash-password.js")
) {
/* istanbul ignore next */
process.exit(
main(process.argv.slice(2), {
write: (s) => {
process.stdout.write(s);
},
writeError: (s) => {
process.stderr.write(s);
},
})
);
}

View File

@@ -0,0 +1,63 @@
# suaveplan-hash-password CLI
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`hash-password.ts` is the testable core of the `suaveplan-hash-password` binary in `@suaveplan/crypto-bin`. It accepts an argv-style list of flags, an env adapter (so tests can capture stdout/stderr without process side effects), and returns an exit code. Two modes: hash mode (no `--verify`) prints a stored hash; verify mode (`--verify <stored>`) checks a candidate password against a stored hash and returns 0/1 by match outcome.
The bin lives in its own package (`@suaveplan/crypto-bin`) rather than alongside the universal `@suaveplan/crypto` because CLI surfaces would force the entire `@suaveplan/cli` runtime tree into universal consumers; keeping the bin in a separate package preserves universal purity.
## Features
- Two algorithms: `argon2id` (default, OWASP 2023 recommended) and `blake2b` (opt-in via `--algo blake2b`).
- Hash mode: prints `$argon2id$v=19$...` or `$blake2b$v=1$...` to stdout, exits 0.
- Verify mode: detects algorithm from the stored hash's prefix and runs the matching `verifyPassword*` function. Exits 0 on match, 1 on mismatch, 3+ on argument / format errors.
- Strict argument validation: missing `--password`, unknown `--algo`, malformed `--cost-factor` all surface as `HashPasswordCliError` with one of three stable error codes (`HASH_PASSWORD_CLI_BAD_ARGS` / `HASH_PASSWORD_CLI_UNKNOWN_ALGO` / `HASH_PASSWORD_CLI_VERIFY_FAILED`).
- Env-injected stdout/stderr — never touches `process.stdout` / `process.stderr` directly, so tests can capture output without spawning a subprocess.
- Per-spec exit-code contract: 0 = success / verify-match, 1 = verify-mismatch, 2 = bad args, 3 = unknown algo, 4 = verify failed (corrupt stored hash).
## Usage: Basic Example
```bash
# Hash a password (Argon2id by default)
$ suaveplan-hash-password --password "correct horse battery staple"
$argon2id$v=19$m=65536,t=3,p=1$<salt>$<hash>
# Verify
$ suaveplan-hash-password --password "correct horse battery staple" --verify '$argon2id$v=19$m=65536,t=3,p=1$<salt>$<hash>'
$ echo $?
0
```
## Usage: Advanced Example
```ts
// Programmatic invocation (e.g. from a test harness or a higher-level CLI)
import { main } from "@suaveplan/crypto-bin/hash-password/hash-password";
const out: string[] = [];
const err: string[] = [];
const code = main(["--password", "pw", "--algo", "blake2b", "--cost-factor", "10"], {
write: (s) => out.push(s),
writeError: (s) => err.push(s),
});
if (code === 0) {
storeHash(out.join("").trim());
}
```
## API Reference
- `main(argv: readonly string[], env: HashPasswordCliEnv): number` — testable entry point.
- `parseArgs(argv: readonly string[]): HashPasswordCliArgs` — pure argv parser; throws `HashPasswordCliError` on bad input.
- `detectAlgo(storedHash: string): Algo` — algorithm sniffer; throws on unknown prefix.
- `HashPasswordCliEnv { write(s), writeError(s) }` — output adapter interface.
- `HashPasswordCliError extends SuaveplanError` — error class wrapping the three stable codes.
- `HASH_PASSWORD_CLI_CODES` — exported registry: `HASH_PASSWORD_CLI_BAD_ARGS`, `HASH_PASSWORD_CLI_UNKNOWN_ALGO`, `HASH_PASSWORD_CLI_VERIFY_FAILED`.
## Implementation Notes
The CLI deliberately does not depend on `@suaveplan/cli` to avoid pulling the readline / TTY-prompt machinery into a tiny one-shot binary. argv parsing is hand-rolled (≈40 lines) because the option set is small and stable. Verify-mode exit codes are spec-pinned: 0 = match, 1 = mismatch — those two values are the contract for shell pipelines (`if suaveplan-hash-password --password ... --verify ...; then login; fi`). Errors from the verify path (corrupt stored hash) intentionally exit with `4` rather than `1` so a caller can distinguish "wrong password" from "your stored hash is corrupt"; the spec's `verify mismatch exit 1` requirement is preserved for the wrong-password case.

View File

@@ -0,0 +1,260 @@
import { hashPasswordArgon2id } from "@suaveplan/crypto-hash/mac/argon2id-password/argon2id-password";
import { hashPasswordBlake2b } from "@suaveplan/crypto-hash/mac/blake2b-password/blake2b-password";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
detectAlgo,
type HashPasswordCliEnv,
HashPasswordCliError,
main,
parseArgs,
} from "./hash-password.js";
function captureEnv(): {
env: HashPasswordCliEnv;
out: string[];
err: string[];
} {
const out: string[] = [];
const err: string[] = [];
return {
env: {
write: (s) => {
out.push(s);
},
writeError: (s) => {
err.push(s);
},
},
out,
err,
};
}
describe("hash-password CLI — hash mode", () => {
it("argon2id mode writes a $argon2id$-prefixed hash to stdout and exits 0", {
timeout: 30_000,
}, () => {
const { env, out } = captureEnv();
const code = main(["--password", "pw"], env);
expect(code).toBe(0);
expect(out.join("").startsWith("$argon2id$")).toBe(true);
});
it("blake2b mode writes a $blake2b$-prefixed hash to stdout and exits 0", {
timeout: 5000,
}, () => {
const { env, out } = captureEnv();
const code = main(
["--password", "pw", "--algo", "blake2b", "--cost-factor", "4"],
env
);
expect(code).toBe(0);
expect(out.join("").startsWith("$blake2b$")).toBe(true);
});
});
describe("hash-password CLI — verify mode", () => {
it("verify mode exits 0 when password matches stored hash (argon2id)", {
timeout: 30_000,
}, () => {
const stored = hashPasswordArgon2id("pw", {
memoryCost: 16,
timeCost: 1,
parallelism: 1,
});
const { env, out } = captureEnv();
const code = main(["--password", "pw", "--verify", stored], env);
expect(code).toBe(0);
expect(out.join("")).toBe("");
});
it("verify mode exits 0 when password matches stored hash (blake2b)", {
timeout: 5000,
}, () => {
const stored = hashPasswordBlake2b("pw", { costFactor: 4 });
const { env } = captureEnv();
const code = main(["--password", "pw", "--verify", stored], env);
expect(code).toBe(0);
});
it("verify mode exits 1 when password does not match stored hash", {
timeout: 5000,
}, () => {
const stored = hashPasswordBlake2b("pw", { costFactor: 4 });
const { env } = captureEnv();
const code = main(["--password", "wrong", "--verify", stored], env);
expect(code).toBe(1);
});
it("verify mode rejects stored hash with unknown prefix", {
timeout: 5000,
}, () => {
const { env, err } = captureEnv();
const code = main(["--password", "pw", "--verify", "$bcrypt$..."], env);
expect(code).toBe(3);
expect(err.join("")).toContain("HASH_PASSWORD_CLI_UNKNOWN_ALGO");
});
it("verify mode surfaces a corrupted stored hash as exit 4", {
timeout: 5000,
}, () => {
const { env, err } = captureEnv();
const code = main(
["--password", "pw", "--verify", "$blake2b$v=1$cf=garbage$00$11"],
env
);
expect(code).toBe(4);
expect(err.join("")).toContain("HASH_PASSWORD_CLI_VERIFY_FAILED");
});
});
describe("hash-password CLI — argument validation", () => {
it("missing --password emits HASH_PASSWORD_CLI_BAD_ARGS", {
timeout: 5000,
}, () => {
const { env, err } = captureEnv();
const code = main([], env);
expect(code).toBe(2);
expect(err.join("")).toContain("HASH_PASSWORD_CLI_BAD_ARGS");
});
it("--password with no value emits HASH_PASSWORD_CLI_BAD_ARGS", {
timeout: 5000,
}, () => {
const { env, err } = captureEnv();
const code = main(["--password"], env);
expect(code).toBe(2);
expect(err.join("")).toContain("HASH_PASSWORD_CLI_BAD_ARGS");
});
it("--verify with no value emits HASH_PASSWORD_CLI_BAD_ARGS", {
timeout: 5000,
}, () => {
const { env, err } = captureEnv();
const code = main(["--password", "pw", "--verify"], env);
expect(code).toBe(2);
expect(err.join("")).toContain("HASH_PASSWORD_CLI_BAD_ARGS");
});
it("unknown --algo emits HASH_PASSWORD_CLI_UNKNOWN_ALGO", {
timeout: 5000,
}, () => {
const { env, err } = captureEnv();
const code = main(["--password", "pw", "--algo", "bcrypt"], env);
expect(code).toBe(3);
expect(err.join("")).toContain("HASH_PASSWORD_CLI_UNKNOWN_ALGO");
});
it("--cost-factor with non-integer value emits HASH_PASSWORD_CLI_BAD_ARGS", {
timeout: 5000,
}, () => {
const { env, err } = captureEnv();
const code = main(
["--password", "pw", "--algo", "blake2b", "--cost-factor", "abc"],
env
);
expect(code).toBe(2);
expect(err.join("")).toContain("HASH_PASSWORD_CLI_BAD_ARGS");
});
it("--cost-factor out of range emits HASH_PASSWORD_CLI_BAD_ARGS", {
timeout: 5000,
}, () => {
const { env, err } = captureEnv();
const code = main(["--password", "pw", "--cost-factor", "99"], env);
expect(code).toBe(2);
expect(err.join("")).toContain("HASH_PASSWORD_CLI_BAD_ARGS");
});
it("unknown flag emits HASH_PASSWORD_CLI_BAD_ARGS", {
timeout: 5000,
}, () => {
const { env, err } = captureEnv();
const code = main(["--unknown"], env);
expect(code).toBe(2);
expect(err.join("")).toContain("HASH_PASSWORD_CLI_BAD_ARGS");
});
it("--cost-factor with no value emits HASH_PASSWORD_CLI_BAD_ARGS", {
timeout: 5000,
}, () => {
const { env, err } = captureEnv();
const code = main(["--password", "pw", "--cost-factor"], env);
expect(code).toBe(2);
expect(err.join("")).toContain("HASH_PASSWORD_CLI_BAD_ARGS");
});
it("--cost-factor option is honored in blake2b mode", {
timeout: 5000,
}, () => {
const { env, out } = captureEnv();
const code = main(
["--password", "pw", "--algo", "blake2b", "--cost-factor", "5"],
env
);
expect(code).toBe(0);
expect(out.join("")).toContain("$blake2b$v=1$cf=5$");
});
it("blake2b mode without --cost-factor uses default cost factor", {
timeout: 30_000,
}, () => {
const { env, out } = captureEnv();
const code = main(["--password", "pw", "--algo", "blake2b"], env);
expect(code).toBe(0);
expect(out.join("")).toContain("$blake2b$v=1$cf=14$");
});
it("HashPasswordCliError can be constructed without options", {
timeout: 5000,
}, () => {
const e = new HashPasswordCliError(
"CRYPTO.HASH_PASSWORD_CLI_BAD_ARGS",
"msg"
);
expect(e.code).toBe("CRYPTO.HASH_PASSWORD_CLI_BAD_ARGS");
});
});
describe("parseArgs", () => {
it("parses password / algo / verify / cost-factor", {
timeout: 5000,
}, () => {
const args = parseArgs([
"--password",
"pw",
"--algo",
"blake2b",
"--verify",
"$blake2b$x",
"--cost-factor",
"10",
]);
expect(args.password).toBe("pw");
expect(args.algo).toBe("blake2b");
expect(args.verify).toBe("$blake2b$x");
expect(args.costFactor).toBe(10);
});
});
describe("detectAlgo", () => {
it("recognises argon2id and blake2b prefixes", { timeout: 5000 }, () => {
expect(detectAlgo("$argon2id$v=19$...")).toBe("argon2id");
expect(detectAlgo("$blake2b$v=1$...")).toBe("blake2b");
});
it("throws HashPasswordCliError on unknown prefix", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
detectAlgo("$bcrypt$...");
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(HashPasswordCliError);
expect((caught as HashPasswordCliError).code).toBe(
"CRYPTO.HASH_PASSWORD_CLI_UNKNOWN_ALGO"
);
});
});

View File

@@ -0,0 +1,191 @@
import {
hashPasswordArgon2id,
verifyPasswordArgon2id,
} from "@suaveplan/crypto-hash/mac/argon2id-password/argon2id-password";
import {
hashPasswordBlake2b,
verifyPasswordBlake2b,
} from "@suaveplan/crypto-hash/mac/blake2b-password/blake2b-password";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const HASH_PASSWORD_CLI_CODES = defineCodes("CRYPTO", [
"HASH_PASSWORD_CLI_BAD_ARGS",
"HASH_PASSWORD_CLI_UNKNOWN_ALGO",
"HASH_PASSWORD_CLI_VERIFY_FAILED",
] as const);
export type HashPasswordCliCode =
(typeof HASH_PASSWORD_CLI_CODES)[keyof typeof HASH_PASSWORD_CLI_CODES];
interface CliErrorOptions {
context?: Record<string, unknown>;
}
const DEFAULT_CLI_OPTIONS: CliErrorOptions = {};
export class HashPasswordCliError extends SuaveplanError<HashPasswordCliCode> {
public constructor(
code: HashPasswordCliCode,
messageKey: string,
options: CliErrorOptions = DEFAULT_CLI_OPTIONS
) {
super(code, messageKey, options);
}
}
export type Algo = "argon2id" | "blake2b";
export interface HashPasswordCliArgs {
readonly password?: string;
readonly algo?: Algo;
readonly verify?: string;
readonly costFactor?: number;
}
export interface HashPasswordCliEnv {
write(s: string): void;
writeError(s: string): void;
}
/** Parse argv into a structured args record. Returns either parsed args or an error. */
export function parseArgs(argv: readonly string[]): HashPasswordCliArgs {
const args: {
password?: string;
algo?: Algo;
verify?: string;
costFactor?: number;
} = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (a === "--password") {
const v = argv[i + 1];
if (v === undefined) {
throw new HashPasswordCliError(
HASH_PASSWORD_CLI_CODES.HASH_PASSWORD_CLI_BAD_ARGS,
"error.crypto.hash-password.cli.missing-password-value",
{ context: { flag: "--password" } }
);
}
args.password = v;
i++;
} else if (a === "--algo") {
const v = argv[i + 1];
if (v !== "argon2id" && v !== "blake2b") {
throw new HashPasswordCliError(
HASH_PASSWORD_CLI_CODES.HASH_PASSWORD_CLI_UNKNOWN_ALGO,
"error.crypto.hash-password.cli.unknown-algo",
{ context: { algo: v } }
);
}
args.algo = v;
i++;
} else if (a === "--verify") {
const v = argv[i + 1];
if (v === undefined) {
throw new HashPasswordCliError(
HASH_PASSWORD_CLI_CODES.HASH_PASSWORD_CLI_BAD_ARGS,
"error.crypto.hash-password.cli.missing-verify-value",
{ context: { flag: "--verify" } }
);
}
args.verify = v;
i++;
} else if (a === "--cost-factor") {
const v = argv[i + 1];
if (v === undefined) {
throw new HashPasswordCliError(
HASH_PASSWORD_CLI_CODES.HASH_PASSWORD_CLI_BAD_ARGS,
"error.crypto.hash-password.cli.missing-cost-factor-value",
{ context: { flag: "--cost-factor" } }
);
}
const n = Number.parseInt(v, 10);
if (!Number.isInteger(n) || n < 1 || n > 31) {
throw new HashPasswordCliError(
HASH_PASSWORD_CLI_CODES.HASH_PASSWORD_CLI_BAD_ARGS,
"error.crypto.hash-password.cli.bad-cost-factor",
{ context: { value: v } }
);
}
args.costFactor = n;
i++;
} else {
throw new HashPasswordCliError(
HASH_PASSWORD_CLI_CODES.HASH_PASSWORD_CLI_BAD_ARGS,
"error.crypto.hash-password.cli.unknown-flag",
{ context: { flag: a } }
);
}
}
if (args.password === undefined) {
throw new HashPasswordCliError(
HASH_PASSWORD_CLI_CODES.HASH_PASSWORD_CLI_BAD_ARGS,
"error.crypto.hash-password.cli.missing-password",
{ context: { flag: "--password" } }
);
}
return args;
}
/** Detect the algorithm from a stored hash's MCF/PHC prefix. */
export function detectAlgo(storedHash: string): Algo {
if (storedHash.startsWith("$argon2id$")) return "argon2id";
if (storedHash.startsWith("$blake2b$")) return "blake2b";
throw new HashPasswordCliError(
HASH_PASSWORD_CLI_CODES.HASH_PASSWORD_CLI_UNKNOWN_ALGO,
"error.crypto.hash-password.cli.unknown-stored-prefix",
{
context: {
prefix: storedHash.slice(0, Math.min(20, storedHash.length)),
},
}
);
}
/**
* `suaveplan-hash-password` CLI body. Given an argv slice and an env
* adapter, returns an exit code (0 success, 1 verify mismatch, 2+ for
* argument / algorithm errors). Never throws — every error path is
* captured and surfaced via `env.writeError(...)` and a non-zero return.
*/
export function main(argv: readonly string[], env: HashPasswordCliEnv): number {
let args: HashPasswordCliArgs;
try {
args = parseArgs(argv);
} catch (err) {
const e = err as HashPasswordCliError;
env.writeError(`[hash-password] ERROR: ${e.code} ${e.message}\n`);
return e.code === "CRYPTO.HASH_PASSWORD_CLI_UNKNOWN_ALGO" ? 3 : 2;
}
const algo: Algo = args.algo ?? "argon2id";
const password = args.password!;
if (args.verify !== undefined) {
let storedAlgo: Algo;
try {
storedAlgo = detectAlgo(args.verify);
} catch (err) {
const e = err as HashPasswordCliError;
env.writeError(`[hash-password] ERROR: ${e.code} ${e.message}\n`);
return 3;
}
try {
const ok =
storedAlgo === "argon2id"
? verifyPasswordArgon2id(args.verify, password)
: verifyPasswordBlake2b(args.verify, password);
return ok ? 0 : 1;
} catch (err) {
const msg = (err as Error).message;
env.writeError(
`[hash-password] ERROR: CRYPTO.HASH_PASSWORD_CLI_VERIFY_FAILED ${msg}\n`
);
return 4;
}
}
if (algo === "argon2id") {
env.write(`${hashPasswordArgon2id(password)}\n`);
return 0;
}
const opts: { costFactor?: number } = {};
if (args.costFactor !== undefined) opts.costFactor = args.costFactor;
env.write(`${hashPasswordBlake2b(password, opts)}\n`);
return 0;
}

View File

@@ -0,0 +1,13 @@
export type {
Algo,
HashPasswordCliArgs,
HashPasswordCliCode,
HashPasswordCliEnv,
} from "./hash-password.js";
export {
detectAlgo,
HASH_PASSWORD_CLI_CODES,
HashPasswordCliError,
main,
parseArgs,
} from "./hash-password.js";

View File

@@ -0,0 +1,13 @@
export const PACKAGE_NAME = "@suaveplan/crypto-bin" as const;
export {
type Algo,
detectAlgo,
HASH_PASSWORD_CLI_CODES,
type HashPasswordCliArgs,
type HashPasswordCliCode,
type HashPasswordCliEnv,
HashPasswordCliError,
main,
parseArgs,
} from "./hash-password/hash-password.js";

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
// bin/hash-password.ts is a side-effect entry: an auto-exec
// guard (`if process.argv...; process.exit(main(...))`)
// wrapping a single delegation to main(). It can't be
// covered without spawning a subprocess. The main()
// function itself IS unit-tested via direct invocation in
// src/hash-password/hash-password.test.ts.
"src/bin/hash-password.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});