Sample
bcryptjs 3.0.3: Handle bcryptjs parameter asymmetries in hash salt defaults, selective 60-character error throwing in compare, fallback random precedence, and custom Base64 encoding
Verified sample for npm bcryptjs 3.0.3: Handle bcryptjs parameter asymmetries in hash salt defaults, selective 60-character error throwing in compare…
sha256:9e53fd13103b399494311358bf94c04682964ebb0f7c6cc08d8077e3ab99f3db
This network offers one thing: a sample that builds. It ran the sample in a sandbox and kept the signed receipt. It grades nothing and warrants nothing — whether the same code builds where you are is not something it measured.
How many distinct signing keys filed a passing contract receipt. One is the author alone; more than one means somebody else built it too. A key is self-generated with nothing registered behind it, so it counts keys, not people.
MIT-0
Execution evidence
The declared environment and the signed runs are kept apart, so you can see exactly what this sample ran and where.
- Evidence basis
- Signed contract pass
- Verification receipts
- 3
- Signing keys that built it
- 3
Declared environment
node linux x64 node node npm
Verification-run environments
| Environment | Contract | Stages | Run |
|---|---|---|---|
| node 22 · linux alpine/x64 · docker ed25519:d91480838ac982c9 | PASS | compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS CONTAINER_RUN · node-typescript@1 |
2026-08-17 |
| node 22 · linux alpine/x64 · docker ed25519:2175b912ea1c23b1 | PASS | compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS CONTAINER_RUN · node-typescript@1 |
2026-08-18 |
| node 22 · linux alpine/x64 · docker ed25519:c1973797be207ac4 | PASS | compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS CONTAINER_RUN · node-typescript@1node:22-alpine@sha256:c610fcdfb1d5… |
2026-09-07 |
Case
HOW- Goal
- Handle bcryptjs parameter asymmetries in hash salt defaults, selective 60-character error throwing in compare, fallback random precedence, and custom Base64 encoding
- Packages
- Symbols
-
- bcryptjs.compareSync
- bcryptjs.compare
- bcryptjs.hashSync
- bcryptjs.hash
- bcryptjs.genSaltSync
- bcryptjs.genSalt
- bcryptjs.setRandomFallback
- bcryptjs.getRounds
- bcryptjs.getSalt
- bcryptjs.truncates
- bcryptjs.encodeBase64
- bcryptjs.decodeBase64
- Environment
- node
- Created
- 2026-08-17T15:40:43Z
Contract
- assert compareSync returns boolean false for mismatched hashes whose length is not 60, but throws an uncaught Error on 60-character invalid or unsupported revision strings, while async hash rejects with Illegal arguments unless rounds or salt is explicitly supplied
- assert hashSync defaults to 10 rounds producing a 60-character $2b$10$ hash when salt is omitted, whereas async hash without salt rejects with 'Illegal arguments: string, undefined'
- assert setRandomFallback does not override crypto.getRandomValues or node:crypto when available, only executing if native crypto is absent
- assert genSaltSync(0) evaluates 0 || 10 and produces 10 rounds, while rounds 1 to 3 are silently clamped to 4 and rounds above 31 are clamped to 31
- assert compareSync and async compare throw 'Invalid salt version' on 60-character non-bcrypt strings and 'Invalid salt revision' on $2x$, but return false on length mismatches
- assert $2a$, $2b$, and $2y$ 60-character hashes verify with compareSync, whereas legacy $2$ produces a 59-character hash that fails compareSync and causes getSalt to throw
- assert encodeBase64 and decodeBase64 use bcrypt's non-standard alphabet where index 0 is '.' rather than 'A', causing standard RFC 4648 Base64 'AAAA' to decode to [8, 32, 130]
- assert async functions return a Promise when callback is omitted, but return undefined when a callback is passed
- assert truncates measures UTF-8 byte length with boundary at 72 bytes across multibyte characters and surrogate pairs, causing passwords sharing their initial 72 bytes to collide under compareSync
- assert embedded null bytes in passwords are preserved during UTF-8 array conversion rather than terminating early, producing distinct hashes for 'secret' and 'secret\x00'
- assert getRounds returns NaN without throwing on empty or malformed strings, while getSalt strictly requires length 60 and extracts 29 characters without format validation
- assert bcryptjs 3 defines an exports map restricting subpaths so requiring 'bcryptjs/package.json' throws ERR_PACKAGE_PATH_NOT_EXPORTED
Files
- NOTES.md
- csx.json
- package-lock.json
- package.json
- src/index.mjs
- test/contract.mjs
Source
# bcryptjs 3.0.3 API Traps and Behavioral Contracts
## What `search_known_solution` Answered
`search_known_solution` returned existing published samples:
- `sha256:70583a22fda0b9a43aca2419197c61191453261a59934ff49df46aea0832ca04` ("Handle the bcryptjs 3 major revision switch where genSalt and hash default silently to 2b revisions, clamp out-of-range rounds, and restrict package subpath exports")
- `sha256:728973545e45ca5700dadbdcde59e4ceb0c9e7cf083c2ca5d448fde147d2bfd9` ("Handle bcryptjs parameter asymmetries in hash salt defaults, genSalt rounds clamping, selective 60-character error throwing in compare, and non-standard Base64 decoding")
This sample provides a comprehensive contract proving the runtime behavioral contracts and edge-case traps of `bcryptjs` 3.0.3, including `setRandomFallback` precedence, selective 60-character error throwing in password verification, synchronous vs asynchronous salt parameter defaults, custom Base64 alphabet decoding, 72-byte multibyte UTF-8 truncation and collision boundaries, embedded null-byte preservation, and subpath encapsulation.
## Pinned Release
- Package: `bcryptjs`
- Exact Resolved Version: `3.0.3`
## Observed Behaviour and Traps
1. **Selective 60-Character Error Throwing in `compareSync` and `compare`**:
When comparing against a candidate hash whose length is not 60 characters (e.g. empty string, 20 characters, 59 characters), `compareSync` returns boolean `false` without throwing. However, if the candidate string is exactly 60 characters and has a malformed version prefix or unsupported revision (such as `"x".repeat(60)` or `"$2x$10$..."`), `compareSync` throws an uncaught `Error: Invalid salt version: xx` or `Error: Invalid salt revision: x$`.
2. **Synchronous vs Asynchronous Default Salt Parameter Asymmetry**:
`bcrypt.hashSync(password)` permits omitting the second argument, automatically defaulting to 10 rounds (`$2b$10$...`). In contrast, `bcrypt.hash(password)` does not default the salt parameter; omitting it results in an unhandled Promise rejection with `Error: Illegal arguments: string, undefined`.
3. **Fallback Precedence in `setRandomFallback`**:
`bcrypt.setRandomFallback(fn)` registers a custom PRNG, but `randomBytes(len)` prioritizes `crypto.getRandomValues` (Web Crypto) and `node:crypto` (`crypto.randomBytes`) first. In Node.js or modern browser contexts where native crypto is available, `setRandomFallback` is completely bypassed during `genSaltSync()` and `genSalt()`.
4. **`genSaltSync` Rounds Clamping and Falsy 0 Fallback**:
`genSaltSync(0)` evaluates `0 || 10`, silently producing a 10-round salt (`$2b$10$`). Out-of-bounds rounds 1 through 3 are silently clamped up to 4 (`$2b$04$`), and rounds 32 and above are clamped down to 31 (`$2b$31$`). Passing a string representation like `"10"` throws `Error: Illegal arguments: string, undefined`.
5. **Custom Non-Standard Base64 Alphabet**:
bcrypt uses a custom Base64 alphabet (`./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789`) where 0x00 is encoded as `.` (index 0) rather than `A` (index 0 in standard RFC 4648). Standard RFC 4648 `"AAAA"` decodes to `[8, 32, 130]` rather than `[0, 0, 0]`.
6. **Dual API: Promise Resolution vs Callback Return Value**:
Async functions (`genSalt`, `hash`, `compare`) return an awaitable `Promise` when the callback argument is omitted, but return `undefined` when a callback function is supplied.
7. **Multibyte UTF-8 Byte Truncation and Password Collisions at 72 Bytes**:
`truncates(password)` measures the UTF-8 encoded byte length against the 72-byte limit, not string character length. Passwords containing 2-byte, 3-byte, or 4-byte UTF-8 sequences that share their initial 72 bytes produce identical Blowfish cipher states and cross-verify under `compareSync`.
8. **Embedded Null-Byte Preservation**:
Unlike C-based bcrypt libraries that truncate input strings at the first `\0` byte via `strlen`, `bcryptjs` converts the entire string to a UTF-8 byte array and appends `\0` for revisions `$2a$`, `$2b$`, and `$2y$`. Consequently, `"secret"` and `"secret\x00"` produce distinct hashes.
9. **`getRounds` vs `getSalt` Parameter Disparities**:
`getRounds(hash)` returns `NaN` without throwing on empty or malformed strings, whereas `getSalt(hash)` strictly throws `Error: Illegal hash length: ... != 60` if length is not 60. When length is 60, `getSalt` returns the first 29 characters without validating structure.
10. **Exports Map Subpath Encapsulation**:
`bcryptjs` 3 declares an `exports` map in `package.json` with conditional `import` and `require` entrypoints, preventing unexported internal subpath access like `require("bcryptjs/package.json")`.
## How the Wrong Expectation Fails
- **Loud runtime crashes**: A naive asynchronous call `await bcrypt.hash(password)` assuming optional salt defaults crashes with `Error: Illegal arguments: string, undefined`. In authentication controllers, executing `if (bcrypt.compareSync(inputPassword, storedHash))` crashes with an uncaught `Error: Invalid salt version` whenever `storedHash` is a 60-character malformed value (e.g. from an external service or legacy database column), resulting in 500 server errors rather than a 401 rejection.
- **Silent divergence**: Mocking or seeding deterministic PRNGs via `bcrypt.setRandomFallback(customPrng)` silently executes native cryptographically secure random bytes instead of the fallback function, passing tests without actually using the deterministic generator.
{"case":{"believed":"bcrypt.compareSync returns boolean false for any non-matching password hash and hash defaults to 10 rounds when the salt parameter is omitted","caseId":"case:sha256:165571a87d5a02d064601d360771eb9ced9c418301739c2e9a5d241c5b6cba23","contract":["assert compareSync returns boolean false for mismatched hashes whose length is not 60, but throws an uncaught Error on 60-character invalid or unsupported revision strings, while async hash rejects with Illegal arguments unless rounds or salt is explicitly supplied","assert hashSync defaults to 10 rounds producing a 60-character $2b$10$ hash when salt is omitted, whereas async hash without salt rejects with 'Illegal arguments: string, undefined'","assert setRandomFallback does not override crypto.getRandomValues or node:crypto when available, only executing if native crypto is absent","assert genSaltSync(0) evaluates 0 || 10 and produces 10 rounds, while rounds 1 to 3 are silently clamped to 4 and rounds above 31 are clamped to 31","assert compareSync and async compare throw 'Invalid salt version' on 60-character non-bcrypt strings and 'Invalid salt revision' on $2x$, but return false on length mismatches","assert $2a$, $2b$, and $2y$ 60-character hashes verify with compareSync, whereas legacy $2$ produces a 59-character hash that fails compareSync and causes getSalt to throw","assert encodeBase64 and decodeBase64 use bcrypt's non-standard alphabet where index 0 is '.' rather than 'A', causing standard RFC 4648 Base64 'AAAA' to decode to [8, 32, 130]","assert async functions return a Promise when callback is omitted, but return undefined when a callback is passed","assert truncates measures UTF-8 byte length with boundary at 72 bytes across multibyte characters and surrogate pairs, causing passwords sharing their initial 72 bytes to collide under compareSync","assert embedded null bytes in passwords are preserved during UTF-8 array conversion rather than terminating early, producing distinct hashes for 'secret' and 'secret\\x00'","assert getRounds returns NaN without throwing on empty or malformed strings, while getSalt strictly requires length 60 and extracts 29 characters without format validation","assert bcryptjs 3 defines an exports map restricting subpaths so requiring 'bcryptjs/package.json' throws ERR_PACKAGE_PATH_NOT_EXPORTED"],"goal":"Handle bcryptjs parameter asymmetries in hash salt defaults, selective 60-character error throwing in compare, fallback random precedence, and custom Base64 encoding","kind":"HOW","packages":["pkg:npm/bcryptjs@3.0.3"],"schemaVersion":1,"symbols":["bcryptjs.compareSync","bcryptjs.compare","bcryptjs.hashSync","bcryptjs.hash","bcryptjs.genSaltSync","bcryptjs.genSalt","bcryptjs.setRandomFallback","bcryptjs.getRounds","bcryptjs.getSalt","bcryptjs.truncates","bcryptjs.encodeBase64","bcryptjs.decodeBase64"]},"contractCommand":["node","test/contract.mjs"],"environment":{"arch":"x64","ecosystem":"npm","executionContext":"node","language":"node","os":"linux","packageManager":"npm","runtime":"node","schemaVersion":1},"license":"MIT-0","packages":["pkg:npm/bcryptjs@3.0.3"],"schemaVersion":1,"symbols":["bcryptjs.compareSync","bcryptjs.compare","bcryptjs.hashSync","bcryptjs.hash","bcryptjs.genSaltSync","bcryptjs.genSalt","bcryptjs.setRandomFallback","bcryptjs.getRounds","bcryptjs.getSalt","bcryptjs.truncates","bcryptjs.encodeBase64","bcryptjs.decodeBase64"],"verifierAdapter":"node-typescript@1"}
{
"name": "bcryptjs-contract",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bcryptjs-contract",
"version": "1.0.0",
"license": "MIT-0",
"dependencies": {
"bcryptjs": "3.0.3"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
}
}
}
{
"name": "bcryptjs-contract",
"version": "1.0.0",
"private": true,
"type": "module",
"license": "MIT-0",
"dependencies": {
"bcryptjs": "3.0.3"
}
}
import bcrypt from "bcryptjs";
/**
* bcrypt modular crypt hash format constants.
*/
export const BCRYPT_HASH_LENGTH = 60;
export const BCRYPT_SALT_LENGTH = 29;
export const DEFAULT_ROUNDS = 10;
export const MIN_ROUNDS = 4;
export const MAX_ROUNDS = 31;
/**
* Safely compares a plaintext password against a stored bcrypt hash.
*
* TRAP: bcrypt.compareSync returns `false` if `hash.length !== 60`, BUT if `hash`
* is exactly 60 characters with an invalid version/revision prefix (e.g. "x".repeat(60)
* or "$2x$10$..."), it THROWS an uncaught Error ("Invalid salt version: xx" or
* "Invalid salt revision: x$") instead of returning false.
*
* In web authentication services, this can turn malformed database entries or
* corrupted inputs into uncaught 500 crashes instead of 401 unauthorized failures.
*
* @param {unknown} password - Candidate plaintext password
* @param {unknown} hash - Stored hash candidate
* @returns {boolean} True if matching, false on mismatch, invalid length, invalid revision, or invalid types.
*/
export function safeCompare(password, hash) {
if (typeof password !== "string" || typeof hash !== "string") {
return false;
}
if (hash.length !== BCRYPT_HASH_LENGTH) {
return false;
}
try {
return bcrypt.compareSync(password, hash);
} catch {
return false;
}
}
/**
* Safely hashes a password asynchronously.
*
* TRAP: `bcrypt.hashSync(password)` defaults the salt parameter to 10 rounds when omitted,
* but `bcrypt.hash(password)` does NOT default the salt and rejects with
* `Error: Illegal arguments: string, undefined`.
*
* @param {string} password - Password to hash
* @param {number|string} [saltOrRounds=DEFAULT_ROUNDS] - Cost factor or explicit salt string
* @returns {Promise<string>}
*/
export async function safeHash(password, saltOrRounds = DEFAULT_ROUNDS) {
if (typeof password !== "string") {
throw new TypeError("Password must be a string");
}
return bcrypt.hash(password, saltOrRounds);
}
/**
* Safely extracts the 29-character salt prefix from a 60-character bcrypt hash.
*
* TRAP: `bcrypt.getSalt(hash)` throws `Error: Illegal hash length: ... != 60` if length
* is not exactly 60, but when length IS 60, it blindly returns `hash.substring(0, 29)`
* without validating whether the prefix is a valid salt format.
*
* @param {unknown} hash - Candidate hash string
* @returns {string|null} 29-character salt prefix, or null if invalid
*/
export function safeGetSalt(hash) {
if (typeof hash !== "string" || hash.length !== BCRYPT_HASH_LENGTH) {
return null;
}
try {
return bcrypt.getSalt(hash);
} catch {
return null;
}
}
/**
* Safely extracts the cost factor (rounds) from a bcrypt hash.
*
* TRAP: `bcrypt.getRounds(hash)` throws on non-string arguments, but returns `NaN`
* (without throwing) on empty or malformed strings.
*
* @param {unknown} hash - Candidate hash string
* @returns {number|null} Parsed integer rounds or null if invalid
*/
export function safeGetRounds(hash) {
if (typeof hash !== "string") {
return null;
}
const rounds = bcrypt.getRounds(hash);
return Number.isNaN(rounds) ? null : rounds;
}
/**
* Checks if a password string will be truncated at bcrypt's 72 UTF-8 byte boundary.
*
* @param {string} password - Plaintext password
* @returns {boolean} True if password UTF-8 byte length exceeds 72 bytes
*/
export function willTruncate(password) {
return bcrypt.truncates(password);
}
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import bcrypt, {
compare,
compareSync,
decodeBase64,
encodeBase64,
genSalt,
genSaltSync,
getRounds,
getSalt,
hash,
hashSync,
setRandomFallback,
truncates,
} from "bcryptjs";
import {
BCRYPT_HASH_LENGTH,
BCRYPT_SALT_LENGTH,
DEFAULT_ROUNDS,
MAX_ROUNDS,
MIN_ROUNDS,
safeCompare,
safeGetRounds,
safeGetSalt,
safeHash,
willTruncate,
} from "../src/index.mjs";
const PASSWORD = "correct-horse-battery-staple";
// ---------------------------------------------------------------------------
// 1. Manifest Resolution and Exports Map Encapsulation
// ---------------------------------------------------------------------------
// Verify exact package version
const manifestUrl = new URL("./package.json", import.meta.resolve("bcryptjs"));
const manifest = JSON.parse(readFileSync(manifestUrl, "utf8"));
assert.equal(manifest.name, "bcryptjs");
assert.equal(manifest.version, "3.0.3");
// bcryptjs 3 defines an exports map restricting subpaths
const require = createRequire(import.meta.url);
assert.throws(
() => require("bcryptjs/package.json"),
{ code: "ERR_PACKAGE_PATH_NOT_EXPORTED" },
"Subpath exports must restrict unexported internal files",
);
// ---------------------------------------------------------------------------
// 2. Synchronous vs Asynchronous Default Salt Parameter Asymmetry
// ---------------------------------------------------------------------------
// hashSync allows omitting the salt argument: defaults to 10 rounds producing a $2b$10$ hash
const syncHash = hashSync(PASSWORD);
assert.equal(syncHash.length, BCRYPT_HASH_LENGTH);
assert.ok(syncHash.startsWith("$2b$10$"));
assert.equal(getRounds(syncHash), DEFAULT_ROUNDS);
// hash() does NOT default the salt argument: calling it without salt rejects with Illegal arguments
await assert.rejects(
async () => {
await hash(PASSWORD);
},
{
name: "Error",
message: "Illegal arguments: string, undefined",
},
);
// Explicit cost factor makes async hash succeed
const asyncHash = await hash(PASSWORD, 10);
assert.equal(asyncHash.length, BCRYPT_HASH_LENGTH);
assert.ok(asyncHash.startsWith("$2b$10$"));
assert.equal(compareSync(PASSWORD, asyncHash), true);
// safeHash provides the default rounds for consistent async usage
const safeAsyncResult = await safeHash(PASSWORD);
assert.equal(safeAsyncResult.length, BCRYPT_HASH_LENGTH);
assert.ok(safeAsyncResult.startsWith("$2b$10$"));
// ---------------------------------------------------------------------------
// 3. Fallback Precedence in setRandomFallback
// ---------------------------------------------------------------------------
// In Node.js / modern JS runtimes where WebCrypto (crypto.getRandomValues) or
// node:crypto (crypto.randomBytes) is present, setRandomFallback does NOT override
// the random source — it is only reached if both built-in providers are unavailable.
let customRandomCalled = false;
setRandomFallback((len) => {
customRandomCalled = true;
return new Array(len).fill(42);
});
const generatedSaltWithFallback = genSaltSync(10);
assert.ok(generatedSaltWithFallback.startsWith("$2b$10$"));
assert.equal(
customRandomCalled,
false,
"setRandomFallback must not override built-in crypto providers when available",
);
// Clean up fallback registration
setRandomFallback(null);
// ---------------------------------------------------------------------------
// 4. genSaltSync Parameter Parsing and Silent Rounds Clamping
// ---------------------------------------------------------------------------
// Falsy rounds=0 falls back to default 10 rounds due to `rounds = rounds || 10`
const saltZero = genSaltSync(0);
assert.ok(saltZero.startsWith("$2b$10$"));
assert.equal(getRounds(saltZero + "0".repeat(31)), 10);
// Undefined also falls back to default 10 rounds
const saltUndefined = genSaltSync(undefined);
assert.ok(saltUndefined.startsWith("$2b$10$"));
// Rounds 1..3 are silently clamped up to MIN_ROUNDS (4)
const saltOne = genSaltSync(1);
assert.ok(saltOne.startsWith("$2b$04$"));
assert.equal(getRounds(saltOne + "0".repeat(31)), MIN_ROUNDS);
const saltThree = genSaltSync(3);
assert.ok(saltThree.startsWith("$2b$04$"));
assert.equal(getRounds(saltThree + "0".repeat(31)), MIN_ROUNDS);
// Rounds 31 is preserved; rounds >= 32 are clamped down to MAX_ROUNDS (31)
const salt31 = genSaltSync(31);
assert.ok(salt31.startsWith("$2b$31$"));
assert.equal(getRounds(salt31 + "0".repeat(31)), MAX_ROUNDS);
const salt32 = genSaltSync(32);
assert.ok(salt32.startsWith("$2b$31$"));
assert.equal(getRounds(salt32 + "0".repeat(31)), MAX_ROUNDS);
// Non-number argument throws rather than coercing
assert.throws(
() => genSaltSync("10"),
{
name: "Error",
message: "Illegal arguments: string, undefined",
},
);
// ---------------------------------------------------------------------------
// 5. Selective 60-Character Error Throwing in compareSync and compare
// ---------------------------------------------------------------------------
// Hashes with length !== 60 return false without throwing
assert.equal(compareSync(PASSWORD, ""), false);
assert.equal(compareSync(PASSWORD, "short-invalid"), false);
assert.equal(compareSync(PASSWORD, "x".repeat(59)), false);
assert.equal(compareSync(PASSWORD, "x".repeat(61)), false);
assert.equal(await compare(PASSWORD, "x".repeat(59)), false);
// 60-character strings with non-bcrypt format THROW uncaught Error instead of returning false
const invalid60Chars = "x".repeat(60);
assert.throws(
() => compareSync(PASSWORD, invalid60Chars),
{
name: "Error",
message: "Invalid salt version: xx",
},
);
await assert.rejects(
async () => {
await compare(PASSWORD, invalid60Chars);
},
{
name: "Error",
message: "Invalid salt version: xx",
},
);
// 60-character string starting with unsupported revision $2x$ throws revision error
const unsupportedRevision60 = "$2x$10$" + "0".repeat(53);
assert.equal(unsupportedRevision60.length, 60);
assert.throws(
() => compareSync(PASSWORD, unsupportedRevision60),
{
name: "Error",
message: "Invalid salt revision: x$",
},
);
// Non-string inputs throw Illegal arguments
assert.throws(() => compareSync(null, syncHash), /Illegal arguments/);
assert.throws(() => compareSync(PASSWORD, null), /Illegal arguments/);
assert.throws(() => compareSync(12345, syncHash), /Illegal arguments/);
// safeCompare catches both type errors and 60-character parse throws
assert.equal(safeCompare(null, syncHash), false);
assert.equal(safeCompare(PASSWORD, null), false);
assert.equal(safeCompare(PASSWORD, invalid60Chars), false);
assert.equal(safeCompare(PASSWORD, unsupportedRevision60), false);
assert.equal(safeCompare(PASSWORD, syncHash), true);
assert.equal(safeCompare("wrong-password", syncHash), false);
// ---------------------------------------------------------------------------
// 6. Backward Compatibility for $2a$, $2b$, $2y$ vs $2$ 59-Char Format
// ---------------------------------------------------------------------------
const HISTORICAL_VECTORS = [
{
plain: "",
hash: "$2a$06$DCq7YPn5Rq63x1Lad4cll.TV4S6ytwfsfvkgY8jIucDrjc8deX1s.",
rounds: 6,
},
{
plain: "abcdefghijklmnopqrstuvwxyz",
hash: "$2a$06$.rCVZVOThsIa97pEDOxvGuRRgzG64bvtJ0938xuqzv18d3ZpQhstC",
rounds: 6,
},
{
plain: "password",
hash: "$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi",
rounds: 10,
},
];
for (const vec of HISTORICAL_VECTORS) {
assert.equal(compareSync(vec.plain, vec.hash), true);
assert.equal(safeCompare(vec.plain, vec.hash), true);
assert.equal(getRounds(vec.hash), vec.rounds);
assert.equal(safeGetRounds(vec.hash), vec.rounds);
assert.equal(getSalt(vec.hash), vec.hash.substring(0, 29));
assert.equal(safeGetSalt(vec.hash), vec.hash.substring(0, 29));
}
// Legacy $2$ format without minor revision produces 59 characters (28 char salt + 31 hash)
const legacy2Salt = "$2$04$" + "0".repeat(22);
assert.equal(legacy2Salt.length, 28);
const legacy2Hash = hashSync(PASSWORD, legacy2Salt);
assert.equal(legacy2Hash.length, 59);
// Because length is 59 !== 60, compareSync returns false and getSalt throws!
assert.equal(compareSync(PASSWORD, legacy2Hash), false);
assert.throws(
() => getSalt(legacy2Hash),
{
name: "Error",
message: "Illegal hash length: 59 != 60",
},
);
assert.equal(safeGetSalt(legacy2Hash), null);
// ---------------------------------------------------------------------------
// 7. Custom Non-Standard Base64 Codec
// ---------------------------------------------------------------------------
// bcrypt Base64 alphabet: ./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789
// Byte 0x00 is encoded as '.' (index 0) rather than standard RFC 4648 'A' (index 0)
const zeroBytes = [0, 0, 0];
const bcryptEncodedZeroes = encodeBase64(zeroBytes, 3);
assert.equal(bcryptEncodedZeroes, "....");
const standardBase64Zeroes = Buffer.from(zeroBytes).toString("base64");
assert.equal(standardBase64Zeroes, "AAAA");
// decodeBase64 using bcrypt alphabet
assert.deepEqual(decodeBase64("....", 3), [0, 0, 0]);
// Passing standard RFC 4648 "AAAA" decodes 'A' as value 2 in bcrypt table -> [8, 32, 130]
assert.deepEqual(decodeBase64("AAAA", 3), [8, 32, 130]);
// ---------------------------------------------------------------------------
// 8. Dual API: Promise Resolution vs Callback Return Value
// ---------------------------------------------------------------------------
// When callback is omitted, async functions return a Promise
const genPromise = genSalt(4);
assert.ok(genPromise instanceof Promise);
const generatedAsyncSalt = await genPromise;
assert.ok(generatedAsyncSalt.startsWith("$2b$04$"));
// When callback is provided, return value is strictly undefined
let callbackFired = false;
const cbReturn = hash(PASSWORD, generatedAsyncSalt, (err, res) => {
assert.ifError(err);
assert.equal(typeof res, "string");
callbackFired = true;
});
assert.equal(cbReturn, undefined, "Passing a callback must return undefined, not a Promise");
await new Promise((resolve) => setImmediate(resolve));
assert.equal(callbackFired, true);
// ---------------------------------------------------------------------------
// 9. Multibyte UTF-8 Byte Truncation and Password Collisions at 72 Bytes
// ---------------------------------------------------------------------------
// ASCII characters (1 byte each)
const ascii72 = "K".repeat(72);
const ascii73 = "K".repeat(73);
assert.equal(truncates(ascii72), false);
assert.equal(truncates(ascii73), true);
assert.equal(willTruncate(ascii72), false);
assert.equal(willTruncate(ascii73), true);
// 2-byte UTF-8 characters: 36 chars = 72 bytes (false), 37 chars = 74 bytes (true)
const utf8_2byte_36 = "é".repeat(36);
const utf8_2byte_37 = "é".repeat(37);
assert.equal(Buffer.byteLength(utf8_2byte_36, "utf8"), 72);
assert.equal(Buffer.byteLength(utf8_2byte_37, "utf8"), 74);
assert.equal(truncates(utf8_2byte_36), false);
assert.equal(truncates(utf8_2byte_37), true);
// 3-byte UTF-8 characters: 24 chars = 72 bytes (false), 25 chars = 75 bytes (true)
const utf8_3byte_24 = "語".repeat(24);
const utf8_3byte_25 = "語".repeat(25);
assert.equal(Buffer.byteLength(utf8_3byte_24, "utf8"), 72);
assert.equal(Buffer.byteLength(utf8_3byte_25, "utf8"), 75);
assert.equal(truncates(utf8_3byte_24), false);
assert.equal(truncates(utf8_3byte_25), true);
// 4-byte UTF-8 surrogate pairs: 18 pairs (36 UTF-16 code units) = 72 bytes (false), 19 pairs = 76 bytes (true)
const utf8_4byte_18 = "😀".repeat(18);
const utf8_4byte_19 = "😀".repeat(19);
assert.equal(Buffer.byteLength(utf8_4byte_18, "utf8"), 72);
assert.equal(Buffer.byteLength(utf8_4byte_19, "utf8"), 76);
assert.equal(truncates(utf8_4byte_18), false);
assert.equal(truncates(utf8_4byte_19), true);
// Passwords sharing the same first 72 UTF-8 bytes collide under compareSync
const longPassOne = `${ascii72}-suffix-alpha`;
const longPassTwo = `${ascii72}-suffix-beta`;
const sharedHash = hashSync(longPassOne, 4);
assert.equal(compareSync(longPassOne, sharedHash), true);
assert.equal(compareSync(longPassTwo, sharedHash), true);
assert.equal(compareSync(ascii72, sharedHash), true);
assert.equal(compareSync("K".repeat(71), sharedHash), false);
// ---------------------------------------------------------------------------
// 10. Embedded Null-Byte Preservation
// ---------------------------------------------------------------------------
// Unlike C implementations that stop at \0 via strlen, bcryptjs converts the full
// string length to UTF-8 bytes and appends an extra \0 for minor >= 'a'.
// Thus embedded null bytes produce distinct hashes.
const nullA = "secret";
const nullB = "secret\x00";
const hashNullA = hashSync(nullA, 4);
const hashNullB = hashSync(nullB, 4);
assert.equal(compareSync(nullA, hashNullA), true);
assert.equal(compareSync(nullB, hashNullB), true);
assert.equal(compareSync(nullA, hashNullB), false);
assert.equal(compareSync(nullB, hashNullA), false);
// ---------------------------------------------------------------------------
// 11. getRounds vs getSalt Input Handling Disparities
// ---------------------------------------------------------------------------
// getRounds returns NaN without throwing on empty or non-bcrypt strings
assert.ok(Number.isNaN(getRounds("")));
assert.ok(Number.isNaN(getRounds("non-hash-string")));
assert.equal(safeGetRounds(""), null);
assert.equal(safeGetRounds("non-hash-string"), null);
// getRounds throws on non-string inputs
assert.throws(() => getRounds(null), /Illegal arguments: object/);
assert.throws(() => getRounds(undefined), /Illegal arguments: undefined/);
assert.equal(safeGetRounds(null), null);
// getSalt strictly enforces length === 60 and throws if not 60
assert.throws(() => getSalt(""), /Illegal hash length: 0 != 60/);
assert.throws(() => getSalt(generatedSaltWithFallback), /Illegal hash length: 29 != 60/);
assert.equal(safeGetSalt(""), null);
assert.equal(safeGetSalt(generatedSaltWithFallback), null);
// When length is 60, getSalt extracts first 29 characters without syntax checking
assert.equal(getSalt(invalid60Chars), "x".repeat(29));
assert.equal(getSalt(syncHash), syncHash.substring(0, 29));
console.log("contract ok");