Initial Commit
This commit is contained in:
105
packages/security-stack/crypto/crypto-bin/README.md
Normal file
105
packages/security-stack/crypto/crypto-bin/README.md
Normal 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.
|
||||
|
||||
56
packages/security-stack/crypto/crypto-bin/package.json
Normal file
56
packages/security-stack/crypto/crypto-bin/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
13
packages/security-stack/crypto/crypto-bin/src/index.ts
Normal file
13
packages/security-stack/crypto/crypto-bin/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,7 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
12
packages/security-stack/crypto/crypto-bin/tsconfig.lib.json
Normal file
12
packages/security-stack/crypto/crypto-bin/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
45
packages/security-stack/crypto/crypto-bin/vite.config.ts
Normal file
45
packages/security-stack/crypto/crypto-bin/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
36
packages/security-stack/crypto/crypto-bin/vitest.config.ts
Normal file
36
packages/security-stack/crypto/crypto-bin/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user