diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4bdf6049..0d378c5e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -309,6 +309,45 @@ jobs: - name: Test bindings run: yarn workspaces foreach -A -j 1 run test + test-bcrypt-supported-node: + name: Test bcrypt on supported Node ${{ matrix.node }} + needs: + - build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ['10', '12'] + steps: + - uses: actions/checkout@v7 + + - name: Setup node for test dependencies + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: yarn + + - name: Install test dependencies + run: yarn install --immutable --mode=skip-build + + - name: Setup node + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node }} + + - name: Download native bindings + uses: actions/download-artifact@v8 + with: + name: bindings-x86_64-unknown-linux-gnu + path: packages + + # Avoid the modern development toolchain when testing the published API. + - name: Test bcrypt public API and stored hashes + run: node packages/bcrypt/__tests__/supported-node.cjs + + - name: Test locally imported cancellation polyfill + run: node packages/bcrypt/__tests__/polyfill-cancellation.cjs + test-linux-x64-gnu-binding: name: Test bindings on Linux-x64-gnu - node@${{ matrix.node }} needs: @@ -558,6 +597,7 @@ jobs: - test-linux-aarch64-musl-binding - test-linux-arm-gnueabihf-binding - test-macOS-windows-binding + - test-bcrypt-supported-node - test-wasi-nodejs steps: - uses: actions/checkout@v7 diff --git a/.yarnrc.yml b/.yarnrc.yml index 5f3f0b88..9a14d0c4 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -5,6 +5,9 @@ npmRegistryServer: 'https://registry.npmjs.org/' yarnPath: .yarn/releases/yarn-4.18.0.cjs npmPreapprovedPackages: + # Pinned previous-release compatibility tests, including this repo's platform binaries. + - '@node-rs/bcrypt@1.10.9' + - '@node-rs/bcrypt-*@1.10.9' - '@napi-rs/*' - oxlint - '@oxlint/*' diff --git a/package.json b/package.json index 95cb8be3..70a18410 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,8 @@ "ts" ], "files": [ - "packages/*/__test__/**/*.spec.ts" + "packages/*/__test__/**/*.spec.ts", + "packages/bcrypt/__tests__/**/*.spec.ts" ], "nodeArguments": [ "--import", diff --git a/packages/bcrypt/MIGRATION.md b/packages/bcrypt/MIGRATION.md new file mode 100644 index 00000000..d6e32984 --- /dev/null +++ b/packages/bcrypt/MIGRATION.md @@ -0,0 +1,44 @@ +# Migrating to bcrypt 2 + +Stored password hashes remain usable after migrating API calls. Verification continues to use the salt and cost embedded in each stored hash, including hashes created through older custom-salt bugs. Do not rewrite hashes, replace their prefixes, or reset passwords for this upgrade. + +## Calls use options objects + +| 1.x | 2.x | +| -------------------------------------- | ------------------------------------------------- | +| `hash(password, 12)` | `hash(password, { cost: 12 })` | +| `hashSync(password, 12, rawSalt)` | `hashSync(password, { cost: 12, salt: rawSalt })` | +| `genSalt(12, '2b', signal)` | `genSalt({ cost: 12, version: '2b', signal })` | +| `verify(password, storedHash, signal)` | `verify(password, storedHash, { signal })` | +| `verify(password, storedHash)` | Unchanged | +| `compareSync(password, storedHash)` | Unchanged | + +Omit unused options instead of passing `null`. Unsupported positional arguments, unknown options, and a bare signal where options are expected fail explicitly. All async validation errors now reject the returned Promise, so use `await` inside `try/catch` or attach `.catch()`. + +## Salt creation is corrected + +Raw salts must be exactly 16 bytes. String salts must be canonical encoded salts containing their cost and version, for example the result of `genSalt({ cost: 12 })`. Do not also specify cost/version when supplying an encoded salt. Generated salts now contain 29 characters without `==` padding. + +Old versions treated string salts as raw text and clipped or zero-padded them. Correct interpretation intentionally changes newly computed output. Applications that authenticate by recomputing a hash from a separately saved original salt should switch to `verify(password, storedHash)`. The stored hash contains the actual salt used previously; it needs no conversion. Automatic random salts are the default for new hashes. + +Costs must be finite integers in 4–31. Fractional and overflowing values are rejected instead of truncated or wrapped. Existing hashes still use their embedded effective costs; there is no new default verification cost ceiling. + +Creation no longer accepts `2x`, including inside encoded salt strings. Verification retains its previous prefix handling. That existing behavior does not implement the historical sign-extension algorithm of genuine `2x` hashes; this release neither reinterprets those hashes nor tries multiple algorithms. + +## Password bytes and login compatibility + +Default 72-byte truncation remains in both hash creation and verification. This preserves existing logins and ordinary rehash-on-login flows. No compatibility flag is necessary. UTF-8 string encoding, raw byte inputs, embedded NULs, and empty passwords keep their previous meaning. + +`rejectLongPasswords: true` is an optional creation policy. It rejects more than 72 bytes and accepts exactly 72. Enabling it can affect enrollment or rehashing of long passwords; verification never adopts it automatically. Stored bcrypt strings do not record whether the original password was truncated. + +Invalid UTF-8 bytes supplied as the stored hash now return `false`, consistently with other malformed hash data. Successfully accepted noncanonical encodings, including the `+4` cost spelling, remain accepted by verification but are rejected for new salt creation. + +## Cancellation and byte ownership + +Async calls copy mutable byte inputs before returning. Changing a password, raw salt, or stored-hash array afterward no longer changes the queued operation. + +Put `signal` in async options. A pre-aborted signal rejects before native work is queued. Later abort rejects the pending public Promise with `AbortError`; queued work is cancelled where possible, while running native computation may finish with its result discarded. Existing signal handlers are preserved, shared/reused signals work independently, and abort after observed completion has no effect. + +Native signals and compatible signals from locally imported polyfills are accepted. On Node 10 and 12, import an `AbortController` polyfill and pass `controller.signal`; neither constructor needs to be installed globally. + +Install the matching 2.x platform packages together with the root package. A backend contract check rejects stale binaries rather than silently interpreting new calls with old native arguments. diff --git a/packages/bcrypt/README.md b/packages/bcrypt/README.md index 869b7171..55b467c6 100644 --- a/packages/bcrypt/README.md +++ b/packages/bcrypt/README.md @@ -8,32 +8,38 @@ ## Usage ```typescript -export const DEFAULT_COST: 12 - -export function hashSync(password: string | Buffer, round?: number): string -export function hash(password: string | Buffer, round?: number): Promise -export function verifySync(password: string | Buffer, hash: string | Buffer): boolean -export function verify(password: string | Buffer, hash: string | Buffer): Promise -/** - * The same with `verifySync` - */ -export function compareSync(password: string | Buffer, hash: string | Buffer): boolean -/** - * The same with `verify` - */ -export function compare(password: string | Buffer, hash: string | Buffer): Promise - -export type Version = '2a' | '2x' | '2y' | '2b' -/** - * @param version default '2b' - */ -export function genSaltSync(round: number, version?: Version): string -/** - * @param version default '2b' - */ -export function genSalt(round: number, version?: Version): Promise +import { hash, hashSync, verify, verifySync, genSalt, compare } from '@node-rs/bcrypt' + +const storedHash = await hash('password', { cost: 12 }) +await verify('password', storedHash) // true + +const salt = await genSalt({ cost: 12 }) +const withExplicitSalt = hashSync('password', { salt }) +verifySync('password', withExplicitSalt) // true +await compare('password', storedHash) // alias of verify ``` +`hash` and `hashSync` accept a string or `Uint8Array` password and an options object. `cost` defaults to 12 and must be an integer from 4 through 31. Omitted salts use 16 random bytes. `salt` can be exactly 16 raw bytes or a canonical 29-character encoded bcrypt salt; an encoded salt supplies its own cost and version, so overrides are rejected. Creation supports `2a`, `2b` (default), and `2y`. + +`genSalt` and `genSaltSync` accept `{ cost?, version? }`. `verify` and `verifySync` take the password first and the complete stored hash second. Both password and hash accept `Uint8Array`, including `Buffer`. `compare` and `compareSync` are exact aliases. See [the declarations](index.d.ts) for the complete API. + +Bcrypt uses at most 72 password bytes. That default is unchanged for hashing and verification, including existing database hashes. To reject longer passwords when creating a hash, explicitly set `rejectLongPasswords: true`. This checks bytes, not JavaScript string length, and accepts exactly 72 bytes. Verification has no length-policy option. + +Async functions accept `signal` inside their options object and report errors through Promise rejection. Synchronous functions throw. Invalid call shapes use `TypeError`; invalid creation values use `RangeError`. Wrong passwords and malformed stored hashes return `false`. Verification retains existing accepted encodings independently of the stricter creation parser. + +```typescript +await hash('password', { cost: 12, signal: controller.signal }) +await verify('password', storedHash, { signal: controller.signal }) +``` + +An already-aborted signal prevents queueing. Aborting a pending operation rejects with `name: 'AbortError'`; native work that has already started may finish in the background. Shared and reused signals are supported without replacing existing handlers. The first observed completion or abort determines the result. + +On Node versions without built-in cancellation, pass a signal from a locally imported `AbortController` polyfill. No global installation is required. Signals must provide a boolean `aborted` property and `addEventListener`/`removeEventListener` methods for the `abort` event; see `AbortSignalLike` in the declarations. + +The browser entry uses the same public wrapper and aliases over WASI. Published packages include the matching WASI backend as an optional dependency. Platform-specific backend packages and `binding.js` are internal interfaces; import the public package entry. + +Upgrading from 1.x requires call-site changes. **Existing stored hashes do not require rewriting or password resets.** See [migration instructions](MIGRATION.md). + ## Bench ``` diff --git a/packages/bcrypt/__tests__/bcrypt.spec.ts b/packages/bcrypt/__tests__/bcrypt.spec.ts index 8ef7f505..8228d278 100644 --- a/packages/bcrypt/__tests__/bcrypt.spec.ts +++ b/packages/bcrypt/__tests__/bcrypt.spec.ts @@ -1,61 +1,193 @@ +import { readFileSync } from 'node:fs' import test from 'ava' - +import bcryptjs from 'bcryptjs' +import previous from 'bcrypt-previous' import { - verifySync, - compareSync, + DEFAULT_COST, + genSalt, + genSaltSync, + hash, + hashSync, verify, + verifySync, compare, - hash, - genSaltSync, - genSalt, - hashSync as bcryptHashSync, -} from '../index' + compareSync, +} from '../index.js' -const { hashSync } = require('bcryptjs') +const rawSalt = Buffer.from('0123456789abcdef') +const fixture = (name: string): T => + JSON.parse(readFileSync(new URL(`./fixtures/${name}.json`, import.meta.url), 'utf8')) +const view = (bytes: Uint8Array) => Uint8Array.from([99, ...bytes, 100]).subarray(1, bytes.length + 1) -const fx = Buffer.from('bcrypt-test-password') +test('generated salts compose with hashing and independent implementations', async (t) => { + t.is(DEFAULT_COST, 12) + t.regex(genSaltSync(), /^\$2b\$12\$/) + for (const version of ['2a', '2b', '2y'] as const) { + for (const salt of [genSaltSync({ cost: 4, version }), await genSalt({ cost: 4, version })]) { + t.is(salt.length, 29) + t.true(salt.startsWith(`$${version}$04$`)) + const expected = bcryptjs.hashSync('password', salt) + t.is(hashSync('password', { salt }), expected) + t.is(await hash('password', { salt }), expected) + t.true(await previous.verify('password', expected)) + t.false(previous.verifySync('wrong', expected)) + } + } + t.is(hashSync('password', { cost: 4, salt: rawSalt }), bcryptjs.hashSync('password', '$2b$04$KBCwKxOzLha2MUDgW0PjXe')) +}) -const hashedPassword = hashSync(fx.toString('utf8'), 10) +test('creation validates costs before integer conversion', async (t) => { + for (const cost of [3, 32, 4.9, 4294967300, -4294967292, NaN, Infinity, -Infinity]) { + t.throws(() => genSaltSync({ cost }), { instanceOf: RangeError }) + t.throws(() => hashSync('password', { cost }), { instanceOf: RangeError }) + await t.throwsAsync(genSalt({ cost }), { instanceOf: RangeError }) + await t.throwsAsync(hash('password', { cost }), { instanceOf: RangeError }) + } + // Validate the upper mathematical bound without computing a cost-31 hash. + t.true(genSaltSync({ cost: 31 }).startsWith('$2b$31$')) +}) + +test('creation requires exact raw or canonical encoded salts', async (t) => { + for (const length of [0, 15, 17]) { + t.throws(() => hashSync('password', { cost: 4, salt: new Uint8Array(length) }), { instanceOf: RangeError }) + } + const salt = '$2b$04$KBCwKxOzLha2MUDgW0PjXe' + for (const invalid of [ + '', + 'hello', + `${salt}==`, + salt.replace('2b', '2x'), + salt.replace('04', '+4'), + salt.slice(0, -1) + 'f', + salt.replace('K', 'é'), + ]) { + t.throws(() => hashSync('password', { salt: invalid }), { instanceOf: RangeError }) + await t.throwsAsync(hash('password', { salt: invalid }), { instanceOf: RangeError }) + } + // @ts-expect-error Encoded salts cannot be combined with cost overrides. + t.throws(() => hashSync('password', { salt, cost: 4 }), { instanceOf: RangeError }) + // @ts-expect-error Encoded salts cannot be combined with version overrides. + await t.throwsAsync(hash('password', { salt, version: '2b' }), { instanceOf: RangeError }) + // @ts-expect-error 2x generation is removed. + await t.throwsAsync(genSalt({ version: '2x' }), { instanceOf: RangeError }) +}) -test('genSaltSync should return a string', (t) => { - t.is(typeof genSaltSync(10), 'string') - t.is(typeof genSaltSync(10, '2a'), 'string') - t.is(typeof genSaltSync(10, '2b'), 'string') - t.is(typeof genSaltSync(10, '2y'), 'string') - t.is(typeof genSaltSync(10, '2x'), 'string') - t.throws(() => genSaltSync(10, 'invalid' as any)) +test('removed call shapes fail clearly and async validation always rejects', async (t) => { + // @ts-expect-error Positional calls are intentionally removed. + t.throws(() => hashSync('password', 4, rawSalt), { instanceOf: TypeError }) + // @ts-expect-error Positional calls are intentionally removed. + const invalid = hash('password', 4, rawSalt) + t.true(invalid instanceof Promise) + await t.throwsAsync(invalid, { instanceOf: TypeError }) + // @ts-expect-error Positional generator arguments are removed. + await t.throwsAsync(genSalt(4), { instanceOf: TypeError }) + // @ts-expect-error A bare signal must not silently become empty options. + await t.throwsAsync(verify('password', 'hash', new AbortController().signal), { instanceOf: TypeError }) + // @ts-expect-error No verification salt override. + await t.throwsAsync(verify('password', 'hash', { salt: rawSalt }), { instanceOf: TypeError }) + // @ts-expect-error Invalid runtime input must reject instead of throwing before a Promise. + await t.throwsAsync(hash(null), { instanceOf: TypeError }) + // @ts-expect-error Unknown keys must not silently choose the default cost. + await t.throwsAsync(hash('password', { rounds: 4 }), { instanceOf: TypeError }) }) -test('genSalt should return a string', async (t) => { - t.is(typeof (await genSalt(10)), 'string') - t.is(typeof (await genSalt(10, '2a')), 'string') - t.is(typeof (await genSalt(10, '2b')), 'string') - t.is(typeof (await genSalt(10, '2y')), 'string') - t.is(typeof (await genSalt(10, '2x')), 'string') - t.throws(() => genSalt(10, 'invalid' as any)) +test('default truncation is preserved; strict creation accepts exactly 72 bytes', async (t) => { + for (const password of ['a'.repeat(71), 'a'.repeat(72), 'é'.repeat(36)]) { + const result = hashSync(password, { cost: 4, salt: rawSalt, rejectLongPasswords: true }) + t.true(verifySync(password, result)) + t.is(await hash(password, { cost: 4, salt: rawSalt, rejectLongPasswords: true }), result) + } + for (const password of ['a'.repeat(73), 'é'.repeat(37)]) { + t.throws(() => hashSync(password, { cost: 4, rejectLongPasswords: true }), { instanceOf: RangeError }) + await t.throwsAsync(hash(password, { cost: 4, rejectLongPasswords: true }), { instanceOf: RangeError }) + const old = previous.hashSync(password, 4) + t.true(await verify(password, old)) + const rehashed = await hash(password, { cost: 4 }) + t.true(previous.verifySync(password, rehashed)) + t.true(await verify(password, rehashed)) + } + const result = hashSync('a'.repeat(72), { cost: 4 }) + t.true(await verify('a'.repeat(72) + 'different suffix', result)) }) -test('verifySync hashed password from bcrypt should be true', (t) => { - t.true(verifySync(fx, hashedPassword)) +test('published historical hashes retain authentication and byte view handling', async (t) => { + const { fixtures } = fixture<{ + fixtures: { generatorVersion: string; passwordText?: string; passwordHex: string; hash: string }[] + }>('historical-hash-fixtures') + for (const row of fixtures) { + const bytes = Buffer.from(row.passwordHex, 'hex') + const password = row.passwordText ?? bytes + const label = `${row.generatorVersion}: ${row.passwordHex}` + t.true(verifySync(password, row.hash), label) + t.true(await verify(password, row.hash), label) + t.true(verifySync(view(bytes), view(Buffer.from(row.hash))), label) + t.true(await verify(view(bytes), view(Buffer.from(row.hash))), label) + t.false(await verify(Buffer.concat([Buffer.from('!'), bytes]), row.hash), label) + } }) -test('verifySync hashed password from @node-rs/bcrypt should be true', async (t) => { - const hashed = await hash(fx) - t.true(verifySync(fx, hashed)) +test('previous-release acceptance and rejection outcomes stay frozen', async (t) => { + const { fixtures } = fixture<{ fixtures: { name: string; passwordHex: string; hash: string; expected: boolean }[] }>( + 'stored-hash-fixtures', + ) + for (const row of fixtures) { + const password = Buffer.from(row.passwordHex, 'hex') + t.is(verifySync(password, row.hash), row.expected, row.name) + t.is(await verify(password, row.hash), row.expected, row.name) + } + const parser = fixture<{ fixtures: { name: string; password: string; hash: string; expected: boolean }[] }>( + 'verification-parser-fixtures', + ) + for (const row of parser.fixtures) { + t.is(verifySync(row.password, row.hash), row.expected, row.name) + t.is(await verify(row.password, Buffer.from(row.hash)), row.expected, row.name) + } + t.false(verifySync('password', Buffer.from([255]))) + t.false(await verify('password', Buffer.from([255]))) }) -test('verifySync should always return boolean even if the password is invalid', (t) => { - t.false(verifySync('a', 'b')) - t.false(verifySync('a', '')) - t.false(verifySync('', '')) +test('async calls own password, salt, and stored-hash bytes before returning', async (t) => { + const password = view(Buffer.from('original')) + const salt = view(rawSalt) + const expected = hashSync('original', { cost: 4, salt: rawSalt }) + const pending = hash(password, { cost: 4, salt }) + password.fill(33) + salt.fill(0) + t.is(await pending, expected) + const input = view(Buffer.from('original')) + const encoded = view(Buffer.from(expected)) + const checking = verify(input, encoded) + input.fill(33) + encoded.fill(33) + t.true(await checking) }) -test('compare should be equal to verify', (t) => { - t.is(verifySync, compareSync) - t.is(verify, compare) +test('pre-aborted and reused signals reject without overwriting handlers', async (t) => { + const stopped = new AbortController() + stopped.abort() + for (const operation of [ + () => genSalt({ cost: 4, signal: stopped.signal }), + () => hash('password', { cost: 4, signal: stopped.signal }), + () => verify('password', 'hash', { signal: stopped.signal }), + ]) { + await t.throwsAsync(operation(), { name: 'AbortError' }) + } + const controller = new AbortController() + let propertyCalls = 0 + let listenerCalls = 0 + controller.signal.onabort = () => propertyCalls++ + controller.signal.addEventListener('abort', () => listenerCalls++) + await hash('completed', { cost: 4, signal: controller.signal }) + const first = hash('queued one', { cost: 4, signal: controller.signal }) + const second = hash('queued two', { cost: 4, signal: controller.signal }) + controller.abort() + await t.throwsAsync(first, { name: 'AbortError' }) + await t.throwsAsync(second, { name: 'AbortError' }) + t.is(propertyCalls, 1) + t.is(listenerCalls, 1) }) -test('hash should support long or short string', (t) => { - t.is(typeof bcryptHashSync('string', 10, 'hello'), 'string') - t.is(typeof bcryptHashSync('string', 10, 'aloooooooooooooooooooooongsalt'), 'string') +test('comparison aliases and public exports remain consistent', (t) => { + t.is(compare, verify) + t.is(compareSync, verifySync) }) diff --git a/packages/bcrypt/__tests__/cancellation.spec.ts b/packages/bcrypt/__tests__/cancellation.spec.ts new file mode 100644 index 00000000..ff872c53 --- /dev/null +++ b/packages/bcrypt/__tests__/cancellation.spec.ts @@ -0,0 +1,97 @@ +import { createRequire } from 'node:module' +import test from 'ava' +import { AbortController as PolyfillAbortController } from 'abort-controller' +import * as bcrypt from '../index.js' +import type * as API from '../index.js' + +const require = createRequire(import.meta.url) +const createBcrypt = require('../api.cjs') as (binding: Record) => typeof API +const checkPolyfillCancellation = require('./polyfill-cancellation.cjs') as (api: typeof API) => Promise + +type NativeSignal = { aborted: boolean; onabort?: () => void } +type Pending = { + signal: NativeSignal + cancellations: number + resolve: (value: string) => void + reject: (error: Error) => void +} +function controlled() { + const pending: Pending[] = [] + const api = createBcrypt({ + BCRYPT_API_VERSION: 2, + DEFAULT_COST: 12, + hash: (...args: unknown[]) => + new Promise((resolve, reject) => { + const signal = args[5] as NativeSignal + const task = { signal, cancellations: 0, resolve, reject } + signal.onabort = function () { + if (this !== signal) throw new Error('The native callback requires its original receiver') + task.cancellations++ + } + pending.push(task) + }), + }) + return { api, pending } +} + +test('a mismatched backend cannot silently interpret major-version calls', (t) => { + t.throws(() => createBcrypt({ DEFAULT_COST: 12 }), { message: /Incompatible bcrypt binary/ }) +}) + +test('aborting running work settles publicly and consumes later native failure', async (t) => { + const { api, pending } = controlled() + const controller = new AbortController() + const operation = api.hash('password', { signal: controller.signal }) + t.is(pending.length, 1) + controller.abort() + await t.throwsAsync(operation, { name: 'AbortError' }) + t.true(pending[0].signal.aborted) + t.is(pending[0].cancellations, 1) + pending[0].reject(new Error('late native failure')) + await Promise.resolve() + t.pass() +}) + +test('abort wins if native completion has not yet been observed', async (t) => { + const { api, pending } = controlled() + const controller = new AbortController() + const operation = api.hash('password', { signal: controller.signal }) + pending[0].resolve('native result') + controller.abort() + await t.throwsAsync(operation, { name: 'AbortError' }) +}) + +test('completion wins once observed, and reused signals get independent native state', async (t) => { + const { api, pending } = controlled() + const controller = new AbortController() + const completed = api.hash('first', { signal: controller.signal }) + pending[0].resolve('completed') + t.is(await completed, 'completed') + const next = api.hash('second', { signal: controller.signal }) + t.not(pending[0].signal, pending[1].signal) + controller.abort() + await t.throwsAsync(next, { name: 'AbortError' }) + t.false(pending[0].signal.aborted) + t.true(pending[1].signal.aborted) + t.is(pending[0].cancellations, 0) + t.is(pending[1].cancellations, 1) + pending[1].resolve('discarded') + t.is(await completed, 'completed') +}) + +test('locally imported polyfill signals work alongside native globals and match the public types', async (t) => { + const controller = new PolyfillAbortController() + const options: API.AsyncOptions = { signal: controller.signal } + const encoded = bcrypt.hashSync('password', { cost: 4 }) + t.true(await bcrypt.verify('password', encoded, options)) + await checkPolyfillCancellation(bcrypt) +}) + +test('incomplete signal interfaces reject before calling the native backend', async (t) => { + const { api, pending } = controlled() + for (const signal of [null, {}, { aborted: false }, { aborted: false, addEventListener() {} }]) { + // @ts-expect-error Exercise incomplete cancellation interfaces from JavaScript. + await t.throwsAsync(api.hash('password', { signal }), { instanceOf: TypeError }) + } + t.is(pending.length, 0) +}) diff --git a/packages/bcrypt/__tests__/fixtures/README.md b/packages/bcrypt/__tests__/fixtures/README.md new file mode 100644 index 00000000..115f47b0 --- /dev/null +++ b/packages/bcrypt/__tests__/fixtures/README.md @@ -0,0 +1,9 @@ +# Frozen credential fixtures + +All passwords are synthetic. Do not regenerate expected hashes with the implementation under test. + +- `historical-hash-fixtures.json`: 112 hashes generated through sync/async APIs in published `@node-rs/bcrypt` 1.7.3, 1.9.2, 1.10.5, and 1.10.9. The rows record original cost/salt inputs, including old numeric coercion and text salt handling where supported. Generated on macOS arm64, Node 24.20.0. +- `stored-hash-fixtures.json`: the acceptance baseline from published 1.10.9, including long-password suffixes and both accepted and rejected legacy-prefix vectors. Generated through its WASI backend; subsequently checked against native 1.10.9 and the source build. +- `verification-parser-fixtures.json`: fixed `+4` cost cases accepted by the previous verifier. The creation parser must not gate these checks. + +The test suite additionally uses the pinned `bcrypt-previous` npm alias (1.10.9) to verify newly created hashes and checks independent known answers with bcryptjs. diff --git a/packages/bcrypt/__tests__/fixtures/historical-hash-fixtures.json b/packages/bcrypt/__tests__/fixtures/historical-hash-fixtures.json new file mode 100644 index 00000000..203326a0 --- /dev/null +++ b/packages/bcrypt/__tests__/fixtures/historical-hash-fixtures.json @@ -0,0 +1,1327 @@ +{ + "node": "v24.20.0", + "arch": "arm64", + "syntheticOnly": true, + "fixtures": [ + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "automatic salt", + "costInput": 4, + "saltInput": null, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$pevbhWH91mTdVRUnH6qFn.Xv5wqqy9vNBg19iCegwsiCCGdVGynwC" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "automatic salt", + "costInput": 4, + "saltInput": null, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$RJ1c.ewclIbEK2BSA3iKm.zBFlZ2yu7p6Ifs8sNL9..iNlNr9h1SK" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "raw 16-byte salt", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "raw 16-byte salt", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "71-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXecEcm5teoM3t82pnGlBmdnf0LIM4LTCS" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "71-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXecEcm5teoM3t82pnGlBmdnf0LIM4LTCS" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "72-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "72-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "73-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX", + "passwordHex": "61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616158", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "73-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX", + "passwordHex": "61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616158", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "NUL beyond 72 bytes", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u0000tail", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161007461696c", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "NUL beyond 72 bytes", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u0000tail", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161007461696c", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "UTF-8 across truncation boundary", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaé", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161c3a9", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXebNO28mWJtvf85WUCI4G1pYIqqN2YCGW" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "UTF-8 across truncation boundary", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaé", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161c3a9", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXebNO28mWJtvf85WUCI4G1pYIqqN2YCGW" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "embedded NUL", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "a\u0000b", + "passwordHex": "610062", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeosN6pUItK875OWp7V79DHGzccSxfJry" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "embedded NUL", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "a\u0000b", + "passwordHex": "610062", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeosN6pUItK875OWp7V79DHGzccSxfJry" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "empty password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "", + "passwordHex": "", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeg/oaNzVlQFb3jg.67P.r1snBL8ZffHa" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "empty password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "", + "passwordHex": "", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeg/oaNzVlQFb3jg.67P.r1snBL8ZffHa" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "binary password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordHex": "ffa30080", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXe2Kk.nYQJmuXk/gg9kg4tabiXCFscQRG" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "binary password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordHex": "ffa30080", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXe2Kk.nYQJmuXk/gg9kg4tabiXCFscQRG" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "fractional creation cost", + "costInput": 4.9, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "fractional creation cost", + "costInput": 4.9, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "overflowing creation cost", + "costInput": 4294967300, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "overflowing creation cost", + "costInput": 4294967300, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "automatic salt", + "costInput": 4, + "saltInput": null, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$1Crl6Byzte9nDDNPbtuxkOmDpy.5s.AG0hToghnZLAeq7uf.WxO3y" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "automatic salt", + "costInput": 4, + "saltInput": null, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$PiSUlXuoPFOykK5MMFAkJupen/zjsCLuaCkmnIOMnXHOSPR5.K3ga" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "raw 16-byte salt", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "raw 16-byte salt", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "71-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXecEcm5teoM3t82pnGlBmdnf0LIM4LTCS" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "71-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXecEcm5teoM3t82pnGlBmdnf0LIM4LTCS" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "72-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "72-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "73-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX", + "passwordHex": "61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616158", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "73-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX", + "passwordHex": "61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616158", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "NUL beyond 72 bytes", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u0000tail", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161007461696c", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "NUL beyond 72 bytes", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u0000tail", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161007461696c", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "UTF-8 across truncation boundary", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaé", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161c3a9", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXebNO28mWJtvf85WUCI4G1pYIqqN2YCGW" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "UTF-8 across truncation boundary", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaé", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161c3a9", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXebNO28mWJtvf85WUCI4G1pYIqqN2YCGW" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "embedded NUL", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "a\u0000b", + "passwordHex": "610062", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeosN6pUItK875OWp7V79DHGzccSxfJry" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "embedded NUL", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "a\u0000b", + "passwordHex": "610062", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeosN6pUItK875OWp7V79DHGzccSxfJry" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "empty password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "", + "passwordHex": "", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeg/oaNzVlQFb3jg.67P.r1snBL8ZffHa" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "empty password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "", + "passwordHex": "", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeg/oaNzVlQFb3jg.67P.r1snBL8ZffHa" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "binary password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordHex": "ffa30080", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXe2Kk.nYQJmuXk/gg9kg4tabiXCFscQRG" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "binary password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordHex": "ffa30080", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXe2Kk.nYQJmuXk/gg9kg4tabiXCFscQRG" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "fractional creation cost", + "costInput": 4.9, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "fractional creation cost", + "costInput": 4.9, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "overflowing creation cost", + "costInput": 4294967300, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "overflowing creation cost", + "costInput": 4294967300, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "automatic salt", + "costInput": 4, + "saltInput": null, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$xN8ntGq2E6zYnCZqoVqeNOpUCdfIKZ22gWD64eLHfqD8y3S7UkA.e" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "automatic salt", + "costInput": 4, + "saltInput": null, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$6P3x1cWoSsXO15Q9cv/wluHITH1WWkWN.kSJvKI4UMrJKs.g.OpiC" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "raw 16-byte salt", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "raw 16-byte salt", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "71-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXecEcm5teoM3t82pnGlBmdnf0LIM4LTCS" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "71-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXecEcm5teoM3t82pnGlBmdnf0LIM4LTCS" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "72-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "72-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "73-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX", + "passwordHex": "61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616158", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "73-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX", + "passwordHex": "61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616158", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "NUL beyond 72 bytes", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u0000tail", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161007461696c", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "NUL beyond 72 bytes", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u0000tail", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161007461696c", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "UTF-8 across truncation boundary", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaé", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161c3a9", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXebNO28mWJtvf85WUCI4G1pYIqqN2YCGW" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "UTF-8 across truncation boundary", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaé", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161c3a9", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXebNO28mWJtvf85WUCI4G1pYIqqN2YCGW" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "embedded NUL", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "a\u0000b", + "passwordHex": "610062", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeosN6pUItK875OWp7V79DHGzccSxfJry" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "embedded NUL", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "a\u0000b", + "passwordHex": "610062", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeosN6pUItK875OWp7V79DHGzccSxfJry" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "empty password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "", + "passwordHex": "", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeg/oaNzVlQFb3jg.67P.r1snBL8ZffHa" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "empty password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "", + "passwordHex": "", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeg/oaNzVlQFb3jg.67P.r1snBL8ZffHa" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "binary password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordHex": "ffa30080", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXe2Kk.nYQJmuXk/gg9kg4tabiXCFscQRG" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "binary password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordHex": "ffa30080", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXe2Kk.nYQJmuXk/gg9kg4tabiXCFscQRG" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "fractional creation cost", + "costInput": 4.9, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "fractional creation cost", + "costInput": 4.9, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "overflowing creation cost", + "costInput": 4294967300, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "overflowing creation cost", + "costInput": 4294967300, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "empty text salt", + "costInput": 4, + "saltInput": { + "text": "" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$......................2VBaBohsKe8kgA1pDEkLBJ7N/fpMWfC" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "empty text salt", + "costInput": 4, + "saltInput": { + "text": "" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$......................2VBaBohsKe8kgA1pDEkLBJ7N/fpMWfC" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "short text salt", + "costInput": 4, + "saltInput": { + "text": "hello" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$YETqZE6................ZE13Qk.UWq4DgW87g6gkU0fTPEFRKC" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "short text salt", + "costInput": 4, + "saltInput": { + "text": "hello" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$YETqZE6................ZE13Qk.UWq4DgW87g6gkU0fTPEFRKC" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "long text salt", + "costInput": 4, + "saltInput": { + "text": "0123456789abcdef-extra" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "long text salt", + "costInput": 4, + "saltInput": { + "text": "0123456789abcdef-extra" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "old padded encoded text salt", + "costInput": 4, + "saltInput": { + "text": "$2b$04$KBCwKxOzLha2MUDgW0PjXe==" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$HBHgHB.yHCrAO1bJcC74R.Vr6Jf.PDm7M7k.onMahIMklf4LmZ7SC" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "old padded encoded text salt", + "costInput": 4, + "saltInput": { + "text": "$2b$04$KBCwKxOzLha2MUDgW0PjXe==" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$HBHgHB.yHCrAO1bJcC74R.Vr6Jf.PDm7M7k.onMahIMklf4LmZ7SC" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "automatic salt", + "costInput": 4, + "saltInput": null, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$Gyciqo0KI2YnbLlgNobWhu2F8.eI6WXpAqfISfG9NbgTmyEbQSAKq" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "automatic salt", + "costInput": 4, + "saltInput": null, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$xbdcBNvcCiSeUQuCanDL/uuq4tPwIkQr9tDrRYL4PzeMloI9Hoo8e" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "raw 16-byte salt", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "raw 16-byte salt", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "71-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXecEcm5teoM3t82pnGlBmdnf0LIM4LTCS" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "71-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXecEcm5teoM3t82pnGlBmdnf0LIM4LTCS" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "72-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "72-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "73-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX", + "passwordHex": "61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616158", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "73-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX", + "passwordHex": "61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616158", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "NUL beyond 72 bytes", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u0000tail", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161007461696c", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "NUL beyond 72 bytes", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u0000tail", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161007461696c", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "UTF-8 across truncation boundary", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaé", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161c3a9", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXebNO28mWJtvf85WUCI4G1pYIqqN2YCGW" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "UTF-8 across truncation boundary", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaé", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161c3a9", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXebNO28mWJtvf85WUCI4G1pYIqqN2YCGW" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "embedded NUL", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "a\u0000b", + "passwordHex": "610062", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeosN6pUItK875OWp7V79DHGzccSxfJry" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "embedded NUL", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "a\u0000b", + "passwordHex": "610062", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeosN6pUItK875OWp7V79DHGzccSxfJry" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "empty password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "", + "passwordHex": "", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeg/oaNzVlQFb3jg.67P.r1snBL8ZffHa" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "empty password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "", + "passwordHex": "", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeg/oaNzVlQFb3jg.67P.r1snBL8ZffHa" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "binary password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordHex": "ffa30080", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXe2Kk.nYQJmuXk/gg9kg4tabiXCFscQRG" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "binary password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordHex": "ffa30080", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXe2Kk.nYQJmuXk/gg9kg4tabiXCFscQRG" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "fractional creation cost", + "costInput": 4.9, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "fractional creation cost", + "costInput": 4.9, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "overflowing creation cost", + "costInput": 4294967300, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "overflowing creation cost", + "costInput": 4294967300, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "empty text salt", + "costInput": 4, + "saltInput": { + "text": "" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$......................2VBaBohsKe8kgA1pDEkLBJ7N/fpMWfC" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "empty text salt", + "costInput": 4, + "saltInput": { + "text": "" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$......................2VBaBohsKe8kgA1pDEkLBJ7N/fpMWfC" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "short text salt", + "costInput": 4, + "saltInput": { + "text": "hello" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$YETqZE6................ZE13Qk.UWq4DgW87g6gkU0fTPEFRKC" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "short text salt", + "costInput": 4, + "saltInput": { + "text": "hello" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$YETqZE6................ZE13Qk.UWq4DgW87g6gkU0fTPEFRKC" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "long text salt", + "costInput": 4, + "saltInput": { + "text": "0123456789abcdef-extra" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "long text salt", + "costInput": 4, + "saltInput": { + "text": "0123456789abcdef-extra" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "old padded encoded text salt", + "costInput": 4, + "saltInput": { + "text": "$2b$04$KBCwKxOzLha2MUDgW0PjXe==" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$HBHgHB.yHCrAO1bJcC74R.Vr6Jf.PDm7M7k.onMahIMklf4LmZ7SC" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "old padded encoded text salt", + "costInput": 4, + "saltInput": { + "text": "$2b$04$KBCwKxOzLha2MUDgW0PjXe==" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$HBHgHB.yHCrAO1bJcC74R.Vr6Jf.PDm7M7k.onMahIMklf4LmZ7SC" + } + ] +} diff --git a/packages/bcrypt/__tests__/fixtures/stored-hash-fixtures.json b/packages/bcrypt/__tests__/fixtures/stored-hash-fixtures.json new file mode 100644 index 00000000..b50659b5 --- /dev/null +++ b/packages/bcrypt/__tests__/fixtures/stored-hash-fixtures.json @@ -0,0 +1,254 @@ +{ + "baseline": { + "package": "@node-rs/bcrypt", + "version": "1.10.9", + "date": "2026-09-10", + "node": "v25.2.1", + "syntheticOnly": true, + "arch": "x64", + "backend": "published wasm32-wasi 1.10.9 fallback, confirmed from loaded module paths" + }, + "fixtures": [ + { + "name": "ordinary internally generated salt", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$Iyx27HXZSB0TGLctKQvknumvyrTUQHhNLhs/Lp9QAkP5GzS/.CYeu", + "expected": true + }, + { + "name": "ordinary internally generated salt: wrong prefix", + "passwordHex": "2173796e7468657469632d70617373776f7264", + "hash": "$2b$04$Iyx27HXZSB0TGLctKQvknumvyrTUQHhNLhs/Lp9QAkP5GzS/.CYeu", + "expected": false + }, + { + "name": "exact raw salt", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S", + "expected": true + }, + { + "name": "exact raw salt: wrong prefix", + "passwordHex": "2173796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S", + "expected": false + }, + { + "name": "empty positional salt", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$......................2VBaBohsKe8kgA1pDEkLBJ7N/fpMWfC", + "expected": true + }, + { + "name": "empty positional salt: wrong prefix", + "passwordHex": "2173796e7468657469632d70617373776f7264", + "hash": "$2b$04$......................2VBaBohsKe8kgA1pDEkLBJ7N/fpMWfC", + "expected": false + }, + { + "name": "short positional text salt", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$YETqZE6................ZE13Qk.UWq4DgW87g6gkU0fTPEFRKC", + "expected": true + }, + { + "name": "short positional text salt: wrong prefix", + "passwordHex": "2173796e7468657469632d70617373776f7264", + "hash": "$2b$04$YETqZE6................ZE13Qk.UWq4DgW87g6gkU0fTPEFRKC", + "expected": false + }, + { + "name": "long positional text salt", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S", + "expected": true + }, + { + "name": "long positional text salt: wrong prefix", + "passwordHex": "2173796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S", + "expected": false + }, + { + "name": "encoded positional salt with old padding", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$HBHgHB.yHCrAO1bJcC74R.Vr6Jf.PDm7M7k.onMahIMklf4LmZ7SC", + "expected": true + }, + { + "name": "encoded positional salt with old padding: wrong prefix", + "passwordHex": "2173796e7468657469632d70617373776f7264", + "hash": "$2b$04$HBHgHB.yHCrAO1bJcC74R.Vr6Jf.PDm7M7k.onMahIMklf4LmZ7SC", + "expected": false + }, + { + "name": "71-byte password", + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXecEcm5teoM3t82pnGlBmdnf0LIM4LTCS", + "expected": true + }, + { + "name": "71-byte password: wrong prefix", + "passwordHex": "216161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXecEcm5teoM3t82pnGlBmdnf0LIM4LTCS", + "expected": false + }, + { + "name": "72-byte password", + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci", + "expected": true + }, + { + "name": "72-byte password: wrong prefix", + "passwordHex": "21616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci", + "expected": false + }, + { + "name": "73-byte password", + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX", + "passwordHex": "61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616158", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci", + "expected": true + }, + { + "name": "73-byte password: wrong prefix", + "passwordHex": "2161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616158", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci", + "expected": false + }, + { + "name": "different ignored suffix still accepted", + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaY", + "passwordHex": "61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616159", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci", + "expected": true + }, + { + "name": "UTF-8 character across 72-byte boundary", + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaé", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161c3a9", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXebNO28mWJtvf85WUCI4G1pYIqqN2YCGW", + "expected": true + }, + { + "name": "UTF-8 character across 72-byte boundary: wrong prefix", + "passwordHex": "216161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161c3a9", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXebNO28mWJtvf85WUCI4G1pYIqqN2YCGW", + "expected": false + }, + { + "name": "multibyte long password", + "passwordText": "éééééééééééééééééééééééééééééééééééééééé", + "passwordHex": "c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXei6Hb7SlfT6nGWjyM/STFV23fOIxuXcK", + "expected": true + }, + { + "name": "multibyte long password: wrong prefix", + "passwordHex": "21c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXei6Hb7SlfT6nGWjyM/STFV23fOIxuXcK", + "expected": false + }, + { + "name": "embedded NUL", + "passwordText": "a\u0000b", + "passwordHex": "610062", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeosN6pUItK875OWp7V79DHGzccSxfJry", + "expected": true + }, + { + "name": "embedded NUL: wrong prefix", + "passwordHex": "21610062", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeosN6pUItK875OWp7V79DHGzccSxfJry", + "expected": false + }, + { + "name": "empty password", + "passwordText": "", + "passwordHex": "", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeg/oaNzVlQFb3jg.67P.r1snBL8ZffHa", + "expected": true + }, + { + "name": "empty password: wrong prefix", + "passwordHex": "21", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeg/oaNzVlQFb3jg.67P.r1snBL8ZffHa", + "expected": false + }, + { + "name": "raw non-UTF-8 password bytes", + "passwordHex": "ffa30080", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXe2Kk.nYQJmuXk/gg9kg4tabiXCFscQRG", + "expected": true + }, + { + "name": "raw non-UTF-8 password bytes: wrong prefix", + "passwordHex": "21ffa30080", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXe2Kk.nYQJmuXk/gg9kg4tabiXCFscQRG", + "expected": false + }, + { + "name": "cost 5", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$05$KBCwKxOzLha2MUDgW0PjXeE7YXZXjTMz/ShaHBN92.DKFo9RcxxMO", + "expected": true + }, + { + "name": "cost 5: wrong prefix", + "passwordHex": "2173796e7468657469632d70617373776f7264", + "hash": "$2b$05$KBCwKxOzLha2MUDgW0PjXeE7YXZXjTMz/ShaHBN92.DKFo9RcxxMO", + "expected": false + }, + { + "name": "accepted imported 2a label", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2a$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S", + "expected": true + }, + { + "name": "accepted imported 2x label", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2x$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S", + "expected": true + }, + { + "name": "accepted imported 2y label", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S", + "expected": true + }, + { + "name": "genuine historical 2x remains rejected", + "passwordHex": "a3", + "hash": "$2x$05$/OK.fbVrR/bpIqNJ5ianF.CE5elHaaO4EbggVDjb8P19RukzXSM3e", + "expected": false + }, + { + "name": "corrected value labeled 2x remains accepted", + "passwordHex": "a3", + "hash": "$2x$05$/OK.fbVrR/bpIqNJ5ianF.Sa7shbm4.OzKpvFnX1pQLmQW96oUlCq", + "expected": true + }, + { + "name": "malformed text remains rejected", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "not-a-hash", + "expected": false + } + ] +} diff --git a/packages/bcrypt/__tests__/fixtures/verification-parser-fixtures.json b/packages/bcrypt/__tests__/fixtures/verification-parser-fixtures.json new file mode 100644 index 00000000..4109d928 --- /dev/null +++ b/packages/bcrypt/__tests__/fixtures/verification-parser-fixtures.json @@ -0,0 +1,18 @@ +{ + "baseline": "Published @node-rs/bcrypt 1.10.9, checked 2026-09-10", + "note": "Cost +4 is noncanonical but accepted by the retained verification parser. The new creation parser must reject it independently.", + "fixtures": [ + { + "name": "accepted plus-sign cost", + "password": "synthetic-password", + "hash": "$2b$+4$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S", + "expected": true + }, + { + "name": "plus-sign cost wrong password", + "password": "wrong", + "hash": "$2b$+4$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S", + "expected": false + } + ] +} diff --git a/packages/bcrypt/__tests__/package.json b/packages/bcrypt/__tests__/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/packages/bcrypt/__tests__/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/packages/bcrypt/__tests__/polyfill-cancellation.cjs b/packages/bcrypt/__tests__/polyfill-cancellation.cjs new file mode 100644 index 00000000..0697be9a --- /dev/null +++ b/packages/bcrypt/__tests__/polyfill-cancellation.cjs @@ -0,0 +1,74 @@ +const assert = require('assert') +const { AbortController, AbortSignal } = require('abort-controller') + +module.exports = async function checkPolyfillCancellation(api) { + const encoded = api.hashSync('password', { cost: 4 }) + const operations = [ + (signal) => api.genSalt({ cost: 4, signal }), + (signal) => api.hash('password', { cost: 4, signal }), + (signal) => api.verify('password', encoded, { signal }), + ] + const isAbortError = (error) => error.name === 'AbortError' + const stopped = new AbortController() + stopped.abort() + for (const operation of operations) { + await assert.rejects(operation(stopped.signal), isAbortError) + } + + for (const operation of operations) { + const controller = new AbortController() + const signal = controller.signal + let propertyCalls = 0 + let listenerCalls = 0 + const onabort = () => propertyCalls++ + signal.onabort = onabort + signal.addEventListener('abort', () => listenerCalls++) + + // Track only listeners added by the adapter, independently of the polyfill internals. + const listeners = new Set() + const add = signal.addEventListener + const remove = signal.removeEventListener + signal.addEventListener = function (type, listener, options) { + listeners.add(listener) + return add.call(this, type, listener, options) + } + signal.removeEventListener = function (type, listener, options) { + listeners.delete(listener) + return remove.call(this, type, listener, options) + } + + assert.ok(await operation(signal)) + assert.strictEqual(signal.onabort, onabort) + assert.strictEqual(listeners.size, 0) + + const first = operation(signal) + const second = operation(signal) + assert.strictEqual(listeners.size, 2) + controller.abort() + await assert.rejects(first, isAbortError) + await assert.rejects(second, isAbortError) + assert.strictEqual(signal.onabort, onabort) + assert.strictEqual(propertyCalls, 1) + assert.strictEqual(listenerCalls, 1) + assert.strictEqual(listeners.size, 0) + } +} + +if (require.main === module) { + const api = require('../index.js') + // This runs in its own process, including on modern Node versions. + delete global.AbortController + delete global.AbortSignal + module + .exports(api) + .then(() => { + // A partial global shim must not require a matching global controller. + global.AbortSignal = AbortSignal + return module.exports(api) + }) + .then(() => console.log(`Imported polyfill cancellation passed on ${process.version}`)) + .catch((error) => { + console.error(error) + process.exitCode = 1 + }) +} diff --git a/packages/bcrypt/__tests__/supported-node.cjs b/packages/bcrypt/__tests__/supported-node.cjs new file mode 100644 index 00000000..665365ff --- /dev/null +++ b/packages/bcrypt/__tests__/supported-node.cjs @@ -0,0 +1,64 @@ +// Run directly on the oldest supported Node versions, without the modern test runner. +const assert = require('assert') +const bcrypt = require('../index.js') +const historical = require('./fixtures/historical-hash-fixtures.json').fixtures +const outcomes = require('./fixtures/stored-hash-fixtures.json').fixtures +const parser = require('./fixtures/verification-parser-fixtures.json').fixtures + +async function main() { + assert.strictEqual(bcrypt.DEFAULT_COST, 12) + assert.strictEqual(bcrypt.compare, bcrypt.verify) + assert.strictEqual(bcrypt.compareSync, bcrypt.verifySync) + assert.ok(bcrypt.genSaltSync().startsWith('$2b$12$')) + assert.ok((await bcrypt.genSalt()).startsWith('$2b$12$')) + + for (const version of ['2a', '2b', '2y']) { + const salt = await bcrypt.genSalt({ cost: 4, version }) + assert.strictEqual(salt.length, 29) + assert.ok(salt.startsWith(`$${version}$04$`)) + const hash = bcrypt.hashSync('password', { salt }) + assert.strictEqual(await bcrypt.hash('password', { salt }), hash) + assert.strictEqual(bcrypt.verifySync('password', hash), true) + assert.strictEqual(await bcrypt.verify('wrong', hash), false) + } + + const password = Buffer.from('original') + const salt = Buffer.alloc(16) + const expected = bcrypt.hashSync('original', { cost: 4, salt }) + const pending = bcrypt.hash(password, { cost: 4, salt }) + password.fill(33) + salt.fill(33) + assert.strictEqual(await pending, expected) + + const longPassword = 'a'.repeat(73) + const longHash = await bcrypt.hash(longPassword, { cost: 4 }) + assert.strictEqual(await bcrypt.verify(longPassword, longHash), true) + assert.throws(() => bcrypt.hashSync(longPassword, { cost: 4, rejectLongPasswords: true }), RangeError) + await assert.rejects(bcrypt.hash('password', { salt: 'invalid' }), RangeError) + await assert.rejects(bcrypt.genSalt({ cost: 3 }), RangeError) + const invalid = bcrypt.hash('password', 4) + assert.ok(invalid instanceof Promise) + await assert.rejects(invalid, TypeError) + await assert.rejects(bcrypt.verify('password', expected, { signal: {} }), TypeError) + + for (const row of historical) { + const input = row.passwordText === undefined ? Buffer.from(row.passwordHex, 'hex') : row.passwordText + assert.strictEqual(bcrypt.verifySync(input, row.hash), true) + assert.strictEqual(await bcrypt.verify(input, row.hash), true) + } + for (const row of outcomes) { + const input = Buffer.from(row.passwordHex, 'hex') + assert.strictEqual(bcrypt.verifySync(input, row.hash), row.expected, row.name) + assert.strictEqual(await bcrypt.verify(input, row.hash), row.expected, row.name) + } + for (const row of parser) { + assert.strictEqual(bcrypt.verifySync(row.password, row.hash), row.expected, row.name) + assert.strictEqual(await bcrypt.verify(row.password, row.hash), row.expected, row.name) + } + console.log(`Bcrypt public API and stored-hash checks passed on ${process.version}`) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/bcrypt/api.cjs b/packages/bcrypt/api.cjs new file mode 100644 index 00000000..3506fe52 --- /dev/null +++ b/packages/bcrypt/api.cjs @@ -0,0 +1,178 @@ +// Shared by the Node entry (including WASI fallback) and the browser entry. +// Keep this adapter parseable on Node 10, before any runtime feature checks. +module.exports = function createBcrypt(binding) { + if (binding.BCRYPT_API_VERSION !== 2) { + throw new Error('Incompatible bcrypt binary: rebuild or reinstall the matching @node-rs/bcrypt backend') + } + + function arity(args, maximum) { + if (args.length > maximum) throw new TypeError('Positional bcrypt options are no longer supported') + } + + function options(value, keys) { + if (value === undefined) return Object.create(null) + if (value === null || typeof value !== 'object') throw new TypeError('options must be an object') + const prototype = Object.getPrototypeOf(value) + if (prototype !== null && prototype !== Object.prototype) throw new TypeError('options must be a plain object') + const result = Object.create(null) + for (const key of Reflect.ownKeys(value)) { + if (!keys.includes(key)) throw new TypeError(`Unknown bcrypt option: ${String(key)}`) + result[key] = value[key] + } + return result + } + + function bytes(value, name) { + if (typeof value === 'string') return value + if (ArrayBuffer.isView(value) && Object.prototype.toString.call(value) === '[object Uint8Array]') return value + throw new TypeError(`${name} must be a string or Uint8Array`) + } + + function creation(value) { + if ( + value.cost !== undefined && + (typeof value.cost !== 'number' || !Number.isInteger(value.cost) || value.cost < 4 || value.cost > 31) + ) { + throw new RangeError('cost must be an integer between 4 and 31') + } + if (value.version !== undefined && !['2a', '2b', '2y'].includes(value.version)) { + throw new RangeError('version must be 2a, 2b, or 2y') + } + if (value.salt !== undefined) { + bytes(value.salt, 'salt') + if (typeof value.salt === 'string') { + if (value.cost !== undefined || value.version !== undefined) + throw new RangeError('an encoded salt already supplies cost and version') + } else if (value.salt.byteLength !== 16) { + throw new RangeError('raw salt must contain exactly 16 bytes') + } + } + if (value.rejectLongPasswords !== undefined && typeof value.rejectLongPasswords !== 'boolean') { + throw new TypeError('rejectLongPasswords must be a boolean') + } + return value + } + + function signal(value) { + if (value === undefined) return value + if ( + value === null || + typeof value !== 'object' || + typeof value.aborted !== 'boolean' || + typeof value.addEventListener !== 'function' || + typeof value.removeEventListener !== 'function' + ) { + throw new TypeError('signal must provide aborted, addEventListener, and removeEventListener') + } + return value + } + + function nativeError(error) { + return error && error.code === 'InvalidArg' ? new RangeError(error.message) : error + } + + function sync(start) { + try { + return start() + } catch (error) { + throw nativeError(error) + } + } + + function run(userSignal, start) { + return new Promise((resolve, reject) => { + // The native binding installs an onabort callback on the object it receives. + // Give each task a private bridge, independent of the caller's signal implementation. + const nativeSignal = userSignal === undefined ? undefined : { aborted: false, onabort: undefined } + let settled = false + const finish = (callback, value) => { + if (settled) return + settled = true + if (userSignal !== undefined) userSignal.removeEventListener('abort', abort) + callback(value) + } + const abort = () => { + if (settled) return + const error = new Error('The operation was aborted') + error.name = 'AbortError' + finish(reject, error) + nativeSignal.aborted = true + if (typeof nativeSignal.onabort === 'function') nativeSignal.onabort() + } + if (userSignal !== undefined) { + userSignal.addEventListener('abort', abort, { once: true }) + if (settled || userSignal.aborted) { + abort() + return + } + } + try { + // The binding copies all byte inputs before returning this Promise. + const task = start(nativeSignal) + Promise.resolve(task).then( + (value) => finish(resolve, value), + (error) => finish(reject, nativeError(error)), + ) + } catch (error) { + finish(reject, nativeError(error)) + } + }) + } + + function genSaltSync(value) { + arity(arguments, 1) + const opts = creation(options(value, ['cost', 'version'])) + return sync(() => binding.genSaltSync(opts.cost === undefined ? binding.DEFAULT_COST : opts.cost, opts.version)) + } + + async function genSalt(value) { + arity(arguments, 1) + const opts = creation(options(value, ['cost', 'version', 'signal'])) + return run(signal(opts.signal), (internal) => + binding.genSalt(opts.cost === undefined ? binding.DEFAULT_COST : opts.cost, opts.version, internal), + ) + } + + function hashSync(password, value) { + arity(arguments, 2) + bytes(password, 'password') + const opts = creation(options(value, ['cost', 'salt', 'version', 'rejectLongPasswords'])) + return sync(() => binding.hashSync(password, opts.cost, opts.salt, opts.version, opts.rejectLongPasswords === true)) + } + + async function hash(password, value) { + arity(arguments, 2) + bytes(password, 'password') + const opts = creation(options(value, ['cost', 'salt', 'version', 'rejectLongPasswords', 'signal'])) + return run(signal(opts.signal), (internal) => + binding.hash(password, opts.cost, opts.salt, opts.version, opts.rejectLongPasswords === true, internal), + ) + } + + function verifySync(password, encoded) { + arity(arguments, 2) + bytes(password, 'password') + bytes(encoded, 'hash') + return binding.verifySync(password, encoded) + } + + async function verify(password, encoded, value) { + arity(arguments, 3) + bytes(password, 'password') + bytes(encoded, 'hash') + const opts = options(value, ['signal']) + return run(signal(opts.signal), (internal) => binding.verify(password, encoded, internal)) + } + + return { + DEFAULT_COST: binding.DEFAULT_COST, + genSalt, + genSaltSync, + hash, + hashSync, + verify, + verifySync, + compare: verify, + compareSync: verifySync, + } +} diff --git a/packages/bcrypt/bcrypt.wasi-browser.js b/packages/bcrypt/bcrypt.wasi-browser.js index 3a483db0..9a2b76ee 100644 --- a/packages/bcrypt/bcrypt.wasi-browser.js +++ b/packages/bcrypt/bcrypt.wasi-browser.js @@ -1,58 +1,585 @@ import { + emnapiAsyncWorkPlugin as __emnapiAsyncWorkPlugin, + emnapiTSFNPlugin as __emnapiTSFNPlugin, createOnMessage as __wasmCreateOnMessageForFsProxy, - getDefaultContext as __emnapiGetDefaultContext, - instantiateNapiModuleSync as __emnapiInstantiateNapiModuleSync, + instantiateNapiModule as __emnapiInstantiateNapiModule, WASI as __WASI, } from '@napi-rs/wasm-runtime' +import { createContext as __emnapiCreateContext } from '@emnapi/runtime' const __wasi = new __WASI({ version: 'preview1', }) const __wasmUrl = new URL('./bcrypt.wasm32-wasi.wasm', import.meta.url).href -const __emnapiContext = __emnapiGetDefaultContext() +const __wasmResponse = await globalThis.fetch(__wasmUrl) +if (!__wasmResponse.ok) { + throw new Error( + 'Failed to fetch WASI module ' + + __wasmUrl + + ': ' + + __wasmResponse.status + + ' ' + + (__wasmResponse.statusText || 'Unknown Status'), + ) +} +const __wasmFile = await __wasmResponse.arrayBuffer() const __sharedMemory = new WebAssembly.Memory({ initial: 4000, maximum: 65536, shared: true, }) +const __asyncWorkPoolSize = 4 +const __workerPoolSize = Math.max(2, globalThis.navigator?.hardwareConcurrency ?? 4) -const __wasmFile = await fetch(__wasmUrl).then((res) => res.arrayBuffer()) - -const { - instance: __napiInstance, - module: __wasiModule, - napiModule: __napiModule, -} = __emnapiInstantiateNapiModuleSync(__wasmFile, { - context: __emnapiContext, - asyncWorkPoolSize: 4, - wasi: __wasi, - onCreateWorker() { - const worker = new Worker(new URL('./wasi-worker-browser.mjs', import.meta.url), { - type: 'module', - }) - - return worker - }, - overwriteImports(importObject) { - importObject.env = { - ...importObject.env, - ...importObject.napi, - ...importObject.emnapi, - memory: __sharedMemory, - } - return importObject - }, - beforeInit({ instance }) { - for (const name of Object.keys(instance.exports)) { - if (name.startsWith('__napi_register__')) { - instance.exports[name]() - } - } - }, -}) +let __emnapiContext + +const __wasiDisposeSymbol = Symbol.for('napi.rs.wasi.dispose') +const __wasiWorkers = new Set() +let __napiInstance +let __emnapiContextDestroyed = false +let __emnapiContextDestroyPromise +let __emnapiWasmEnvCleanupPrepared = false +let __emnapiWasmEnvCleanupRan = false +let __emnapiWasmEnvCleanupDrained = false +let __emnapiWasmEnvCleanupDrainPromise +let __wasiDisposed = false +let __wasiDisposePromise +let __completeWasiDisposal = function () {} +// Overridden by loader flavors that have a last-resort reclaim for a rollback +// that stopped short of destroying the context. See +// `__rollbackWasiInitialization`. +let __retainWasiRollbackForRetry = function () {} + +function __isThenable(value) { + return ( + value !== null && (typeof value === 'object' || typeof value === 'function') && typeof value.then === 'function' + ) +} + +function __createCleanupError(errors, message) { + if (errors.length === 1) { + return errors[0] + } + const __AggregateError = globalThis.AggregateError + if (typeof __AggregateError === 'function') { + return new __AggregateError(errors, message) + } + const error = new Error(message) + error.errors = errors + return error +} + +function __attachCleanupErrors(error, cleanupErrors) { + if (cleanupErrors.length === 0) { + return error + } + const cleanupError = __createCleanupError(cleanupErrors, 'WASI binding cleanup failed') + try { + if (error && (typeof error === 'object' || typeof error === 'function')) { + if (error.cause === undefined) { + error.cause = cleanupError + if (error.cause === cleanupError) { + return error + } + } + if (Array.isArray(error.cleanupErrors)) { + error.cleanupErrors.push(cleanupError) + return error + } else { + const attachedCleanupErrors = [cleanupError] + error.cleanupErrors = attachedCleanupErrors + if (error.cleanupErrors === attachedCleanupErrors) { + return error + } + } + } + } catch {} + const aggregate = __createCleanupError([error, cleanupError], 'WASI binding initialization and cleanup failed') + try { + aggregate.cause = error + } catch {} + return aggregate +} + +function __prepareWasmEnvCleanup() { + if (__emnapiWasmEnvCleanupPrepared) { + return + } + const prepare = __napiInstance?.exports?.napi_prepare_wasm_env_cleanup + if (typeof prepare === 'function') { + prepare() + __emnapiWasmEnvCleanupRan = true + } + __emnapiWasmEnvCleanupPrepared = true +} + +// Mirror the primitive @emnapi/core schedules its threadsafe-function dispatch +// on, so the drain turns below interleave with that dispatch instead of racing +// ahead of it on a faster queue. +const __scheduleMacrotask = (function () { + if (typeof setImmediate === 'function') { + return function (callback) { + setImmediate(callback) + } + } + const __MessageChannel = globalThis.MessageChannel + if (typeof __MessageChannel === 'function') { + return function (callback) { + const channel = new __MessageChannel() + channel.port1.onmessage = function () { + channel.port1.onmessage = null + try { + channel.port1.close() + } catch {} + try { + channel.port2.close() + } catch {} + callback() + } + channel.port2.postMessage(null) + } + } + return function (callback) { + setTimeout(callback, 0) + } +})() + +// Turns to wait for while the addon still reports queued settlements. Reaching +// zero is the only success. A counter still nonzero at this bound rejects the +// disposal as retryable (`ERR_NAPI_WASI_CLEANUP_PENDING`) rather than +// destroying the context over a still-queued settlement — the wait stays +// bounded either way. +const __WASM_ENV_CLEANUP_DRAIN_TURNS = 128 +// Without `napi_wasm_env_cleanup_pending` the queue is not observable. Fall +// back to the number of turns @emnapi/core needs to coalesce and dispatch a +// call made on this thread (two), plus a margin. +const __WASM_ENV_CLEANUP_BLIND_DRAIN_TURNS = 4 + +/** + * `napi_prepare_wasm_env_cleanup` only *queues* the promise settlements of the + * tasks it cancelled: `napi_call_threadsafe_function` appends to the + * threadsafe-function queue, and @emnapi/core dispatches that queue from a + * macrotask — two coalescing turns later, even for a call made on this very + * thread. `Context.destroy()` then runs the threadsafe function's cleanup hook, + * which drains the queue with a null env and *discards* whatever is still in it. + * + * So destroying without yielding first strands exactly the promises the barrier + * exists to settle. Yield real event-loop turns until the addon reports the + * queue empty; microtask checkpoints cannot help, no number of them lets a + * macrotask run. + * + * Returns nothing when there is nothing to wait for, which keeps disposal + * synchronous in the common case. + * + * The "already drained" flag is set only once a wait has actually finished. + * Scheduling a macrotask can fail — a host-provided or patched `setImmediate` + * that throws is enough — and a disposal that rejects stays retryable, so + * marking the drain complete up front would make the retry skip it and destroy + * the context with the barrier's settlements still queued. + * + * A wait that runs out of turns with the counter still nonzero rejects with + * `ERR_NAPI_WASI_CLEANUP_PENDING` for the same reason: at that point + * "finished" is indistinguishable from the stranding above, and destroying + * would discard the very settlement the wait was for. The rejection leaves the + * flag unset and disposal retryable. + */ +function __drainWasmEnvCleanup() { + if (__emnapiWasmEnvCleanupDrained || !__emnapiWasmEnvCleanupRan) { + return + } + if (__emnapiWasmEnvCleanupDrainPromise) { + return __emnapiWasmEnvCleanupDrainPromise + } + const pending = __napiInstance?.exports?.napi_wasm_env_cleanup_pending + const observable = typeof pending === 'function' + if (observable) { + let queued + try { + queued = pending() + } catch { + __emnapiWasmEnvCleanupDrained = true + return + } + if (!queued) { + __emnapiWasmEnvCleanupDrained = true + return + } + } + const limit = observable ? __WASM_ENV_CLEANUP_DRAIN_TURNS : __WASM_ENV_CLEANUP_BLIND_DRAIN_TURNS + const drainPromise = (async () => { + let queued = 0 + for (let turn = 0; turn < limit; turn++) { + await new Promise((resolve) => { + __scheduleMacrotask(resolve) + }) + if (!observable) { + continue + } + try { + queued = pending() + } catch { + return + } + if (!queued) { + return + } + } + if (!observable) { + // Blind wait: without `napi_wasm_env_cleanup_pending` the bound IS the + // contract — there is nothing to consult, so finishing the turns is + // finishing the drain. + return + } + // The counter is still nonzero after every turn the bound allows. The wait + // stays bounded — but claiming success here would be indistinguishable from + // the stranding this drain exists to prevent: disposal would go on to + // destroy the context, whose cleanup hook discards the still-queued + // settlement with a null env, and the promise it was for hangs forever. + // Reject instead, as a retryable cleanup failure: the drained flag stays + // unset, dispose() (and the rollback) decline to destroy, and a later + // dispose() runs the drain again — by which time the queue has usually been + // delivered. A counter that is somehow stuck nonzero therefore costs each + // attempt at most another bounded wait and a rejection, never a stranded + // promise; the process-exit teardown still reclaims the context. + const drainError = new Error( + 'the wasm environment still reports ' + + queued + + ' queued settlement(s) after ' + + limit + + ' event-loop turns; the context was not destroyed - retry dispose() to wait for the queue again', + ) + drainError.code = 'ERR_NAPI_WASI_CLEANUP_PENDING' + throw drainError + })().then( + (value) => { + // Set only when the wait actually finished AND the queue was seen empty + // (or is unobservable): a drain that timed out with settlements still + // queued rejects above and must stay repeatable. + __emnapiWasmEnvCleanupDrained = true + __emnapiWasmEnvCleanupDrainPromise = undefined + return value + }, + (error) => { + __emnapiWasmEnvCleanupDrainPromise = undefined + throw error + }, + ) + __emnapiWasmEnvCleanupDrainPromise = drainPromise + return drainPromise +} + +function __destroyEmnapiContext() { + if (__emnapiContextDestroyed || __emnapiContext === undefined) { + __emnapiContextDestroyed = true + return + } + if (__emnapiContextDestroyPromise) { + return __emnapiContextDestroyPromise + } + + __prepareWasmEnvCleanup() + const result = __emnapiContext.destroy() + if (!__isThenable(result)) { + __emnapiContextDestroyed = true + return + } + + const destroyPromise = Promise.resolve(result).then( + (value) => { + __emnapiContextDestroyed = true + return value + }, + (error) => { + __emnapiContextDestroyPromise = undefined + throw error + }, + ) + __emnapiContextDestroyPromise = destroyPromise + return destroyPromise +} + +function __terminateWasiWorkers() { + const cleanupErrors = [] + const pending = [] + + for (const worker of __wasiWorkers) { + let result + try { + result = worker.terminate() + } catch (error) { + cleanupErrors.push(error) + continue + } + if (__isThenable(result)) { + pending.push( + Promise.resolve(result).then( + () => { + __wasiWorkers.delete(worker) + }, + (error) => { + cleanupErrors.push(error) + }, + ), + ) + } else { + __wasiWorkers.delete(worker) + } + } + + const finish = () => { + if (cleanupErrors.length > 0) { + throw __createCleanupError(cleanupErrors, 'Failed to terminate WASI workers') + } + } + return pending.length > 0 ? Promise.all(pending).then(finish) : finish() +} + +function __finishWasiDisposal() { + const workerResult = __terminateWasiWorkers() + if (__isThenable(workerResult)) { + return Promise.resolve(workerResult).then(__completeWasiDisposal) + } + return __completeWasiDisposal() +} + +function __continueWasiDisposal() { + const destroyResult = __destroyEmnapiContext() + if (__isThenable(destroyResult)) { + return Promise.resolve(destroyResult).then(__finishWasiDisposal) + } + return __finishWasiDisposal() +} + +function __startWasiDisposal() { + // Run the pre-teardown barrier, then let the settlements it queued actually + // reach JavaScript, and only then destroy the environment. Doing these two + // back to back is what strands them. + __prepareWasmEnvCleanup() + const drainResult = __drainWasmEnvCleanup() + if (__isThenable(drainResult)) { + return Promise.resolve(drainResult).then(__continueWasiDisposal) + } + return __continueWasiDisposal() +} + +/** + * Disposes this generated WASI binding. + * + * Access this function with: + * binding[Symbol.for('napi.rs.wasi.dispose')]() + */ +function __disposeWasiBinding() { + if (__wasiDisposePromise) { + return __wasiDisposePromise + } + if (__wasiDisposed) { + return Promise.resolve() + } + + let resolveDispose + let rejectDispose + const disposePromise = new Promise((resolve, reject) => { + resolveDispose = resolve + rejectDispose = reject + }) + __wasiDisposePromise = disposePromise + + let result + try { + result = __startWasiDisposal() + } catch (error) { + __wasiDisposePromise = undefined + rejectDispose(error) + return disposePromise + } + + Promise.resolve(result).then( + (value) => { + __wasiDisposed = true + resolveDispose(value) + }, + (error) => { + __wasiDisposePromise = undefined + rejectDispose(error) + }, + ) + return disposePromise +} + +function __publishWasiDispose(exports) { + Object.defineProperty(exports, __wasiDisposeSymbol, { + configurable: false, + enumerable: false, + value: __disposeWasiBinding, + writable: false, + }) +} + +function __finishWasiInitializationRollback(cleanupErrors) { + let workerResult + try { + workerResult = __terminateWasiWorkers() + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + return cleanupErrors + } + if (__isThenable(workerResult)) { + return Promise.resolve(workerResult) + .catch((cleanupError) => { + cleanupErrors.push(cleanupError) + }) + .then(() => cleanupErrors) + } + return cleanupErrors +} + +function __destroyContextForWasiRollback(cleanupErrors) { + let destroyResult + try { + destroyResult = __destroyEmnapiContext() + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + return __finishWasiInitializationRollback(cleanupErrors) + } + if (__isThenable(destroyResult)) { + return Promise.resolve(destroyResult) + .catch((cleanupError) => { + cleanupErrors.push(cleanupError) + }) + .then(() => __finishWasiInitializationRollback(cleanupErrors)) + } + return __finishWasiInitializationRollback(cleanupErrors) +} + +/** + * Leaves a rollback that could not reach the queued settlements undestroyed, and + * hands it to whatever this flavor has that can still reclaim it. + */ +function __retainFailedWasiRollback(cleanupErrors) { + try { + __retainWasiRollbackForRetry() + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + } + return cleanupErrors +} + +/** + * Initialization can fail *after* registration has already run, and registration + * runs with a live environment: a module-init hook can start async work and then + * return an error, and the promise it created may already have escaped into + * JavaScript. The barrier cancels that work and *queues* the settlement, so this + * path needs the same drain the ordinary disposal does — destroying without + * yielding discards the queue with a null env and strands the promise. + * + * Stays synchronous when nothing is queued, which covers every failure before + * `beforeInit`: there is no instance to run the barrier on, so nothing to drain. + * + * A barrier or drain that did *not* finish stops the rollback short of + * destroying, which is what `dispose()` already does — a rejected drain there + * never reaches `__continueWasiDisposal`. Destroying anyway is the worse of the + * two trades, and not because of what it saves: + * + * - It cannot deliver the settlements. `Context.destroy()` runs the + * threadsafe function's cleanup hook, which drains the queue with a null env + * and discards it, so a promise that already escaped into JavaScript hangs + * forever with nothing left that could ever settle it. + * - It saves less than it looks. `Context.destroy()` stops JavaScript calls + * and runs cleanup hooks; it does not free the wasm instance or its Memory, + * which this module's scope holds either way. What stopping short retains is + * the emnapi context's bookkeeping and its un-run cleanup hooks. + * - Retry is not theoretical. A rollback that records a cleanup error is + * already kept in the process-wide registry above, so re-`require()`ing this + * file replays it instead of re-instantiating — and the `6e15de6f` flag fix + * means the replay drains again rather than skipping it. Destroying first is + * what makes that retained record useless. + * + * The residual cost is honest: the CJS flavor hands the context to its + * `process.on('exit')` teardown, so a process that never retries still reclaims + * it on the way out. The ESM browser flavor has no equivalent — a module that + * throws while evaluating is permanently errored, so re-importing rethrows + * without re-running this file — and there the context stays until the realm + * goes away. That is the deliberate choice: a hung promise is a silent liveness + * bug with no upper bound, while the retained bookkeeping is bounded by the page. + */ +function __rollbackWasiInitialization() { + const cleanupErrors = [] + let drainResult + let settlementsUnreached = false + try { + __prepareWasmEnvCleanup() + drainResult = __drainWasmEnvCleanup() + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + settlementsUnreached = true + } + if (__isThenable(drainResult)) { + return Promise.resolve(drainResult).then( + () => __destroyContextForWasiRollback(cleanupErrors), + (cleanupError) => { + cleanupErrors.push(cleanupError) + return __retainFailedWasiRollback(cleanupErrors) + }, + ) + } + if (settlementsUnreached) { + return __retainFailedWasiRollback(cleanupErrors) + } + return __destroyContextForWasiRollback(cleanupErrors) +} + +let __wasiModule +let __napiModule + +try { + __emnapiContext = __emnapiCreateContext({ autoDestroy: false }) + __emnapiContext.suppressDestroy() + + ;({ + instance: __napiInstance, + module: __wasiModule, + napiModule: __napiModule, + } = await __emnapiInstantiateNapiModule(__wasmFile, { + context: __emnapiContext, + asyncWorkPoolSize: __asyncWorkPoolSize, + reuseWorker: { size: __asyncWorkPoolSize + __workerPoolSize }, + plugins: [__emnapiAsyncWorkPlugin, __emnapiTSFNPlugin], + wasi: __wasi, + onCreateWorker() { + const worker = new Worker(new URL('./wasi-worker-browser.mjs', import.meta.url), { + type: 'module', + }) + __wasiWorkers.add(worker) + + return worker + }, + overwriteImports(importObject) { + importObject.env = { + ...importObject.env, + ...importObject.napi, + ...importObject.emnapi, + memory: __sharedMemory, + } + return importObject + }, + beforeInit({ instance }) { + __napiInstance = instance + for (const name of Object.keys(instance.exports)) { + if (name.startsWith('__napi_register__')) { + instance.exports[name]() + } + } + }, + })) + __publishWasiDispose(__napiModule.exports) +} catch (error) { + const cleanupErrors = await __rollbackWasiInitialization() + throw __attachCleanupErrors(error, cleanupErrors) +} export default __napiModule.exports +export const BCRYPT_API_VERSION = __napiModule.exports.BCRYPT_API_VERSION export const DEFAULT_COST = __napiModule.exports.DEFAULT_COST export const genSalt = __napiModule.exports.genSalt export const genSaltSync = __napiModule.exports.genSaltSync diff --git a/packages/bcrypt/bcrypt.wasi.cjs b/packages/bcrypt/bcrypt.wasi.cjs index 36d106d8..3e547006 100644 --- a/packages/bcrypt/bcrypt.wasi.cjs +++ b/packages/bcrypt/bcrypt.wasi.cjs @@ -1,3 +1,4 @@ +// napi-rs-artifact-metadata:{"version":2,"rootEntry":"binding.js","exports":["BCRYPT_API_VERSION","DEFAULT_COST","genSalt","genSaltSync","hash","hashSync","verify","verifySync"],"managedRootEntries":["browser.js","binding.js","bcrypt.wasm","bcrypt.debug.wasm"]} /* eslint-disable */ /* prettier-ignore */ @@ -9,10 +10,101 @@ const { WASI: __nodeWASI } = require('node:wasi') const { Worker } = require('node:worker_threads') const { + emnapiAsyncWorkPlugin: __emnapiAsyncWorkPlugin, + emnapiTSFNPlugin: __emnapiTSFNPlugin, createOnMessage: __wasmCreateOnMessageForFsProxy, - getDefaultContext: __emnapiGetDefaultContext, instantiateNapiModuleSync: __emnapiInstantiateNapiModuleSync, } = require('@napi-rs/wasm-runtime') +const { createContext: __emnapiCreateContext } = require('@emnapi/runtime') + +function __getWasiWorkerExecArgv() { + const __workerExecArgv = [] + for (let __index = 0; __index < process.execArgv.length; __index += 1) { + const __arg = process.execArgv[__index] + if ( + __arg === '--input-type' || + __arg === '--eval' || + __arg === '-e' || + __arg === '--print' || + __arg === '-p' + ) { + __index += 1 + continue + } + if ( + __arg.startsWith('--input-type=') || + __arg.startsWith('--eval=') || + __arg.startsWith('--print=') + ) { + continue + } + __workerExecArgv.push(__arg) + } + return __workerExecArgv +} + +function __isInvalidWasiWorkerExecArgv(errorMessage, argument) { + const __equalsIndex = argument.indexOf('=') + const __argumentName = + __equalsIndex === -1 ? argument : argument.slice(0, __equalsIndex) + return ( + errorMessage.includes(': ' + __argumentName + ',') || + errorMessage.includes(': ' + __argumentName + '=') || + errorMessage.endsWith(': ' + __argumentName) || + errorMessage.includes(', ' + __argumentName + ',') || + errorMessage.includes(', ' + __argumentName + '=') || + errorMessage.endsWith(', ' + __argumentName) + ) +} + +function __removeInvalidWasiWorkerExecArgv(execArgv, error) { + if (typeof error.message !== 'string') { + return + } + const __workerExecArgv = [] + let __removed = false + for (let __index = 0; __index < execArgv.length; __index += 1) { + const __arg = execArgv[__index] + if ( + __arg.startsWith('-') && + __isInvalidWasiWorkerExecArgv(error.message, __arg) + ) { + __removed = true + if ( + !__arg.includes('=') && + __index + 1 < execArgv.length && + !execArgv[__index + 1].startsWith('-') + ) { + __index += 1 + } + continue + } + __workerExecArgv.push(__arg) + } + return __removed ? __workerExecArgv : undefined +} + +function __createWasiWorker(filename) { + let __workerExecArgv = __getWasiWorkerExecArgv() + while (true) { + try { + return new Worker(filename, { + env: process.env, + execArgv: __workerExecArgv, + }) + } catch (error) { + if (!error || error.code !== 'ERR_WORKER_INVALID_EXEC_ARGV') { + throw error + } + const __nextWorkerExecArgv = + __removeInvalidWasiWorkerExecArgv(__workerExecArgv, error) + if (!__nextWorkerExecArgv) { + throw error + } + __workerExecArgv = __nextWorkerExecArgv + } + } +} const __rootDir = __nodePath.parse(process.cwd()).root @@ -24,8 +116,6 @@ const __wasi = new __nodeWASI({ } }) -const __emnapiContext = __emnapiGetDefaultContext() - const __sharedMemory = new WebAssembly.Memory({ initial: 4000, maximum: 65536, @@ -38,76 +128,800 @@ const __wasmDebugFilePath = __nodePath.join(__dirname, 'bcrypt.wasm32-wasi.debug if (__nodeFs.existsSync(__wasmDebugFilePath)) { __wasmFilePath = __wasmDebugFilePath } else if (!__nodeFs.existsSync(__wasmFilePath)) { + const __wasiPackageEntry = require.resolve('@node-rs/bcrypt-wasm32-wasi') + const __packagedWasmFilePath = __nodePath.join( + __nodePath.dirname(__wasiPackageEntry), + 'bcrypt.wasm32-wasi.wasm', + ) + if (!__nodeFs.existsSync(__packagedWasmFilePath)) { + throw new Error( + '@node-rs/bcrypt-wasm32-wasi is installed but is missing bcrypt.wasm32-wasi.wasm.', + ) + } + __wasmFilePath = __packagedWasmFilePath +} + +const __wasmFile = __nodeFs.readFileSync(__wasmFilePath) +let __emnapiContext + +const __wasiDisposeSymbol = Symbol.for('napi.rs.wasi.dispose') +const __wasiWorkers = new Set() +let __napiInstance +let __emnapiContextDestroyed = false +let __emnapiContextDestroyPromise +let __emnapiWasmEnvCleanupPrepared = false +let __emnapiWasmEnvCleanupRan = false +let __emnapiWasmEnvCleanupDrained = false +let __emnapiWasmEnvCleanupDrainPromise +let __wasiDisposed = false +let __wasiDisposePromise +let __completeWasiDisposal = function() {} +// Overridden by loader flavors that have a last-resort reclaim for a rollback +// that stopped short of destroying the context. See +// `__rollbackWasiInitialization`. +let __retainWasiRollbackForRetry = function() {} + +function __isThenable(value) { + return ( + value !== null && + (typeof value === 'object' || typeof value === 'function') && + typeof value.then === 'function' + ) +} + +function __createCleanupError(errors, message) { + if (errors.length === 1) { + return errors[0] + } + const __AggregateError = globalThis.AggregateError + if (typeof __AggregateError === 'function') { + return new __AggregateError(errors, message) + } + const error = new Error(message) + error.errors = errors + return error +} + +function __attachCleanupErrors(error, cleanupErrors) { + if (cleanupErrors.length === 0) { + return error + } + const cleanupError = __createCleanupError( + cleanupErrors, + 'WASI binding cleanup failed', + ) try { - __wasmFilePath = require.resolve('@node-rs/bcrypt-wasm32-wasi/bcrypt.wasm32-wasi.wasm') - } catch { - throw new Error('Cannot find bcrypt.wasm32-wasi.wasm file, and @node-rs/bcrypt-wasm32-wasi package is not installed.') + if ( + error && + (typeof error === 'object' || typeof error === 'function') + ) { + if (error.cause === undefined) { + error.cause = cleanupError + if (error.cause === cleanupError) { + return error + } + } + if (Array.isArray(error.cleanupErrors)) { + error.cleanupErrors.push(cleanupError) + return error + } else { + const attachedCleanupErrors = [cleanupError] + error.cleanupErrors = attachedCleanupErrors + if (error.cleanupErrors === attachedCleanupErrors) { + return error + } + } + } + } catch {} + const aggregate = __createCleanupError( + [error, cleanupError], + 'WASI binding initialization and cleanup failed', + ) + try { + aggregate.cause = error + } catch {} + return aggregate +} + +function __prepareWasmEnvCleanup() { + if (__emnapiWasmEnvCleanupPrepared) { + return + } + const prepare = __napiInstance?.exports?.napi_prepare_wasm_env_cleanup + if (typeof prepare === 'function') { + prepare() + __emnapiWasmEnvCleanupRan = true } + __emnapiWasmEnvCleanupPrepared = true } -const { instance: __napiInstance, module: __wasiModule, napiModule: __napiModule } = __emnapiInstantiateNapiModuleSync(__nodeFs.readFileSync(__wasmFilePath), { - context: __emnapiContext, - asyncWorkPoolSize: (function() { - const threadsSizeFromEnv = Number(process.env.NAPI_RS_ASYNC_WORK_POOL_SIZE ?? process.env.UV_THREADPOOL_SIZE) - // NaN > 0 is false - if (threadsSizeFromEnv > 0) { - return threadsSizeFromEnv - } else { - return 4 - } - })(), - reuseWorker: true, - wasi: __wasi, - onCreateWorker() { - const worker = new Worker(__nodePath.join(__dirname, 'wasi-worker.mjs'), { - env: process.env, - }) - worker.onmessage = ({ data }) => { - __wasmCreateOnMessageForFsProxy(__nodeFs)(data) - } - - // The main thread of Node.js waits for all the active handles before exiting. - // But Rust threads are never waited without `thread::join`. - // So here we hack the code of Node.js to prevent the workers from being referenced (active). - // According to https://github.com/nodejs/node/blob/19e0d472728c79d418b74bddff588bea70a403d0/lib/internal/worker.js#L415, - // a worker is consist of two handles: kPublicPort and kHandle. - { - const kPublicPort = Object.getOwnPropertySymbols(worker).find(s => - s.toString().includes("kPublicPort") - ); - if (kPublicPort) { - worker[kPublicPort].ref = () => {}; +// Mirror the primitive @emnapi/core schedules its threadsafe-function dispatch +// on, so the drain turns below interleave with that dispatch instead of racing +// ahead of it on a faster queue. +const __scheduleMacrotask = (function () { + if (typeof setImmediate === 'function') { + return function (callback) { + setImmediate(callback) + } + } + const __MessageChannel = globalThis.MessageChannel + if (typeof __MessageChannel === 'function') { + return function (callback) { + const channel = new __MessageChannel() + channel.port1.onmessage = function () { + channel.port1.onmessage = null + try { + channel.port1.close() + } catch {} + try { + channel.port2.close() + } catch {} + callback() } + channel.port2.postMessage(null) + } + } + return function (callback) { + setTimeout(callback, 0) + } +})() - const kHandle = Object.getOwnPropertySymbols(worker).find(s => - s.toString().includes("kHandle") - ); - if (kHandle) { - worker[kHandle].ref = () => {}; +// Turns to wait for while the addon still reports queued settlements. Reaching +// zero is the only success. A counter still nonzero at this bound rejects the +// disposal as retryable (`ERR_NAPI_WASI_CLEANUP_PENDING`) rather than +// destroying the context over a still-queued settlement — the wait stays +// bounded either way. +const __WASM_ENV_CLEANUP_DRAIN_TURNS = 128 +// Without `napi_wasm_env_cleanup_pending` the queue is not observable. Fall +// back to the number of turns @emnapi/core needs to coalesce and dispatch a +// call made on this thread (two), plus a margin. +const __WASM_ENV_CLEANUP_BLIND_DRAIN_TURNS = 4 + +/** + * `napi_prepare_wasm_env_cleanup` only *queues* the promise settlements of the + * tasks it cancelled: `napi_call_threadsafe_function` appends to the + * threadsafe-function queue, and @emnapi/core dispatches that queue from a + * macrotask — two coalescing turns later, even for a call made on this very + * thread. `Context.destroy()` then runs the threadsafe function's cleanup hook, + * which drains the queue with a null env and *discards* whatever is still in it. + * + * So destroying without yielding first strands exactly the promises the barrier + * exists to settle. Yield real event-loop turns until the addon reports the + * queue empty; microtask checkpoints cannot help, no number of them lets a + * macrotask run. + * + * Returns nothing when there is nothing to wait for, which keeps disposal + * synchronous in the common case. + * + * The "already drained" flag is set only once a wait has actually finished. + * Scheduling a macrotask can fail — a host-provided or patched `setImmediate` + * that throws is enough — and a disposal that rejects stays retryable, so + * marking the drain complete up front would make the retry skip it and destroy + * the context with the barrier's settlements still queued. + * + * A wait that runs out of turns with the counter still nonzero rejects with + * `ERR_NAPI_WASI_CLEANUP_PENDING` for the same reason: at that point + * "finished" is indistinguishable from the stranding above, and destroying + * would discard the very settlement the wait was for. The rejection leaves the + * flag unset and disposal retryable. + */ +function __drainWasmEnvCleanup() { + if (__emnapiWasmEnvCleanupDrained || !__emnapiWasmEnvCleanupRan) { + return + } + if (__emnapiWasmEnvCleanupDrainPromise) { + return __emnapiWasmEnvCleanupDrainPromise + } + const pending = __napiInstance?.exports?.napi_wasm_env_cleanup_pending + const observable = typeof pending === 'function' + if (observable) { + let queued + try { + queued = pending() + } catch { + __emnapiWasmEnvCleanupDrained = true + return + } + if (!queued) { + __emnapiWasmEnvCleanupDrained = true + return + } + } + const limit = observable + ? __WASM_ENV_CLEANUP_DRAIN_TURNS + : __WASM_ENV_CLEANUP_BLIND_DRAIN_TURNS + const drainPromise = (async () => { + let queued = 0 + for (let turn = 0; turn < limit; turn++) { + await new Promise((resolve) => { + __scheduleMacrotask(resolve) + }) + if (!observable) { + continue + } + try { + queued = pending() + } catch { + return + } + if (!queued) { + return } + } + if (!observable) { + // Blind wait: without `napi_wasm_env_cleanup_pending` the bound IS the + // contract — there is nothing to consult, so finishing the turns is + // finishing the drain. + return + } + // The counter is still nonzero after every turn the bound allows. The wait + // stays bounded — but claiming success here would be indistinguishable from + // the stranding this drain exists to prevent: disposal would go on to + // destroy the context, whose cleanup hook discards the still-queued + // settlement with a null env, and the promise it was for hangs forever. + // Reject instead, as a retryable cleanup failure: the drained flag stays + // unset, dispose() (and the rollback) decline to destroy, and a later + // dispose() runs the drain again — by which time the queue has usually been + // delivered. A counter that is somehow stuck nonzero therefore costs each + // attempt at most another bounded wait and a rejection, never a stranded + // promise; the process-exit teardown still reclaims the context. + const drainError = new Error( + 'the wasm environment still reports ' + + queued + + ' queued settlement(s) after ' + + limit + + ' event-loop turns; the context was not destroyed - retry dispose() to wait for the queue again', + ) + drainError.code = 'ERR_NAPI_WASI_CLEANUP_PENDING' + throw drainError + })().then( + (value) => { + // Set only when the wait actually finished AND the queue was seen empty + // (or is unobservable): a drain that timed out with settlements still + // queued rejects above and must stay repeatable. + __emnapiWasmEnvCleanupDrained = true + __emnapiWasmEnvCleanupDrainPromise = undefined + return value + }, + (error) => { + __emnapiWasmEnvCleanupDrainPromise = undefined + throw error + }, + ) + __emnapiWasmEnvCleanupDrainPromise = drainPromise + return drainPromise +} + +function __destroyEmnapiContext() { + if (__emnapiContextDestroyed || __emnapiContext === undefined) { + __emnapiContextDestroyed = true + return + } + if (__emnapiContextDestroyPromise) { + return __emnapiContextDestroyPromise + } + + __prepareWasmEnvCleanup() + const result = __emnapiContext.destroy() + if (!__isThenable(result)) { + __emnapiContextDestroyed = true + return + } + + const destroyPromise = Promise.resolve(result).then( + (value) => { + __emnapiContextDestroyed = true + return value + }, + (error) => { + __emnapiContextDestroyPromise = undefined + throw error + }, + ) + __emnapiContextDestroyPromise = destroyPromise + return destroyPromise +} + +function __terminateWasiWorkers() { + const cleanupErrors = [] + const pending = [] + + for (const worker of __wasiWorkers) { + let result + try { + result = worker.terminate() + } catch (error) { + cleanupErrors.push(error) + continue + } + if (__isThenable(result)) { + pending.push( + Promise.resolve(result).then( + () => { + __wasiWorkers.delete(worker) + }, + (error) => { + cleanupErrors.push(error) + }, + ), + ) + } else { + __wasiWorkers.delete(worker) + } + } + + const finish = () => { + if (cleanupErrors.length > 0) { + throw __createCleanupError( + cleanupErrors, + 'Failed to terminate WASI workers', + ) + } + } + return pending.length > 0 ? Promise.all(pending).then(finish) : finish() +} + +function __finishWasiDisposal() { + const workerResult = __terminateWasiWorkers() + if (__isThenable(workerResult)) { + return Promise.resolve(workerResult).then(__completeWasiDisposal) + } + return __completeWasiDisposal() +} + +function __continueWasiDisposal() { + const destroyResult = __destroyEmnapiContext() + if (__isThenable(destroyResult)) { + return Promise.resolve(destroyResult).then(__finishWasiDisposal) + } + return __finishWasiDisposal() +} + +function __startWasiDisposal() { + // Run the pre-teardown barrier, then let the settlements it queued actually + // reach JavaScript, and only then destroy the environment. Doing these two + // back to back is what strands them. + __prepareWasmEnvCleanup() + const drainResult = __drainWasmEnvCleanup() + if (__isThenable(drainResult)) { + return Promise.resolve(drainResult).then(__continueWasiDisposal) + } + return __continueWasiDisposal() +} + +/** + * Disposes this generated WASI binding. + * + * Access this function with: + * binding[Symbol.for('napi.rs.wasi.dispose')]() + */ +function __disposeWasiBinding() { + if (__wasiDisposePromise) { + return __wasiDisposePromise + } + if (__wasiDisposed) { + return Promise.resolve() + } - worker.unref(); - } - return worker - }, - overwriteImports(importObject) { - importObject.env = { - ...importObject.env, - ...importObject.napi, - ...importObject.emnapi, - memory: __sharedMemory, - } - return importObject - }, - beforeInit({ instance }) { - for (const name of Object.keys(instance.exports)) { - if (name.startsWith('__napi_register__')) { - instance.exports[name]() + let resolveDispose + let rejectDispose + const disposePromise = new Promise((resolve, reject) => { + resolveDispose = resolve + rejectDispose = reject + }) + __wasiDisposePromise = disposePromise + + let result + try { + result = __startWasiDisposal() + } catch (error) { + __wasiDisposePromise = undefined + rejectDispose(error) + return disposePromise + } + + Promise.resolve(result).then( + (value) => { + __wasiDisposed = true + resolveDispose(value) + }, + (error) => { + __wasiDisposePromise = undefined + rejectDispose(error) + }, + ) + return disposePromise +} + +function __publishWasiDispose(exports) { + Object.defineProperty(exports, __wasiDisposeSymbol, { + configurable: false, + enumerable: false, + value: __disposeWasiBinding, + writable: false, + }) +} + +function __finishWasiInitializationRollback(cleanupErrors) { + let workerResult + try { + workerResult = __terminateWasiWorkers() + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + return cleanupErrors + } + if (__isThenable(workerResult)) { + return Promise.resolve(workerResult) + .catch((cleanupError) => { + cleanupErrors.push(cleanupError) + }) + .then(() => cleanupErrors) + } + return cleanupErrors +} + +function __destroyContextForWasiRollback(cleanupErrors) { + let destroyResult + try { + destroyResult = __destroyEmnapiContext() + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + return __finishWasiInitializationRollback(cleanupErrors) + } + if (__isThenable(destroyResult)) { + return Promise.resolve(destroyResult) + .catch((cleanupError) => { + cleanupErrors.push(cleanupError) + }) + .then(() => __finishWasiInitializationRollback(cleanupErrors)) + } + return __finishWasiInitializationRollback(cleanupErrors) +} + +/** + * Leaves a rollback that could not reach the queued settlements undestroyed, and + * hands it to whatever this flavor has that can still reclaim it. + */ +function __retainFailedWasiRollback(cleanupErrors) { + try { + __retainWasiRollbackForRetry() + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + } + return cleanupErrors +} + +/** + * Initialization can fail *after* registration has already run, and registration + * runs with a live environment: a module-init hook can start async work and then + * return an error, and the promise it created may already have escaped into + * JavaScript. The barrier cancels that work and *queues* the settlement, so this + * path needs the same drain the ordinary disposal does — destroying without + * yielding discards the queue with a null env and strands the promise. + * + * Stays synchronous when nothing is queued, which covers every failure before + * `beforeInit`: there is no instance to run the barrier on, so nothing to drain. + * + * A barrier or drain that did *not* finish stops the rollback short of + * destroying, which is what `dispose()` already does — a rejected drain there + * never reaches `__continueWasiDisposal`. Destroying anyway is the worse of the + * two trades, and not because of what it saves: + * + * - It cannot deliver the settlements. `Context.destroy()` runs the + * threadsafe function's cleanup hook, which drains the queue with a null env + * and discards it, so a promise that already escaped into JavaScript hangs + * forever with nothing left that could ever settle it. + * - It saves less than it looks. `Context.destroy()` stops JavaScript calls + * and runs cleanup hooks; it does not free the wasm instance or its Memory, + * which this module's scope holds either way. What stopping short retains is + * the emnapi context's bookkeeping and its un-run cleanup hooks. + * - Retry is not theoretical. A rollback that records a cleanup error is + * already kept in the process-wide registry above, so re-`require()`ing this + * file replays it instead of re-instantiating — and the `6e15de6f` flag fix + * means the replay drains again rather than skipping it. Destroying first is + * what makes that retained record useless. + * + * The residual cost is honest: the CJS flavor hands the context to its + * `process.on('exit')` teardown, so a process that never retries still reclaims + * it on the way out. The ESM browser flavor has no equivalent — a module that + * throws while evaluating is permanently errored, so re-importing rethrows + * without re-running this file — and there the context stays until the realm + * goes away. That is the deliberate choice: a hung promise is a silent liveness + * bug with no upper bound, while the retained bookkeeping is bounded by the page. + */ +function __rollbackWasiInitialization() { + const cleanupErrors = [] + let drainResult + let settlementsUnreached = false + try { + __prepareWasmEnvCleanup() + drainResult = __drainWasmEnvCleanup() + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + settlementsUnreached = true + } + if (__isThenable(drainResult)) { + return Promise.resolve(drainResult).then( + () => __destroyContextForWasiRollback(cleanupErrors), + (cleanupError) => { + cleanupErrors.push(cleanupError) + return __retainFailedWasiRollback(cleanupErrors) + }, + ) + } + if (settlementsUnreached) { + return __retainFailedWasiRollback(cleanupErrors) + } + return __destroyContextForWasiRollback(cleanupErrors) +} + +const __wasiRollbackRegistrySymbol = Symbol.for('napi.rs.wasi.rollback.registry.v1') +const __wasiRollbackRegistryKey = + typeof __filename === 'string' ? __filename : __wasmFilePath + +function __getWasiRollbackRegistry() { + const existing = process[__wasiRollbackRegistrySymbol] + if (existing !== undefined) { + if (!(existing instanceof Map)) { + throw new TypeError( + 'The process-wide NAPI-RS WASI rollback registry is invalid', + ) + } + return existing + } + const registry = new Map() + Object.defineProperty(process, __wasiRollbackRegistrySymbol, { + configurable: false, + enumerable: false, + value: registry, + writable: false, + }) + return registry +} + +const __wasiRollbackRegistry = __getWasiRollbackRegistry() + +function __completeWasiInitializationRollback(record, cleanupErrors) { + try { + if (cleanupErrors.length === 0) { + if ( + __wasiRollbackRegistry.get(__wasiRollbackRegistryKey) === record + ) { + __wasiRollbackRegistry.delete(__wasiRollbackRegistryKey) } + return } - }, -}) + record.error = __attachCleanupErrors(record.error, cleanupErrors) + } catch (cleanupError) { + try { + record.error = __createCleanupError( + [record.error, cleanupError], + 'WASI binding initialization and cleanup failed', + ) + } catch {} + } finally { + record.active = false + record.promise = undefined + } +} + +function __runWasiInitializationRollback(record) { + if (record.active) { + return + } + record.active = true + + let rollbackResult + try { + rollbackResult = record.rollback() + } catch (cleanupError) { + __completeWasiInitializationRollback(record, [cleanupError]) + return + } + + if (!__isThenable(rollbackResult)) { + __completeWasiInitializationRollback(record, rollbackResult) + return + } + + record.promise = Promise.resolve(rollbackResult).then( + (cleanupErrors) => { + __completeWasiInitializationRollback(record, cleanupErrors) + }, + (cleanupError) => { + __completeWasiInitializationRollback(record, [cleanupError]) + }, + ) +} + +const __pendingWasiRollback = __wasiRollbackRegistry.get( + __wasiRollbackRegistryKey, +) +if (__pendingWasiRollback !== undefined) { + __runWasiInitializationRollback(__pendingWasiRollback) + throw __pendingWasiRollback.error +} + +let __wasiModule +let __napiModule +let __wasiExitListenerRegistered = false + +function __removeWasiExitListener() { + if ( + __wasiExitListenerRegistered && + typeof process.removeListener === 'function' + ) { + process.removeListener('exit', __disposeWasiBindingAtExit) + } + __wasiExitListenerRegistered = false +} + +function __disposeWasiBindingAtExit() { + __wasiExitListenerRegistered = false + // An 'exit' handler cannot yield, so it cannot wait for queued promise + // settlements the way __startWasiDisposal does — the process is leaving and + // those promises have no observer left anyway. Run the synchronous teardown + // directly. Every step is idempotent, which also makes this the synchronous + // finish for a disposal that is still waiting for its drain. + try { + __destroyEmnapiContext() + } catch {} + try { + const workerResult = __terminateWasiWorkers() + if (__isThenable(workerResult)) { + void Promise.resolve(workerResult).catch(() => {}) + } + } catch {} +} + +function __registerWasiExitListener() { + if ( + !__wasiExitListenerRegistered && + typeof process.once === 'function' + ) { + process.once('exit', __disposeWasiBindingAtExit) + __wasiExitListenerRegistered = true + } +} + +__completeWasiDisposal = __removeWasiExitListener +// A rollback that could not reach the queued settlements keeps the context so +// the registry replay above can retry it. Nothing forces that replay to happen, +// so hand the context to the same synchronous teardown a successful load uses: +// a process that exits without ever retrying still runs the cleanup hooks. The +// handler cannot yield, so it does not settle anything — but by then the process +// is leaving and those promises have no observer left anyway. +__retainWasiRollbackForRetry = __registerWasiExitListener + +function __captureEmnapiAutoDestroyListener() { + if ( + typeof process.prependListener !== 'function' || + typeof process.removeListener !== 'function' + ) { + return + } + let __autoDestroyListener + const __captureListener = (__event, __listener) => { + if (__event === 'beforeExit' && __autoDestroyListener === undefined) { + __autoDestroyListener = __listener + } + } + try { + // Run before existing newListener hooks so a hook that registers its own + // beforeExit listener cannot be mistaken for emnapi's registration. + process.prependListener('newListener', __captureListener) + } catch { + return + } + return () => { + try { + process.removeListener('newListener', __captureListener) + } catch {} + if (__autoDestroyListener !== undefined) { + try { + process.removeListener('beforeExit', __autoDestroyListener) + } catch {} + } + } +} + +try { + const __finishAutoDestroyCapture = __captureEmnapiAutoDestroyListener() + try { + __emnapiContext = __emnapiCreateContext({ autoDestroy: false }) + // emnapi 2.x still registers an unconditional once-listener for + // beforeExit that auto-destroys the context, and suppressDestroy() only + // neutralizes its callback without removing it. This loader owns cleanup + // through its 'exit' listener, so emnapi's listener is captured and + // removed; suppressDestroy() remains the safety net when removal fails. + __emnapiContext.suppressDestroy() + } finally { + // Remove only the exact emnapi callback captured above. + __finishAutoDestroyCapture?.() + } + + ;({ + instance: __napiInstance, + module: __wasiModule, + napiModule: __napiModule, + } = __emnapiInstantiateNapiModuleSync(__wasmFile, { + context: __emnapiContext, + asyncWorkPoolSize: (function() { + const threadsSizeFromEnv = Number(process.env.NAPI_RS_ASYNC_WORK_POOL_SIZE ?? process.env.UV_THREADPOOL_SIZE) + // NaN > 0 is false + if (threadsSizeFromEnv > 0) { + return threadsSizeFromEnv + } else { + return 4 + } + })(), + reuseWorker: true, + plugins: [__emnapiAsyncWorkPlugin, __emnapiTSFNPlugin], + wasi: __wasi, + onCreateWorker() { + const worker = __createWasiWorker(__nodePath.join(__dirname, 'wasi-worker.mjs')) + __wasiWorkers.add(worker) + worker.onmessage = ({ data }) => { + __wasmCreateOnMessageForFsProxy(__nodeFs)(data) + } + + // The main thread of Node.js waits for all the active handles before exiting. + // But Rust threads are never waited without `thread::join`. + // So here we hack the code of Node.js to prevent the workers from being referenced (active). + // According to https://github.com/nodejs/node/blob/19e0d472728c79d418b74bddff588bea70a403d0/lib/internal/worker.js#L415, + // a worker is consist of two handles: kPublicPort and kHandle. + { + const kPublicPort = Object.getOwnPropertySymbols(worker).find(s => + s.toString().includes("kPublicPort") + ); + if (kPublicPort) { + worker[kPublicPort].ref = () => {}; + } + + const kHandle = Object.getOwnPropertySymbols(worker).find(s => + s.toString().includes("kHandle") + ); + if (kHandle) { + worker[kHandle].ref = () => {}; + } + + worker.unref(); + } + return worker + }, + overwriteImports(importObject) { + importObject.env = { + ...importObject.env, + ...importObject.napi, + ...importObject.emnapi, + memory: __sharedMemory, + } + return importObject + }, + beforeInit({ instance }) { + __napiInstance = instance + for (const name of Object.keys(instance.exports)) { + if (name.startsWith('__napi_register__')) { + instance.exports[name]() + } + } + }, + })) + __publishWasiDispose(__napiModule.exports) + __registerWasiExitListener() +} catch (error) { + const rollback = { + active: false, + error, + promise: undefined, + rollback: __rollbackWasiInitialization, + } + __wasiRollbackRegistry.set(__wasiRollbackRegistryKey, rollback) + __runWasiInitializationRollback(rollback) + throw rollback.error +} module.exports = __napiModule.exports +module.exports.BCRYPT_API_VERSION = __napiModule.exports.BCRYPT_API_VERSION module.exports.DEFAULT_COST = __napiModule.exports.DEFAULT_COST module.exports.genSalt = __napiModule.exports.genSalt module.exports.genSaltSync = __napiModule.exports.genSaltSync diff --git a/packages/bcrypt/bcrypt.wasi.d.cts b/packages/bcrypt/bcrypt.wasi.d.cts index 06073ad4..4b353539 100644 --- a/packages/bcrypt/bcrypt.wasi.d.cts +++ b/packages/bcrypt/bcrypt.wasi.d.cts @@ -1,2 +1,39 @@ /* auto-generated by NAPI-RS */ -export * from './index.js' +/* eslint-disable */ +/** Internal binding contract, checked by the public JavaScript wrapper. */ +export const BCRYPT_API_VERSION: number + +export const DEFAULT_COST: number + +export declare function genSalt( + round: number, + version?: string | undefined | null, + signal?: AbortSignal | undefined | null, +): Promise + +export declare function genSaltSync(round: number, version?: string | undefined | null): string + +export declare function hash( + input: string | Uint8Array, + cost: number | undefined | null, + salt: string | Uint8Array | undefined | null, + version: string | undefined | null, + rejectLongPasswords: boolean, + signal?: AbortSignal | undefined | null, +): Promise + +export declare function hashSync( + input: string | Uint8Array, + cost: number | undefined | null, + salt: string | Uint8Array | undefined | null, + version: string | undefined | null, + rejectLongPasswords: boolean, +): string + +export declare function verify( + password: string | Uint8Array, + hash: string | Uint8Array, + signal?: AbortSignal | undefined | null, +): Promise + +export declare function verifySync(input: string | Uint8Array, hash: string | Uint8Array): boolean diff --git a/packages/bcrypt/benchmark/bcrypt.ts b/packages/bcrypt/benchmark/bcrypt.ts index 96e1ccf7..988dac04 100644 --- a/packages/bcrypt/benchmark/bcrypt.ts +++ b/packages/bcrypt/benchmark/bcrypt.ts @@ -4,7 +4,7 @@ import { hashSync, compare, genSaltSync } from 'bcrypt' import bcryptjs from 'bcryptjs' import { Bench } from 'tinybench' -import { hashSync as napiHashSync, verifySync, genSaltSync as napiGenSaltSync } from '../binding.js' +import { hashSync as napiHashSync, verifySync, genSaltSync as napiGenSaltSync } from '../index.js' const password = 'node-rust-password' @@ -14,7 +14,7 @@ const syncHashSuite = new Bench({ syncHashSuite .add('@node-rs/bcrypt', () => { - napiHashSync(password, 10) + napiHashSync(password, { cost: 10 }) }) .add('node bcrypt', () => { hashSync(password, 10) @@ -36,7 +36,7 @@ console.table(syncHashSuite.table()) const verifySuite = new Bench({ name: 'Verify benchmark`', }) -const hashed = napiHashSync(password, 12) +const hashed = napiHashSync(password, { cost: 12 }) verifySuite .add('@node-rs/bcrypt', () => { verifySync(password, hashed) @@ -57,7 +57,7 @@ const genSaltSuite = new Bench({ }) genSaltSuite .add('@node-rs/bcrypt', () => { - napiGenSaltSync(12) + napiGenSaltSync({ cost: 12 }) }) .add('node bcrypt', () => { genSaltSync(12) diff --git a/packages/bcrypt/binding.d.ts b/packages/bcrypt/binding.d.ts index d37a9867..3c6bc8ca 100644 --- a/packages/bcrypt/binding.d.ts +++ b/packages/bcrypt/binding.d.ts @@ -1,15 +1,18 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ +/** Internal binding contract, checked by the public JavaScript wrapper. */ +export const BCRYPT_API_VERSION: number + export const DEFAULT_COST: number -export declare function genSalt(round: number, version?: '2a' | '2x' | '2y' | '2b', signal?: AbortSignal): Promise +export declare function genSalt(round: number, version?: string | undefined | null, signal?: AbortSignal | undefined | null): Promise -export declare function genSaltSync(round: number, version?: '2a' | '2x' | '2y' | '2b'): string +export declare function genSaltSync(round: number, version?: string | undefined | null): string -export declare function hash(input: Uint8Array | string, cost?: number | undefined | null, salt?: string | Uint8Array | undefined | null, signal?: AbortSignal | undefined | null): Promise +export declare function hash(input: string | Uint8Array, cost: number | undefined | null, salt: string | Uint8Array | undefined | null, version: string | undefined | null, rejectLongPasswords: boolean, signal?: AbortSignal | undefined | null): Promise -export declare function hashSync(input: string | Uint8Array, cost?: number | undefined | null, salt?: string | Uint8Array | undefined | null): string +export declare function hashSync(input: string | Uint8Array, cost: number | undefined | null, salt: string | Uint8Array | undefined | null, version: string | undefined | null, rejectLongPasswords: boolean): string -export declare function verify(password: Uint8Array | string, hash: Uint8Array | string, signal?: AbortSignal | undefined | null): Promise +export declare function verify(password: string | Uint8Array, hash: string | Uint8Array, signal?: AbortSignal | undefined | null): Promise export declare function verifySync(input: string | Uint8Array, hash: string | Uint8Array): boolean diff --git a/packages/bcrypt/binding.js b/packages/bcrypt/binding.js index 799aa3fa..0ec4606b 100644 --- a/packages/bcrypt/binding.js +++ b/packages/bcrypt/binding.js @@ -77,8 +77,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-android-arm64') const bindingPackageVersion = require('@node-rs/bcrypt-android-arm64/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -93,8 +93,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-android-arm-eabi') const bindingPackageVersion = require('@node-rs/bcrypt-android-arm-eabi/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -114,8 +114,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-win32-x64-gnu') const bindingPackageVersion = require('@node-rs/bcrypt-win32-x64-gnu/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -130,8 +130,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-win32-x64-msvc') const bindingPackageVersion = require('@node-rs/bcrypt-win32-x64-msvc/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -147,8 +147,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-win32-ia32-msvc') const bindingPackageVersion = require('@node-rs/bcrypt-win32-ia32-msvc/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -163,8 +163,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-win32-arm64-msvc') const bindingPackageVersion = require('@node-rs/bcrypt-win32-arm64-msvc/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -182,8 +182,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-darwin-universal') const bindingPackageVersion = require('@node-rs/bcrypt-darwin-universal/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -198,8 +198,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-darwin-x64') const bindingPackageVersion = require('@node-rs/bcrypt-darwin-x64/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -214,8 +214,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-darwin-arm64') const bindingPackageVersion = require('@node-rs/bcrypt-darwin-arm64/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -234,8 +234,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-freebsd-x64') const bindingPackageVersion = require('@node-rs/bcrypt-freebsd-x64/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -250,8 +250,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-freebsd-arm64') const bindingPackageVersion = require('@node-rs/bcrypt-freebsd-arm64/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -271,8 +271,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-x64-musl') const bindingPackageVersion = require('@node-rs/bcrypt-linux-x64-musl/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -287,8 +287,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-x64-gnu') const bindingPackageVersion = require('@node-rs/bcrypt-linux-x64-gnu/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -305,8 +305,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-arm64-musl') const bindingPackageVersion = require('@node-rs/bcrypt-linux-arm64-musl/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -321,8 +321,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-arm64-gnu') const bindingPackageVersion = require('@node-rs/bcrypt-linux-arm64-gnu/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -339,8 +339,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-arm-musleabihf') const bindingPackageVersion = require('@node-rs/bcrypt-linux-arm-musleabihf/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -355,8 +355,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-arm-gnueabihf') const bindingPackageVersion = require('@node-rs/bcrypt-linux-arm-gnueabihf/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -373,8 +373,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-loong64-musl') const bindingPackageVersion = require('@node-rs/bcrypt-linux-loong64-musl/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -389,8 +389,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-loong64-gnu') const bindingPackageVersion = require('@node-rs/bcrypt-linux-loong64-gnu/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -407,8 +407,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-riscv64-musl') const bindingPackageVersion = require('@node-rs/bcrypt-linux-riscv64-musl/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -423,8 +423,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-riscv64-gnu') const bindingPackageVersion = require('@node-rs/bcrypt-linux-riscv64-gnu/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -440,8 +440,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-ppc64-gnu') const bindingPackageVersion = require('@node-rs/bcrypt-linux-ppc64-gnu/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -456,8 +456,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-s390x-gnu') const bindingPackageVersion = require('@node-rs/bcrypt-linux-s390x-gnu/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -476,8 +476,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-openharmony-arm64') const bindingPackageVersion = require('@node-rs/bcrypt-openharmony-arm64/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -492,8 +492,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-openharmony-x64') const bindingPackageVersion = require('@node-rs/bcrypt-openharmony-x64/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -508,8 +508,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-openharmony-arm') const bindingPackageVersion = require('@node-rs/bcrypt-openharmony-arm/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -648,8 +648,8 @@ if (!nativeBinding || forceWasi) { if (!candidateFailed) { if (process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { const bindingPackageVersion = require('@node-rs/bcrypt-wasm32-wasi/package.json').version - if (bindingPackageVersion !== '1.10.7') { - throw new Error(`WASI binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '2.0.0') { + throw new Error(`WASI binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } } wasiBinding = require('@node-rs/bcrypt-wasm32-wasi') @@ -700,6 +700,7 @@ if (!nativeBinding) { } module.exports = nativeBinding +module.exports.BCRYPT_API_VERSION = nativeBinding.BCRYPT_API_VERSION module.exports.DEFAULT_COST = nativeBinding.DEFAULT_COST module.exports.genSalt = nativeBinding.genSalt module.exports.genSaltSync = nativeBinding.genSaltSync diff --git a/packages/bcrypt/browser-entry.js b/packages/bcrypt/browser-entry.js new file mode 100644 index 00000000..b364294a --- /dev/null +++ b/packages/bcrypt/browser-entry.js @@ -0,0 +1,6 @@ +import binding from '@node-rs/bcrypt-wasm32-wasi' +import createBcrypt from './api.cjs' + +// Keep the public adapter separate from browser.js, which napi build regenerates. +const api = createBcrypt(binding) +export const { DEFAULT_COST, genSalt, genSaltSync, hash, hashSync, verify, verifySync, compare, compareSync } = api diff --git a/packages/bcrypt/index.d.ts b/packages/bcrypt/index.d.ts index 2a7429f4..884cc111 100644 --- a/packages/bcrypt/index.d.ts +++ b/packages/bcrypt/index.d.ts @@ -1,6 +1,67 @@ -import { verify, verifySync } from './binding' +export type Password = string | Uint8Array +/** Creation versions only; verification retains existing prefix handling independently. */ +export type Version = '2a' | '2b' | '2y' +export declare const DEFAULT_COST: number // Remains 12. -export const compare: typeof verify -export const compareSync: typeof verifySync +/** The cancellation interface used from native AbortSignals and compatible polyfills. */ +export interface AbortSignalLike { + readonly aborted: boolean + addEventListener(type: 'abort', listener: () => void, options?: { once?: boolean }): void + removeEventListener(type: 'abort', listener: () => void): void +} -export * from './binding' +export interface AsyncOptions { + /** + * For a valid call: pre-aborted signals reject with name AbortError before queueing. + * Later abort rejects the pending public Promise with AbortError; running native work + * may finish in the background. First observed settlement wins. Signal handlers + * are preserved; shared/reused signals work independently for each operation. + * Locally imported polyfills work without installing global constructors. + */ + signal?: AbortSignalLike +} + +export interface SaltOptions { + /** Finite integer 4..31; defaults to 12. */ + cost?: number + /** Defaults to 2b. 2x generation is removed. */ + version?: Version +} + +export type HashOptions = ( + | { salt?: Uint8Array; cost?: number; version?: Version } + | { salt: string; cost?: never; version?: never } +) & { + /** Defaults to false. Optional creation policy; never imposed on verification. */ + rejectLongPasswords?: boolean +} + +/** Canonical 29-character salt; async failures reject the returned Promise. */ +export declare function genSalt(options?: SaltOptions & AsyncOptions): Promise +export declare function genSaltSync(options?: SaltOptions): string + +/** + * Omitted salt generates 16 random bytes. Raw salts must contain exactly 16 bytes. + * String salts: exactly 29 ASCII characters, prefix 2a/2b/2y, two decimal cost + * digits in 04..31, and canonical bcrypt Base64 encoding exactly 16 salt bytes. + * Embedded 2x prefixes, +4 costs, padding, and noncanonical trailing bits are rejected + * during creation. Encoded salts supply their own cost/version. + * No positional legacy overload or implicit salt clipping/padding remains. + * Passwords retain existing bcrypt byte/truncation semantics by default. + */ +export declare function hash(password: Password, options?: HashOptions & AsyncOptions): Promise +export declare function hashSync(password: Password, options?: HashOptions): string + +/** + * Same password bytes and stored hash retain their previous verification result. + * No new length restriction, default cost ceiling, normalization, or prefix computation. + * Async argument errors reject. Existing successfully parsed encodings stay supported, + * including cost +4; creation's strict parser must not gate verification. + * Encodings rejected by the retained verifier parser and password mismatches return false. + * Invalid UTF-8 hash bytes also return false under the new error contract. + * Caller options cannot override the stored salt, cost, or version. + */ +export declare function verify(password: Password, encodedHash: string | Uint8Array, options?: AsyncOptions): Promise +export declare function verifySync(password: Password, encodedHash: string | Uint8Array): boolean +export declare const compare: typeof verify +export declare const compareSync: typeof verifySync diff --git a/packages/bcrypt/index.js b/packages/bcrypt/index.js index a6f3badc..b4b8571b 100644 --- a/packages/bcrypt/index.js +++ b/packages/bcrypt/index.js @@ -1,4 +1,5 @@ -const { DEFAULT_COST, genSaltSync, genSalt, hashSync, hash, verifySync, verify } = require('./binding') +const createBcrypt = require('./api.cjs') +const { DEFAULT_COST, genSaltSync, genSalt, hashSync, hash, verifySync, verify } = createBcrypt(require('./binding')) module.exports.DEFAULT_COST = DEFAULT_COST module.exports.genSaltSync = genSaltSync diff --git a/packages/bcrypt/package.json b/packages/bcrypt/package.json index c11714ae..d3316e2c 100644 --- a/packages/bcrypt/package.json +++ b/packages/bcrypt/package.json @@ -1,6 +1,6 @@ { "name": "@node-rs/bcrypt", - "version": "1.10.9", + "version": "2.0.0", "description": "Rust bcrypt binding", "keywords": [ "N-API", @@ -30,14 +30,16 @@ "files": [ "binding.d.ts", "binding.js", - "browser.js", + "browser-entry.js", "index.d.ts", "index.js", "LICENSE", - "LICENSE.rust-bcrypt" + "LICENSE.rust-bcrypt", + "api.cjs", + "MIGRATION.md" ], "main": "index.js", - "browser": "browser.js", + "browser": "browser-entry.js", "typings": "index.d.ts", "publishConfig": { "access": "public", @@ -47,7 +49,7 @@ "artifacts": "napi artifacts -d ../../artifacts", "bench": "cross-env NODE_ENV=production node --import @oxc-node/core/register benchmark/bcrypt.ts", "build": "napi build --platform --release --js binding.js --dts binding.d.ts", - "build:debug": "napi build --platform", + "build:debug": "napi build --platform --js binding.js --dts binding.d.ts", "prepublishOnly": "napi prepublish", "version": "napi version" }, @@ -55,9 +57,12 @@ "@cwasm/openbsd-bcrypt": "^0.1.0", "@cwasm/openwall-bcrypt": "^0.1.0", "@napi-rs/cli": "^3.8.6", + "@node-rs/bcrypt-wasm32-wasi": "1.10.9", "@types/bcrypt": "^6.0.0", "@types/bcryptjs": "^3.0.0", + "abort-controller": "3.0.0", "bcrypt": "^6.0.0", + "bcrypt-previous": "npm:@node-rs/bcrypt@1.10.9", "bcryptjs": "^3.0.3", "cross-env": "^10.1.0", "tinybench": "^6.1.3" @@ -79,7 +84,13 @@ "i686-pc-windows-msvc", "armv7-linux-androideabi", "wasm32-wasip1-threads" - ] + ], + "wasm": { + "optionalDependency": true, + "browser": { + "fs": false + } + } }, "engines": { "node": ">= 10" diff --git a/packages/bcrypt/src/hash_task.rs b/packages/bcrypt/src/hash_task.rs index 094b9875..ca30efab 100644 --- a/packages/bcrypt/src/hash_task.rs +++ b/packages/bcrypt/src/hash_task.rs @@ -1,25 +1,28 @@ -use napi::{ - Env, Error, Result, Status, Task, - bindgen_prelude::{Either, Uint8Array}, -}; +use bcrypt::Version; +use napi::{Env, Error, Result, Status, Task}; use napi_derive::napi; pub struct HashTask { - buf: Either, - cost: u32, - salt: [u8; 16], + pub(crate) password: Vec, + pub(crate) cost: u32, + pub(crate) salt: [u8; 16], + pub(crate) version: Version, } impl HashTask { - #[inline] - pub fn new(buf: Either, cost: u32, salt: [u8; 16]) -> HashTask { - HashTask { buf, cost, salt } + pub fn validate_password(password: &[u8], reject_long_passwords: bool) -> Result<()> { + if reject_long_passwords && password.len() > 72 { + return Err(Error::new( + Status::InvalidArg, + "password must not exceed 72 bytes", + )); + } + Ok(()) } - #[inline] - pub fn hash(buf: &[u8], salt: [u8; 16], cost: u32) -> Result { - bcrypt::hash_with_salt(buf, cost, salt) - .map(|hash_part| hash_part.to_string()) + pub fn hash(password: &[u8], cost: u32, salt: [u8; 16], version: Version) -> Result { + bcrypt::hash_with_salt(password, cost, salt) + .map(|parts| parts.format_for_version(version)) .map_err(|err| Error::new(Status::GenericFailure, format!("{err}"))) } } @@ -30,7 +33,7 @@ impl Task for HashTask { type JsValue = String; fn compute(&mut self) -> Result { - Self::hash(self.buf.as_ref(), self.salt, self.cost) + Self::hash(&self.password, self.cost, self.salt, self.version.clone()) } fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { diff --git a/packages/bcrypt/src/lib.rs b/packages/bcrypt/src/lib.rs index ed8b91b1..71b524d3 100644 --- a/packages/bcrypt/src/lib.rs +++ b/packages/bcrypt/src/lib.rs @@ -4,112 +4,96 @@ /// Explicit extern crate to use allocator. extern crate global_alloc; -use std::cmp; - -use bcrypt::Version; use napi::bindgen_prelude::*; use napi_derive::*; use crate::hash_task::HashTask; +use crate::options::{hash_options, validate_cost, version_from_str}; use crate::salt_task::{format_salt, gen_salt}; use crate::verify_task::VerifyTask; mod hash_task; +mod options; mod salt_task; mod verify_task; #[napi] pub const DEFAULT_COST: u32 = 12; -#[napi(ts_args_type = "round: number, version?: '2a' | '2x' | '2y' | '2b'")] -pub fn gen_salt_sync(round: u32, version: Option) -> Result { - let salt = gen_salt(); - Ok(format_salt(round, &version_from_str(version)?, &salt)) +/// Internal binding contract, checked by the public JavaScript wrapper. +#[napi] +pub const BCRYPT_API_VERSION: u32 = 2; + +#[napi] +pub fn gen_salt_sync(round: f64, version: Option) -> Result { + let round = validate_cost(round)?; + Ok(format_salt( + round, + &version_from_str(version.as_deref())?, + &gen_salt(), + )) } -#[napi( - js_name = "genSalt", - ts_args_type = "round: number, version?: '2a' | '2x' | '2y' | '2b', signal?: AbortSignal" -)] +#[napi(js_name = "genSalt")] pub fn gen_salt_js( - round: u32, + round: f64, version: Option, signal: Option, ) -> Result> { let task = salt_task::SaltTask { - round, - version: version_from_str(version)?, + round: validate_cost(round)?, + version: version_from_str(version.as_deref())?, }; Ok(AsyncTask::with_optional_signal(task, signal)) } #[napi] -#[inline] pub fn hash_sync( input: Either, - cost: Option, + cost: Option, salt: Option>, + version: Option, + reject_long_passwords: bool, ) -> Result { - let salt = if let Some(salt) = salt { - let mut s = [0u8; 16]; - let buf = salt.as_ref(); - // make sure salt buffer length should be 16 - let copy_length = cmp::min(buf.len(), s.len()); - s[..copy_length].copy_from_slice(&buf[..copy_length]); - s - } else { - rand::random() - }; - HashTask::hash(input.as_ref(), salt, cost.unwrap_or(DEFAULT_COST)) + let options = hash_options(cost, salt, version)?; + HashTask::validate_password(input.as_ref(), reject_long_passwords)?; + HashTask::hash(input.as_ref(), options.cost, options.salt, options.version) } #[napi] pub fn hash( - input: Either, - cost: Option, + input: Either, + cost: Option, salt: Option>, + version: Option, + reject_long_passwords: bool, signal: Option, ) -> Result> { - let salt = if let Some(salt) = salt { - let mut s = [0u8; 16]; - let buf = salt.as_ref(); - // make sure salt buffer length should be 16 - let copy_length = cmp::min(buf.len(), s.len()); - s[..copy_length].copy_from_slice(&buf[..copy_length]); - s - } else { - gen_salt() + let options = hash_options(cost, salt, version)?; + HashTask::validate_password(input.as_ref(), reject_long_passwords)?; + let task = HashTask { + password: input.as_ref().to_vec(), + cost: options.cost, + salt: options.salt, + version: options.version, }; - let task = HashTask::new(input, cost.unwrap_or(DEFAULT_COST), salt); Ok(AsyncTask::with_optional_signal(task, signal)) } #[napi] -#[inline] -pub fn verify_sync(input: Either, hash: Either) -> Result { - VerifyTask::verify(input, hash) +pub fn verify_sync(input: Either, hash: Either) -> bool { + VerifyTask::verify(input.as_ref(), hash.as_ref()) } #[napi] pub fn verify( - password: Either, - hash: Either, + password: Either, + hash: Either, signal: Option, ) -> Result> { - let task = VerifyTask::new(password, hash); + let task = VerifyTask { + password: password.as_ref().to_vec(), + hash: hash.as_ref().to_vec(), + }; Ok(AsyncTask::with_optional_signal(task, signal)) } - -#[inline] -fn version_from_str(version: Option) -> Result { - match version.as_deref() { - Some("2a") => Ok(Version::TwoA), - Some("2b") | None => Ok(Version::TwoB), - Some("2x") => Ok(Version::TwoX), - Some("2y") => Ok(Version::TwoY), - Some(version) => Err(Error::new( - Status::InvalidArg, - format!("{version} is not a valid version"), - )), - } -} diff --git a/packages/bcrypt/src/options.rs b/packages/bcrypt/src/options.rs new file mode 100644 index 00000000..0f2cf764 --- /dev/null +++ b/packages/bcrypt/src/options.rs @@ -0,0 +1,94 @@ +use base64::engine::Engine; +use bcrypt::Version; +use napi::bindgen_prelude::*; + +use crate::DEFAULT_COST; +use crate::salt_task::{gen_salt, salt_engine}; + +pub(crate) struct HashOptions { + pub cost: u32, + pub salt: [u8; 16], + pub version: Version, +} + +pub(crate) fn validate_cost(cost: f64) -> Result { + if !cost.is_finite() || cost.fract() != 0.0 || !(4.0..=31.0).contains(&cost) { + return Err(Error::new( + Status::InvalidArg, + "cost must be an integer between 4 and 31", + )); + } + Ok(cost as u32) +} + +pub(crate) fn version_from_str(version: Option<&str>) -> Result { + match version { + Some("2a") => Ok(Version::TwoA), + Some("2b") | None => Ok(Version::TwoB), + Some("2y") => Ok(Version::TwoY), + _ => Err(Error::new( + Status::InvalidArg, + "version must be 2a, 2b, or 2y", + )), + } +} + +// Creation is strict. Never use this parser to gate verification of stored hashes. +pub(crate) fn hash_options( + cost: Option, + salt: Option>, + version: Option, +) -> Result { + if let Some(Either::A(encoded)) = salt.as_ref() { + if cost.is_some() || version.is_some() { + return Err(Error::new( + Status::InvalidArg, + "an encoded salt already supplies cost and version", + )); + } + let invalid = || { + Error::new( + Status::InvalidArg, + "salt must be a canonical 29-character bcrypt salt", + ) + }; + if encoded.len() != 29 || !encoded.is_ascii() { + return Err(invalid()); + } + let bytes = encoded.as_bytes(); + if bytes[0] != b'$' + || bytes[3] != b'$' + || bytes[6] != b'$' + || !bytes[4].is_ascii_digit() + || !bytes[5].is_ascii_digit() + { + return Err(invalid()); + } + let version = version_from_str(Some(&encoded[1..3]))?; + let cost = validate_cost(((bytes[4] - b'0') * 10 + bytes[5] - b'0') as f64)?; + let decoded = salt_engine().decode(&encoded[7..]).map_err(|_| invalid())?; + let salt: [u8; 16] = decoded.try_into().map_err(|_| invalid())?; + if salt_engine().encode(salt) != encoded[7..] { + return Err(invalid()); + } + return Ok(HashOptions { + cost, + salt, + version, + }); + } + let cost = validate_cost(cost.unwrap_or(DEFAULT_COST as f64))?; + let version = version_from_str(version.as_deref())?; + let salt = match salt { + Some(Either::B(bytes)) => bytes + .try_into() + .map_err(|_| Error::new(Status::InvalidArg, "raw salt must contain exactly 16 bytes"))?, + None => gen_salt(), + Some(Either::A(_)) => unreachable!(), + }; + Ok(HashOptions { + cost, + salt, + version, + }) +} diff --git a/packages/bcrypt/src/salt_task.rs b/packages/bcrypt/src/salt_task.rs index e11cf4a3..7b6a6641 100644 --- a/packages/bcrypt/src/salt_task.rs +++ b/packages/bcrypt/src/salt_task.rs @@ -2,7 +2,7 @@ use base64::engine::Engine; use napi::{Env, Result, Task}; use napi_derive::napi; -use crate::Version; +use bcrypt::Version; #[inline] pub(crate) fn gen_salt() -> [u8; 16] { @@ -11,15 +11,17 @@ pub(crate) fn gen_salt() -> [u8; 16] { #[inline] pub(crate) fn format_salt(rounds: u32, version: &Version, salt: &[u8; 16]) -> String { - let mut base64_string = String::new(); - let engine = base64::engine::general_purpose::GeneralPurpose::new( - &base64::alphabet::BCRYPT, - base64::engine::general_purpose::PAD, - ); - engine.encode_string(salt, &mut base64_string); + let base64_string = salt_engine().encode(salt); format!("${version}${rounds:0>2}${base64_string}") } +pub(crate) fn salt_engine() -> base64::engine::general_purpose::GeneralPurpose { + base64::engine::general_purpose::GeneralPurpose::new( + &base64::alphabet::BCRYPT, + base64::engine::general_purpose::NO_PAD, + ) +} + pub struct SaltTask { pub(crate) round: u32, pub(crate) version: Version, diff --git a/packages/bcrypt/src/verify_task.rs b/packages/bcrypt/src/verify_task.rs index 794d29ec..152aa893 100644 --- a/packages/bcrypt/src/verify_task.rs +++ b/packages/bcrypt/src/verify_task.rs @@ -4,28 +4,17 @@ use napi::bindgen_prelude::*; use napi_derive::napi; pub struct VerifyTask { - password: Either, - hash: Either, + pub(crate) password: Vec, + pub(crate) hash: Vec, } impl VerifyTask { - pub fn new(password: Either, hash: Either) -> VerifyTask { - Self { password, hash } - } - - #[inline] - pub fn verify(password: P, hash: H) -> Result - where - P: AsRef<[u8]>, - H: AsRef<[u8]>, - { - Ok( - bcrypt::verify( - password, - str::from_utf8(hash.as_ref()).map_err(|_| Error::from_status(Status::StringExpected))?, - ) - .unwrap_or(false), - ) + pub fn verify(password: &[u8], hash: &[u8]) -> bool { + let Ok(encoded) = str::from_utf8(hash) else { + return false; + }; + // Retain the backend's existing parser, prefix handling and 72-byte semantics. + bcrypt::verify(password, encoded).unwrap_or(false) } } @@ -35,10 +24,10 @@ impl Task for VerifyTask { type JsValue = bool; fn compute(&mut self) -> Result { - VerifyTask::verify(self.password.as_ref(), self.hash.as_ref()) + Ok(Self::verify(&self.password, &self.hash)) } - fn resolve(&mut self, _: Env, output: Self::Output) -> Result { + fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { Ok(output) } } diff --git a/packages/bcrypt/wasi-worker-browser.mjs b/packages/bcrypt/wasi-worker-browser.mjs index 8b1b1722..c4bd3783 100644 --- a/packages/bcrypt/wasi-worker-browser.mjs +++ b/packages/bcrypt/wasi-worker-browser.mjs @@ -1,4 +1,10 @@ -import { instantiateNapiModuleSync, MessageHandler, WASI } from '@napi-rs/wasm-runtime' +import { + instantiateNapiModuleSync, + MessageHandler, + WASI, + emnapiAsyncWorkPlugin, + emnapiTSFNPlugin, +} from '@napi-rs/wasm-runtime' const handler = new MessageHandler({ onLoad({ wasmModule, wasmMemory }) { @@ -10,11 +16,17 @@ const handler = new MessageHandler({ printErr: function() { // eslint-disable-next-line no-console console.error.apply(console, arguments) + }, }) return instantiateNapiModuleSync(wasmModule, { childThread: true, wasi, + // The wasm links a "basic" emnapi archive (no C async-work / + // threadsafe-function implementations), so every thread that + // instantiates it must provide the JavaScript implementations + // through the emnapi plugins. + plugins: [emnapiAsyncWorkPlugin, emnapiTSFNPlugin], overwriteImports(importObject) { importObject.env = { ...importObject.env, @@ -25,6 +37,7 @@ const handler = new MessageHandler({ }, }) }, + }) globalThis.onmessage = function (e) { diff --git a/packages/bcrypt/wasi-worker.mjs b/packages/bcrypt/wasi-worker.mjs index 84b448fc..cc3a4b81 100644 --- a/packages/bcrypt/wasi-worker.mjs +++ b/packages/bcrypt/wasi-worker.mjs @@ -6,7 +6,13 @@ import { parentPort, Worker } from "node:worker_threads"; const require = createRequire(import.meta.url); -const { instantiateNapiModuleSync, MessageHandler, getDefaultContext } = require("@napi-rs/wasm-runtime"); +const { + instantiateNapiModuleSync, + MessageHandler, + getDefaultContext, + emnapiAsyncWorkPlugin, + emnapiTSFNPlugin, +} = require("@napi-rs/wasm-runtime"); if (parentPort) { parentPort.on("message", (data) => { @@ -46,6 +52,11 @@ const handler = new MessageHandler({ childThread: true, wasi, context: emnapiContext, + // The wasm links a "basic" emnapi archive (no C async-work / + // threadsafe-function implementations), so every thread that + // instantiates it must provide the JavaScript implementations + // through the emnapi plugins. + plugins: [emnapiAsyncWorkPlugin, emnapiTSFNPlugin], overwriteImports(importObject) { importObject.env = { ...importObject.env, diff --git a/yarn.lock b/yarn.lock index eccac879..6f48cedc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -132,23 +132,23 @@ __metadata: languageName: node linkType: hard -"@emnapi/core@npm:^1.1.0": - version: 1.11.3 - resolution: "@emnapi/core@npm:1.11.3" +"@emnapi/core@npm:2.0.0-alpha.5, @emnapi/core@npm:^2.0.0-alpha.4": + version: 2.0.0-alpha.5 + resolution: "@emnapi/core@npm:2.0.0-alpha.5" dependencies: - "@emnapi/wasi-threads": "npm:1.2.3" + "@emnapi/wasi-threads": "npm:2.1.0" tslib: "npm:^2.4.0" - checksum: 10c0/4ca08d349a82d5d2887ccc9e12df630877b0412ddcd59b9faee61e3c3947ccead27a18257a18bfe17abdf2b0709857808ad75d423ac49edd50c32fb140a7ed6e + checksum: 10c0/c89ae29b699d1b75f555ac18342dfa7354be4ee6ed60c071b1723a7bf2f2f20ed3418e6157ca12e457309e44fc1776e6699f66c65319e6ace56c1c859b42a7b4 languageName: node linkType: hard -"@emnapi/core@npm:^2.0.0-alpha.4": - version: 2.0.0-alpha.5 - resolution: "@emnapi/core@npm:2.0.0-alpha.5" +"@emnapi/core@npm:^1.1.0": + version: 1.11.3 + resolution: "@emnapi/core@npm:1.11.3" dependencies: - "@emnapi/wasi-threads": "npm:2.1.0" + "@emnapi/wasi-threads": "npm:1.2.3" tslib: "npm:^2.4.0" - checksum: 10c0/c89ae29b699d1b75f555ac18342dfa7354be4ee6ed60c071b1723a7bf2f2f20ed3418e6157ca12e457309e44fc1776e6699f66c65319e6ace56c1c859b42a7b4 + checksum: 10c0/4ca08d349a82d5d2887ccc9e12df630877b0412ddcd59b9faee61e3c3947ccead27a18257a18bfe17abdf2b0709857808ad75d423ac49edd50c32fb140a7ed6e languageName: node linkType: hard @@ -188,21 +188,21 @@ __metadata: languageName: node linkType: hard -"@emnapi/runtime@npm:^1.1.0": - version: 1.11.3 - resolution: "@emnapi/runtime@npm:1.11.3" +"@emnapi/runtime@npm:2.0.0-alpha.5, @emnapi/runtime@npm:^2.0.0-alpha.4": + version: 2.0.0-alpha.5 + resolution: "@emnapi/runtime@npm:2.0.0-alpha.5" dependencies: tslib: "npm:^2.4.0" - checksum: 10c0/a00f1020fefb9d4145c367f93a9fddb383a00da8ffd7871e20b19659890379b83aeecb9f84d7d0eda5456343f4a09eb05b0acb5153b0d3d889539dedb1ed87c3 + checksum: 10c0/0effd9cc76cb7d65d38d4fc30b0d4599bd6c96abbfe334d10f7739e69e95f3e465d4243019931a0d7ae682ba42420b0170b87b341ea410e700343f5002704ec9 languageName: node linkType: hard -"@emnapi/runtime@npm:^2.0.0-alpha.4": - version: 2.0.0-alpha.5 - resolution: "@emnapi/runtime@npm:2.0.0-alpha.5" +"@emnapi/runtime@npm:^1.1.0": + version: 1.11.3 + resolution: "@emnapi/runtime@npm:1.11.3" dependencies: tslib: "npm:^2.4.0" - checksum: 10c0/0effd9cc76cb7d65d38d4fc30b0d4599bd6c96abbfe334d10f7739e69e95f3e465d4243019931a0d7ae682ba42420b0170b87b341ea410e700343f5002704ec9 + checksum: 10c0/a00f1020fefb9d4145c367f93a9fddb383a00da8ffd7871e20b19659890379b83aeecb9f84d7d0eda5456343f4a09eb05b0acb5153b0d3d889539dedb1ed87c3 languageName: node linkType: hard @@ -1304,7 +1304,7 @@ __metadata: languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^1.1.2, @napi-rs/wasm-runtime@npm:^1.1.6, @napi-rs/wasm-runtime@npm:^1.2.3": +"@napi-rs/wasm-runtime@npm:^1.1.2, @napi-rs/wasm-runtime@npm:^1.1.6, @napi-rs/wasm-runtime@npm:^1.2.3, @napi-rs/wasm-runtime@npm:~1.2.3": version: 1.2.3 resolution: "@napi-rs/wasm-runtime@npm:1.2.3" dependencies: @@ -1481,6 +1481,108 @@ __metadata: languageName: unknown linkType: soft +"@node-rs/bcrypt-android-arm-eabi@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-android-arm-eabi@npm:1.10.9" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@node-rs/bcrypt-android-arm64@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-android-arm64@npm:1.10.9" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@node-rs/bcrypt-darwin-arm64@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-darwin-arm64@npm:1.10.9" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@node-rs/bcrypt-darwin-x64@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-darwin-x64@npm:1.10.9" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@node-rs/bcrypt-freebsd-x64@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-freebsd-x64@npm:1.10.9" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@node-rs/bcrypt-linux-arm-gnueabihf@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-linux-arm-gnueabihf@npm:1.10.9" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@node-rs/bcrypt-linux-arm64-gnu@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-linux-arm64-gnu@npm:1.10.9" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@node-rs/bcrypt-linux-arm64-musl@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-linux-arm64-musl@npm:1.10.9" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@node-rs/bcrypt-linux-x64-gnu@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-linux-x64-gnu@npm:1.10.9" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@node-rs/bcrypt-linux-x64-musl@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-linux-x64-musl@npm:1.10.9" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@node-rs/bcrypt-wasm32-wasi@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-wasm32-wasi@npm:1.10.9" + dependencies: + "@emnapi/core": "npm:2.0.0-alpha.5" + "@emnapi/runtime": "npm:2.0.0-alpha.5" + "@napi-rs/wasm-runtime": "npm:~1.2.3" + checksum: 10c0/9386f894721624af0fbdc1b9ef489396e5bced81a0faf61019d535cf5ccc7858fb8716bf610ec6a7daaf357429e8d7e5dc211efe700aead6e3a9c10478e6e825 + languageName: node + linkType: hard + +"@node-rs/bcrypt-win32-arm64-msvc@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-win32-arm64-msvc@npm:1.10.9" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@node-rs/bcrypt-win32-ia32-msvc@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-win32-ia32-msvc@npm:1.10.9" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@node-rs/bcrypt-win32-x64-msvc@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-win32-x64-msvc@npm:1.10.9" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@node-rs/bcrypt@workspace:packages/bcrypt": version: 0.0.0-use.local resolution: "@node-rs/bcrypt@workspace:packages/bcrypt" @@ -1488,9 +1590,12 @@ __metadata: "@cwasm/openbsd-bcrypt": "npm:^0.1.0" "@cwasm/openwall-bcrypt": "npm:^0.1.0" "@napi-rs/cli": "npm:^3.8.6" + "@node-rs/bcrypt-wasm32-wasi": "npm:1.10.9" "@types/bcrypt": "npm:^6.0.0" "@types/bcryptjs": "npm:^3.0.0" + abort-controller: "npm:3.0.0" bcrypt: "npm:^6.0.0" + bcrypt-previous: "npm:@node-rs/bcrypt@1.10.9" bcryptjs: "npm:^3.0.3" cross-env: "npm:^10.1.0" tinybench: "npm:^6.1.3" @@ -3281,6 +3386,15 @@ __metadata: languageName: node linkType: hard +"abort-controller@npm:3.0.0": + version: 3.0.0 + resolution: "abort-controller@npm:3.0.0" + dependencies: + event-target-shim: "npm:^5.0.0" + checksum: 10c0/90ccc50f010250152509a344eb2e71977fbf8db0ab8f1061197e3275ddf6c61a41a6edfd7b9409c664513131dd96e962065415325ef23efa5db931b382d24ca5 + languageName: node + linkType: hard + "acorn-import-attributes@npm:^1.9.5": version: 1.9.5 resolution: "acorn-import-attributes@npm:1.9.5" @@ -3597,6 +3711,54 @@ __metadata: languageName: node linkType: hard +"bcrypt-previous@npm:@node-rs/bcrypt@1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt@npm:1.10.9" + dependencies: + "@node-rs/bcrypt-android-arm-eabi": "npm:1.10.9" + "@node-rs/bcrypt-android-arm64": "npm:1.10.9" + "@node-rs/bcrypt-darwin-arm64": "npm:1.10.9" + "@node-rs/bcrypt-darwin-x64": "npm:1.10.9" + "@node-rs/bcrypt-freebsd-x64": "npm:1.10.9" + "@node-rs/bcrypt-linux-arm-gnueabihf": "npm:1.10.9" + "@node-rs/bcrypt-linux-arm64-gnu": "npm:1.10.9" + "@node-rs/bcrypt-linux-arm64-musl": "npm:1.10.9" + "@node-rs/bcrypt-linux-x64-gnu": "npm:1.10.9" + "@node-rs/bcrypt-linux-x64-musl": "npm:1.10.9" + "@node-rs/bcrypt-win32-arm64-msvc": "npm:1.10.9" + "@node-rs/bcrypt-win32-ia32-msvc": "npm:1.10.9" + "@node-rs/bcrypt-win32-x64-msvc": "npm:1.10.9" + dependenciesMeta: + "@node-rs/bcrypt-android-arm-eabi": + optional: true + "@node-rs/bcrypt-android-arm64": + optional: true + "@node-rs/bcrypt-darwin-arm64": + optional: true + "@node-rs/bcrypt-darwin-x64": + optional: true + "@node-rs/bcrypt-freebsd-x64": + optional: true + "@node-rs/bcrypt-linux-arm-gnueabihf": + optional: true + "@node-rs/bcrypt-linux-arm64-gnu": + optional: true + "@node-rs/bcrypt-linux-arm64-musl": + optional: true + "@node-rs/bcrypt-linux-x64-gnu": + optional: true + "@node-rs/bcrypt-linux-x64-musl": + optional: true + "@node-rs/bcrypt-win32-arm64-msvc": + optional: true + "@node-rs/bcrypt-win32-ia32-msvc": + optional: true + "@node-rs/bcrypt-win32-x64-msvc": + optional: true + checksum: 10c0/e787d4cee0c8d4ee499fdb080d2439ed5851c40757384d370035b3c8cfbd91fe58d6e79f4828613544f00fdba500a28cbb7b33bbb61449ba2eb44d1876513ef2 + languageName: node + linkType: hard + "bcrypt@npm:^6.0.0": version: 6.0.0 resolution: "bcrypt@npm:6.0.0" @@ -4639,6 +4801,13 @@ __metadata: languageName: node linkType: hard +"event-target-shim@npm:^5.0.0": + version: 5.0.1 + resolution: "event-target-shim@npm:5.0.1" + checksum: 10c0/0255d9f936215fd206156fd4caa9e8d35e62075d720dc7d847e89b417e5e62cf1ce6c9b4e0a1633a9256de0efefaf9f8d26924b1f3c8620cffb9db78e7d3076b + languageName: node + linkType: hard + "eventemitter3@npm:^4.0.4": version: 4.0.7 resolution: "eventemitter3@npm:4.0.7"