diff --git a/Cargo.toml b/Cargo.toml index a8f27541..1cd8c10c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ resolver = "2" [workspace.dependencies] -argon2 = { version = "=0.6.0-rc.8", default-features = false, features = ["alloc", "getrandom", "zeroize"] } +argon2-rust = { version = "1", default-features = false } base64 = { version = "0.23" } bcrypt = "0.19" blowfish = { version = "0.10", features = ["bcrypt"] } diff --git a/README.md b/README.md index 0e01117c..93c6ab2a 100644 --- a/README.md +++ b/README.md @@ -18,5 +18,5 @@ Playground: [https://node-rs.dev](https://node-rs.dev) | [`@node-rs/jieba`](./packages/jieba) | ![](https://img.shields.io/npm/v/@node-rs/jieba.svg) | ![](https://img.shields.io/npm/dm/@node-rs/jieba.svg?sanitize=true) | [`jieba-rs`](https://github.com/messense/jieba-rs) binding | | [`@node-rs/bcrypt`](./packages/bcrypt) | ![](https://img.shields.io/npm/v/@node-rs/bcrypt.svg) | ![](https://img.shields.io/npm/dm/@node-rs/bcrypt.svg?sanitize=true) | Fastest bcrypt implementation | | [`@node-rs/xxhash`](./packages/xxhash) | ![](https://img.shields.io/npm/v/@node-rs/xxhash.svg) | ![](https://img.shields.io/npm/dm/@node-rs/xxhash.svg?sanitize=true) | [`xxhash-rust`](https://github.com/DoumanAsh/xxhash-rust) Node.js binding | -| [`@node-rs/argon2`](./packages/argon2) | ![](https://img.shields.io/npm/v/@node-rs/argon2.svg) | ![](https://img.shields.io/npm/dm/@node-rs/argon2.svg?sanitize=true) | [argon2](https://crates.io/crates/argon2) binding for Node.js. | +| [`@node-rs/argon2`](./packages/argon2) | ![](https://img.shields.io/npm/v/@node-rs/argon2.svg) | ![](https://img.shields.io/npm/dm/@node-rs/argon2.svg?sanitize=true) | [argon2-rust](https://crates.io/crates/argon2-rust) binding for Node.js. | | [`@node-rs/jsonwebtoken`](./packages/jsonwebtoken) | ![](https://img.shields.io/npm/v/@node-rs/jsonwebtoken.svg) | ![](https://img.shields.io/npm/dm/@node-rs/jsonwebtoken.svg?sanitize=true) | [jsonwebtoken](https://github.com/Keats/jsonwebtoken) binding for Node.js. | diff --git a/packages/argon2/Cargo.toml b/packages/argon2/Cargo.toml index 678e92b0..246d822b 100644 --- a/packages/argon2/Cargo.toml +++ b/packages/argon2/Cargo.toml @@ -7,13 +7,15 @@ version = "0.0.0" crate-type = ["cdylib"] [features] -parallel = ["argon2/parallel"] +parallel = ["argon2-rust/parallel"] [dependencies] -argon2 = { workspace = true } +argon2-rust = { workspace = true, features = ["std", "zeroize-memory"] } +base64 = { workspace = true } global_alloc = { workspace = true } napi = { workspace = true, features = ["napi3"] } napi-derive = { workspace = true, default-features = false, features = ["type-def"] } +rand = { workspace = true } [build-dependencies] napi-build = { workspace = true } diff --git a/packages/argon2/README.md b/packages/argon2/README.md index 30d04504..f815aa01 100644 --- a/packages/argon2/README.md +++ b/packages/argon2/README.md @@ -3,7 +3,7 @@ ![](https://github.com/napi-rs/node-rs/workflows/CI/badge.svg) ![](https://img.shields.io/npm/dm/@node-rs/argon2.svg?sanitize=true) -[RustCrypto: Argon2](https://crates.io/crates/argon2) binding for Node.js. +[argon2-rust](https://crates.io/crates/argon2-rust) binding for Node.js. Argon2 is a [key derivation function](https://en.wikipedia.org/wiki/Key_derivation_function) that was selected as the winner of the [Password Hashing Competition(PHC)](https://password-hashing.net) in July 2015. @@ -13,7 +13,7 @@ It has a simple design aimed at the highest memory filling rate and effective us ## Features -- Faster performance. +- Faster than node-argon2 and `node:crypto.argon2` at the same m/t/p (see Benchmarks). - No node-gyp and postinstall. - Cross-platform support, including [Apple M1](https://www.apple.com/newsroom/2020/11/apple-unleashes-m1/). - Smaller file size after npm installation(476K vs [node-argon2](https://github.com/ranisalt/node-argon2) 3.7M). @@ -28,7 +28,29 @@ It has a simple design aimed at the highest memory filling rate and effective us # Benchmarks -See [benchmark/](benchmark/argon2.ts). +Comparing each library's **defaults** is not 1:1. `@node-rs/argon2` defaults to `m=19456,t=2,p=1`; `argon2` (node-argon2) defaults to `m=65536,t=3,p=4`. See [#841](https://github.com/napi-rs/node-rs/issues/841). + +This bench pins the same password, salt, Argon2id v=19, m, t, p, and 32-byte tag. Only the **raw** KDF is timed. The tag is asserted equal on every run. Native impls are interleaved with each other; JS/wasm are a separate group so a 7 ms hash is not timed after a 400 ms JS loop. + +Apple M5 Max / arm64 / Node 24 / [argon2-rust 1.0.0](https://crates.io/crates/argon2-rust). Median ms. See [benchmark/](benchmark/argon2.ts). + +**Native async raw** — same calling shape as node-argon2 (no sync API there): + +| params | @node-rs/argon2 | node-argon2 | node:crypto | +| --------------------- | --------------: | ----------: | ----------: | +| m=19456 KiB, t=2, p=1 | **7.58** | 15.41 | 13.05 | +| m=65536 KiB, t=3, p=1 | **49.35** | 84.57 | 71.71 | +| m=65536 KiB, t=3, p=4 | **13.62** | 22.54 | 20.83 | + +**JS / wasm raw** — same params, same tags: + +| params | hash-wasm | @noble/hashes | +| --------------------- | --------: | ------------: | +| m=19456 KiB, t=2, p=1 | 19.79 | 86.14 | +| m=65536 KiB, t=3, p=1 | 104.09 | 433.07 | +| m=65536 KiB, t=3, p=4 | 106.06 | 435.39 | + +hash-wasm and `@noble/hashes` do not run lanes in parallel, so their p=4 row is the p=1 work. ## API diff --git a/packages/argon2/__test__/argon2.spec.ts b/packages/argon2/__test__/argon2.spec.ts index 5b1f33a1..0853596f 100644 --- a/packages/argon2/__test__/argon2.spec.ts +++ b/packages/argon2/__test__/argon2.spec.ts @@ -1,8 +1,14 @@ +import * as nodeCrypto from 'node:crypto' import { randomBytes } from 'node:crypto' import test from 'ava' -import { Algorithm, hash, hashRaw, verify, Version } from '../index.js' +import { Algorithm, hash, hashRaw, hashRawSync, hashSync, verify, Version } from '../index.js' + +const argon2Sync = typeof nodeCrypto.argon2Sync === 'function' ? nodeCrypto.argon2Sync.bind(nodeCrypto) : undefined +const interop = argon2Sync ? test : test.skip + +const b64 = (value: Uint8Array) => Buffer.from(value).toString('base64').replace(/=+$/, '') const passwordString = 'some_string123' const passwordBuffer = Buffer.from(passwordString) @@ -122,7 +128,7 @@ test('should return memoryCost error', async (t) => { }), ) - t.is(error?.message, 'memory cost is too small') + t.is(error?.message, 'Memory cost is too small') }) test('should return timeCost error', async (t) => { @@ -132,7 +138,7 @@ test('should return timeCost error', async (t) => { }), ) - t.is(error?.message, 'time cost is too small') + t.is(error?.message, 'Time cost is too small') }) test('should return parallelism error', async (t) => { @@ -143,5 +149,62 @@ test('should return parallelism error', async (t) => { }), ) - t.is(error?.message, 'not enough threads') + t.is(error?.message, 'Too few lanes') +}) + +interop('should match node:crypto argon2 on the same params and salt', async (t) => { + const password = '1:1-compare-password' + const salt = Buffer.from('somesaltforsure!') + const options = { + algorithm: Algorithm.Argon2id, + memoryCost: 4096, + timeCost: 1, + parallelism: 1, + outputLen: 32, + salt, + } + + const encoded = await hash(password, options) + const raw = await hashRaw(password, options) + const builtin = Buffer.from( + argon2Sync!('argon2id', { + message: Buffer.from(password), + nonce: salt, + parallelism: options.parallelism, + tagLength: options.outputLen, + memory: options.memoryCost, + passes: options.timeCost, + }), + ) + + t.deepEqual(raw, builtin) + t.true(await verify(encoded, password)) + t.is(hashSync(password, options), encoded) + t.deepEqual(hashRawSync(password, options), raw) +}) + +interop('should verify PHC strings that carry associatedData', async (t) => { + const password = '1:1-compare-password' + const salt = Buffer.from('somesaltforsure!') + const associatedData = Buffer.from('phc-associated-data') + const secret = randomBytes(16) + const shared = { + message: Buffer.from(password), + nonce: salt, + parallelism: 1, + tagLength: 32, + memory: 4096, + passes: 1, + associatedData, + } + + const withAd = Buffer.from(argon2Sync!('argon2id', shared)) + const phc = `$argon2id$v=19$m=4096,t=1,p=1,data=${b64(associatedData)}$${b64(salt)}$${b64(withAd)}` + t.true(await verify(phc, password)) + t.false(await verify(phc, 'wrong-password')) + + const withAdAndSecret = Buffer.from(argon2Sync!('argon2id', { ...shared, secret })) + const phcKeyed = `$argon2id$v=19$m=4096,t=1,p=1,data=${b64(associatedData)}$${b64(salt)}$${b64(withAdAndSecret)}` + t.true(await verify(phcKeyed, password, { secret })) + t.false(await verify(phcKeyed, password)) }) diff --git a/packages/argon2/benchmark/argon2.ts b/packages/argon2/benchmark/argon2.ts index cbad4e85..23721578 100644 --- a/packages/argon2/benchmark/argon2.ts +++ b/packages/argon2/benchmark/argon2.ts @@ -1,52 +1,193 @@ -import { cpus } from 'node:os' +import { argon2, argon2Sync } from 'node:crypto' +import { performance } from 'node:perf_hooks' +import { promisify } from 'node:util' +import { argon2id as nobleArgon2id } from '@noble/hashes/argon2.js' import nodeArgon2 from 'argon2' -import { Bench } from 'tinybench' +import { argon2id as wasmArgon2id } from 'hash-wasm' -import { hash, verify, Algorithm } from '../index.js' +import { Algorithm, hashRaw, hashRawSync } from '../index.js' + +const argon2Async = promisify(argon2) const PASSWORD = 'test-password-for-benchmark' -const CORES = cpus().length +const PASSWORD_BUF = Buffer.from(PASSWORD) +const SALT = Buffer.from('somesaltforsure!') -// Use aligned parameters for fair comparison -// These are OWASP-recommended settings for Argon2id -const MEMORY_COST = 65536 // 64 MiB -const TIME_COST = 3 // 3 iterations -const PARALLELISM = CORES +type SharedParams = { + memoryCost: number + timeCost: number + parallelism: number + outputLen: number +} -const HASHED = await hash(PASSWORD, { +// Same numbers on every implementation. `p` is part of the tag, so it is pinned. +const CONFIGS: Array<{ name: string; params: SharedParams; rounds: number }> = [ + { + name: 'OWASP m=19456 KiB t=2 p=1', + params: { memoryCost: 19456, timeCost: 2, parallelism: 1, outputLen: 32 }, + rounds: 40, + }, + { + name: 'RFC9106-low m=65536 KiB t=3 p=1', + params: { memoryCost: 65536, timeCost: 3, parallelism: 1, outputLen: 32 }, + rounds: 25, + }, + { + name: 'RFC9106-low m=65536 KiB t=3 p=4', + params: { memoryCost: 65536, timeCost: 3, parallelism: 4, outputLen: 32 }, + rounds: 25, + }, +] + +type Impl = { + name: string + run: () => Uint8Array | Promise +} + +const nodeRsOptions = (params: SharedParams) => ({ algorithm: Algorithm.Argon2id, - memoryCost: MEMORY_COST, - timeCost: TIME_COST, - parallelism: PARALLELISM, + memoryCost: params.memoryCost, + timeCost: params.timeCost, + parallelism: params.parallelism, + outputLen: params.outputLen, + salt: SALT, }) -const bench = new Bench() +const nodeCryptoParams = (params: SharedParams) => ({ + message: PASSWORD_BUF, + nonce: SALT, + parallelism: params.parallelism, + tagLength: params.outputLen, + memory: params.memoryCost, + passes: params.timeCost, +}) -bench - .add('@node-rs/argon2 hash', async () => { - await hash(PASSWORD, { - algorithm: Algorithm.Argon2id, - memoryCost: MEMORY_COST, - timeCost: TIME_COST, - parallelism: PARALLELISM, - }) - }) - .add('node-argon2 hash', async () => { - await nodeArgon2.hash(PASSWORD, { - type: nodeArgon2.argon2id, - memoryCost: MEMORY_COST, - timeCost: TIME_COST, - parallelism: PARALLELISM, - }) - }) - .add('@node-rs/argon2 verify', async () => { - console.assert(await verify(HASHED, PASSWORD)) - }) - .add('node-argon2 verify', async () => { - console.assert(await nodeArgon2.verify(HASHED, PASSWORD)) - }) +const hex = (bytes: Uint8Array) => Buffer.from(bytes).toString('hex') -await bench.run() +const median = (values: number[]) => { + const sorted = [...values].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid] +} -console.table(bench.table()) +const asBuffer = (value: Uint8Array) => Buffer.from(value) + +const assertTag = (name: string, got: Uint8Array, expected: Buffer) => { + const actual = asBuffer(got) + if (!actual.equals(expected)) { + throw new Error(`${name}: raw tag mismatch\n got ${hex(actual)}\n want ${hex(expected)}`) + } +} + +const timeMs = async (run: Impl['run']) => { + const start = performance.now() + const out = await run() + return { ms: performance.now() - start, out } +} + +const interleaved = async (impls: Impl[], expected: Buffer, rounds: number) => { + for (const impl of impls) { + assertTag(`${impl.name} warmup`, await impl.run(), expected) + } + + const samples = impls.map(() => [] as number[]) + for (let round = 0; round < rounds; round++) { + // Rotate so no impl is always measured immediately after the slowest one. + for (let offset = 0; offset < impls.length; offset++) { + const i = (round + offset) % impls.length + const { ms, out } = await timeMs(impls[i].run) + assertTag(`${impls[i].name} #${round}`, out, expected) + samples[i].push(ms) + } + } + + return impls.map((impl, i) => ({ + impl: impl.name, + 'median ms': Number(median(samples[i]).toFixed(2)), + 'min ms': Number(Math.min(...samples[i]).toFixed(2)), + rounds: samples[i].length, + })) +} + +const printTable = (title: string, rows: Array>) => { + console.log(`\n${title}`) + console.table(rows) +} + +for (const { name, params, rounds } of CONFIGS) { + const expected = hashRawSync(PASSWORD, nodeRsOptions(params)) + + const nativeSync: Impl[] = [ + { + name: '@node-rs/argon2 hashRawSync', + run: () => hashRawSync(PASSWORD, nodeRsOptions(params)), + }, + { + name: 'node:crypto argon2Sync', + run: () => argon2Sync('argon2id', nodeCryptoParams(params)), + }, + ] + + const nativeAsync: Impl[] = [ + { + name: '@node-rs/argon2 hashRaw', + run: () => hashRaw(PASSWORD, nodeRsOptions(params)), + }, + { + name: 'node-argon2 hash raw', + run: () => + nodeArgon2.hash(PASSWORD, { + type: nodeArgon2.argon2id, + memoryCost: params.memoryCost, + timeCost: params.timeCost, + parallelism: params.parallelism, + hashLength: params.outputLen, + salt: SALT, + version: 0x13, + raw: true, + }), + }, + { + name: 'node:crypto argon2', + run: async () => Buffer.from(await argon2Async('argon2id', nodeCryptoParams(params))), + }, + ] + + const jsWasm: Impl[] = [ + { + name: 'hash-wasm argon2id binary', + run: () => + wasmArgon2id({ + password: PASSWORD, + salt: SALT, + parallelism: params.parallelism, + iterations: params.timeCost, + memorySize: params.memoryCost, + hashLength: params.outputLen, + outputType: 'binary', + }), + }, + { + name: '@noble/hashes argon2id', + run: () => + nobleArgon2id(PASSWORD, SALT, { + t: params.timeCost, + m: params.memoryCost, + p: params.parallelism, + dkLen: params.outputLen, + maxmem: 2 ** 32 - 1, + }), + }, + ] + + for (const impl of [...nativeSync, ...nativeAsync, ...jsWasm]) { + assertTag(impl.name, await impl.run(), expected) + } + + console.log(`${name} tag=${hex(expected)} all impls equal interleaved x${rounds}`) + + printTable(`${name} — native sync raw`, await interleaved(nativeSync, expected, rounds)) + printTable(`${name} — native async raw`, await interleaved(nativeAsync, expected, rounds)) + printTable(`${name} — js/wasm raw`, await interleaved(jsWasm, expected, rounds)) +} diff --git a/packages/argon2/package.json b/packages/argon2/package.json index 3a0e33e4..b005279e 100644 --- a/packages/argon2/package.json +++ b/packages/argon2/package.json @@ -1,7 +1,7 @@ { "name": "@node-rs/argon2", "version": "2.0.2", - "description": "RustCrypto: Argon2 binding for Node.js", + "description": "argon2-rust binding for Node.js", "keywords": [ "argon2", "argon2-password", @@ -41,10 +41,12 @@ }, "devDependencies": { "@napi-rs/cli": "^3.8.6", + "@noble/hashes": "^2.3.0", "@oxc-node/cli": "^0.1.0", "@oxc-node/core": "^0.1.0", "argon2": "^0.45.1", "cross-env": "^10.1.0", + "hash-wasm": "^4.12.0", "tinybench": "^6.1.3" }, "napi": { diff --git a/packages/argon2/src/lib.rs b/packages/argon2/src/lib.rs index 1081cf60..7128a08e 100644 --- a/packages/argon2/src/lib.rs +++ b/packages/argon2/src/lib.rs @@ -3,14 +3,14 @@ /// Explicit extern crate to use allocator. extern crate global_alloc; +use argon2_rust::{ + Algorithm as Argon2Algorithm, Argon2, Error as Argon2Error, Params, Version as Argon2Version, + params::{Memory, TagLen}, +}; +use base64::Engine; use napi::bindgen_prelude::*; use napi_derive::napi; -use argon2::{ - Argon2, Params, - password_hash::{PasswordHasher, PasswordVerifier, phc::Salt}, -}; - #[napi] #[derive(Clone, Copy)] pub enum Algorithm { @@ -30,11 +30,11 @@ pub enum Algorithm { impl Algorithm { #[inline] - fn to_argon(self) -> argon2::Algorithm { + fn to_argon(self) -> Argon2Algorithm { match self { - Self::Argon2d => argon2::Algorithm::Argon2d, - Self::Argon2i => argon2::Algorithm::Argon2i, - Self::Argon2id => argon2::Algorithm::Argon2id, + Self::Argon2d => Argon2Algorithm::Argon2d, + Self::Argon2i => Argon2Algorithm::Argon2i, + Self::Argon2id => Argon2Algorithm::Argon2id, } } } @@ -52,10 +52,10 @@ pub enum Version { impl Version { #[inline] - fn to_argon(self) -> argon2::Version { + fn to_argon(self) -> Argon2Version { match self { - Self::V0x10 => argon2::Version::V0x10, - Self::V0x13 => argon2::Version::V0x13, + Self::V0x10 => Argon2Version::V0x10, + Self::V0x13 => Argon2Version::V0x13, } } } @@ -95,24 +95,272 @@ pub struct Options { } impl Options { - #[inline] - fn to_argon<'a>(&'a self) -> std::result::Result, argon2::Error> { - let algorithm = self.algorithm.map(|a| a.to_argon()).unwrap_or_default(); - let version = self.version.map(|v| v.to_argon()).unwrap_or_default(); - let params = argon2::Params::new( - self.memory_cost.unwrap_or(argon2::Params::DEFAULT_M_COST), - self.time_cost.unwrap_or(argon2::Params::DEFAULT_T_COST), - self.parallelism.unwrap_or(argon2::Params::DEFAULT_P_COST), - self.output_len.map(|o| o as usize), - )?; - if let Some(sec) = &self.secret { - Argon2::new_with_secret(sec.as_ref(), algorithm, version, params) - } else { - Ok(Argon2::new(algorithm, version, params)) + fn algorithm(&self) -> Argon2Algorithm { + self + .algorithm + .map(|algorithm| algorithm.to_argon()) + .unwrap_or_default() + } + + fn version(&self) -> Argon2Version { + self + .version + .map(|version| version.to_argon()) + .unwrap_or_default() + } + + fn secret(&self) -> &[u8] { + self + .secret + .as_ref() + .map(|secret| secret.as_ref()) + .unwrap_or(&[]) + } + + fn salt(&self) -> Option<&[u8]> { + self.salt.as_ref().map(|salt| salt.as_ref()) + } + + fn params(&self) -> Result { + let mut builder = Params::builder(); + if let Some(memory_cost) = self.memory_cost { + builder = builder.memory(Memory::kib(memory_cost as u64)); + } + if let Some(time_cost) = self.time_cost { + builder = builder.passes(time_cost); + } + if let Some(parallelism) = self.parallelism { + builder = builder + .lanes(parallelism) + .threads(thread_budget(parallelism)); + } + if let Some(output_len) = self.output_len { + builder = builder.tag_len(TagLen::bytes(output_len as u64)); + } + builder.build().map_err(map_error) + } + + fn hasher(&self) -> Result { + Ok(Argon2::new( + self.algorithm(), + self.version(), + self.params()?, + )) + } +} + +fn thread_budget(lanes: u32) -> u32 { + let available = std::thread::available_parallelism() + .map(|n| n.get() as u32) + .unwrap_or(1) + .max(1); + lanes.min(available) +} + +fn try_zeroed(len: usize) -> Result> { + let mut tag = Vec::new(); + tag + .try_reserve_exact(len) + .map_err(|_| map_error(Argon2Error::MemoryAllocationError))?; + tag.resize(len, 0); + Ok(tag) +} + +fn map_error(err: Argon2Error) -> Error { + let status = match err { + Argon2Error::DecodingFail | Argon2Error::EncodingFail => Status::InvalidArg, + Argon2Error::MemoryAllocationError + | Argon2Error::ThreadFail + | Argon2Error::OsRandom + | Argon2Error::VerifyMismatch => Status::GenericFailure, + _ => Status::InvalidArg, + }; + Error::new(status, err.to_string()) +} + +fn password_bytes(password: Either) -> Vec { + match password { + Either::A(s) => s.into_bytes(), + Either::B(b) => b.to_vec(), + } +} + +fn utf8_input(value: Either) -> Result { + match value { + Either::A(s) => Ok(s), + Either::B(b) => { + String::from_utf8(b.to_vec()).map_err(|err| Error::new(Status::InvalidArg, format!("{err}"))) } } } +fn generate_salt() -> [u8; argon2_rust::RANDOM_SALT_LEN] { + rand::random() +} + +fn encode_phc(argon2: &Argon2, salt: &[u8], tag: &[u8]) -> String { + format!( + "${}$v={}$m={},t={},p={}${}${}", + argon2.algorithm().as_str(), + argon2.version().as_u32(), + argon2.params().memory_kib(), + argon2.params().passes(), + argon2.params().lanes(), + base64::engine::general_purpose::STANDARD_NO_PAD.encode(salt), + base64::engine::general_purpose::STANDARD_NO_PAD.encode(tag), + ) +} + +fn hash_encoded(argon2: &Argon2, password: &[u8], salt: &[u8], secret: &[u8]) -> Result { + if secret.is_empty() { + return argon2.hash_encoded(password, salt).map_err(map_error); + } + let mut tag = try_zeroed(argon2.params().tag_len_bytes())?; + argon2 + .hash_into_with_ad(password, salt, secret, &[], &mut tag) + .map_err(map_error)?; + Ok(encode_phc(argon2, salt, &tag)) +} + +fn hash_raw_bytes(argon2: &Argon2, password: &[u8], salt: &[u8], secret: &[u8]) -> Result> { + let mut tag = try_zeroed(argon2.params().tag_len_bytes())?; + argon2 + .hash_into_with_ad(password, salt, secret, &[], &mut tag) + .map_err(map_error)?; + Ok(tag) +} + +fn decode_fail() -> Result { + Err(Error::new( + Status::InvalidArg, + format!("Invalid hashed password: {}", Argon2Error::DecodingFail), + )) +} + +fn parse_phc_u32(value: &str) -> Result { + value.parse().or_else(|_| decode_fail()) +} + +fn decode_b64(input: &str) -> Result> { + base64::engine::general_purpose::STANDARD_NO_PAD + .decode(input) + .map_err(|_| { + Error::new( + Status::InvalidArg, + format!("Invalid hashed password: {}", Argon2Error::DecodingFail), + ) + }) +} + +struct DecodedPhc { + algorithm: Argon2Algorithm, + version: Argon2Version, + params: Params, + salt: Vec, + hash: Vec, + ad: Vec, +} + +/// PHC strings from the C reference / argon2-rust use `m,t,p`. +/// `@phc/format` (node-argon2) serializes object insertion order `m,p,t`. +/// Accept either, plus a missing `$v=` (version 16). +fn decode_phc(encoded: &str) -> Result { + let mut parts = encoded.split('$'); + if parts.next() != Some("") { + return decode_fail(); + } + + let algorithm = match parts.next() { + Some("argon2id") => Argon2Algorithm::Argon2id, + Some("argon2i") => Argon2Algorithm::Argon2i, + Some("argon2d") => Argon2Algorithm::Argon2d, + _ => return decode_fail(), + }; + + let mut next = match parts.next() { + Some(part) => part, + None => return decode_fail(), + }; + + let version = if let Some(raw) = next.strip_prefix("v=") { + next = match parts.next() { + Some(part) => part, + None => return decode_fail(), + }; + match raw.parse::() { + Ok(0x10) => Argon2Version::V0x10, + Ok(0x13) => Argon2Version::V0x13, + _ => return decode_fail(), + } + } else { + Argon2Version::V0x10 + }; + + let mut memory = None; + let mut passes = None; + let mut lanes = None; + let mut ad = Vec::new(); + for field in next.split(',') { + let Some((key, value)) = field.split_once('=') else { + return decode_fail(); + }; + match key { + "m" => memory = Some(parse_phc_u32(value)?), + "t" => passes = Some(parse_phc_u32(value)?), + "p" => lanes = Some(parse_phc_u32(value)?), + // node-argon2 / @phc/format stores associated data here. It is not + // produced by this binding, but verify must honour it. + "data" => ad = decode_b64(value)?, + _ => {} + } + } + + let (Some(memory), Some(passes), Some(lanes)) = (memory, passes, lanes) else { + return decode_fail(); + }; + + let salt = decode_b64(match parts.next() { + Some(part) => part, + None => return decode_fail(), + })?; + let hash = decode_b64(match parts.next() { + Some(part) => part, + None => return decode_fail(), + })?; + if parts.next().is_some() { + return decode_fail(); + } + + let params = Params::builder() + .memory(Memory::kib(memory as u64)) + .passes(passes) + .lanes(lanes) + .threads(thread_budget(lanes)) + .tag_len(TagLen::bytes(hash.len() as u64)) + .build() + .map_err(map_error)?; + + Ok(DecodedPhc { + algorithm, + version, + params, + salt, + hash, + ad, + }) +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (left, right) in a.iter().zip(b) { + diff |= left ^ right; + } + diff == 0 +} + pub struct HashTask { password: Vec, options: Options, @@ -124,20 +372,15 @@ impl Task for HashTask { type JsValue = String; fn compute(&mut self) -> Result { - let salt = if let Some(salt) = &self.options.salt { - Salt::new(salt).map_err(|err| Error::new(Status::InvalidArg, format!("{err}")))? - } else { - Salt::generate() - }; - let hasher = self - .options - .to_argon() - .map_err(|err| Error::new(Status::InvalidArg, format!("{err}")))?; - - hasher - .hash_password_with_salt(self.password.as_slice(), &salt) - .map_err(|err| Error::new(Status::GenericFailure, format!("{err}"))) - .map(|h| h.to_string()) + let hasher = self.options.hasher()?; + let secret = self.options.secret(); + match self.options.salt() { + Some(salt) => hash_encoded(&hasher, &self.password, salt, secret), + None => { + let salt = generate_salt(); + hash_encoded(&hasher, &self.password, &salt, secret) + } + } } fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { @@ -153,10 +396,7 @@ pub fn hash( ) -> AsyncTask { AsyncTask::with_optional_signal( HashTask { - password: match password { - Either::A(s) => s.as_bytes().to_vec(), - Either::B(b) => b.to_vec(), - }, + password: password_bytes(password), options: options.unwrap_or_default(), }, abort_signal, @@ -170,10 +410,7 @@ pub fn hash_sync( options: Option, ) -> Result { let mut hash_task = HashTask { - password: match password { - Either::A(s) => s.as_bytes().to_vec(), - Either::B(b) => b.to_vec(), - }, + password: password_bytes(password), options: options.unwrap_or_default(), }; let output = hash_task.compute()?; @@ -191,29 +428,15 @@ impl Task for RawHashTask { type JsValue = Buffer; fn compute(&mut self) -> Result { - let hasher = self - .options - .to_argon() - .map_err(|err| Error::new(Status::InvalidArg, format!("{err}")))?; - let output_len = hasher - .params() - .output_len() - .unwrap_or(Params::DEFAULT_OUTPUT_LEN); - let mut output = vec![0; output_len]; - - match &self.options.salt { - Some(buf) => hasher.hash_password_into(self.password.as_slice(), buf.as_ref(), &mut output), + let hasher = self.options.hasher()?; + let secret = self.options.secret(); + match self.options.salt() { + Some(salt) => hash_raw_bytes(&hasher, &self.password, salt, secret), None => { - let generated_salt = Salt::generate(); - hasher.hash_password_into( - self.password.as_slice(), - generated_salt.as_ref(), - &mut output, - ) + let salt = generate_salt(); + hash_raw_bytes(&hasher, &self.password, &salt, secret) } } - .map_err(|err| Error::new(Status::GenericFailure, format!("{err}"))) - .map(|_| output) } fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { @@ -229,10 +452,7 @@ pub fn hash_raw( ) -> AsyncTask { AsyncTask::with_optional_signal( RawHashTask { - password: match password { - Either::A(s) => s.as_bytes().to_vec(), - Either::B(b) => b.to_vec(), - }, + password: password_bytes(password), options: options.unwrap_or_default(), }, abort_signal, @@ -246,10 +466,7 @@ pub fn hash_raw_sync( options: Option, ) -> Result { let mut hash_task = RawHashTask { - password: match password { - Either::A(s) => s.as_bytes().to_vec(), - Either::B(b) => b.to_vec(), - }, + password: password_bytes(password), options: options.unwrap_or_default(), }; let output = hash_task.compute()?; @@ -268,19 +485,20 @@ impl Task for VerifyTask { type JsValue = bool; fn compute(&mut self) -> Result { - let parsed_hash = argon2::PasswordHash::new(self.hashed.as_str()).map_err(|err| { - Error::new( - Status::InvalidArg, - format!("Invalid hashed password: {err}"), + let decoded = decode_phc(&self.hashed)?; + let argon2 = Argon2::new(decoded.algorithm, decoded.version, decoded.params); + let secret = self.options.secret(); + let mut computed = try_zeroed(decoded.hash.len())?; + argon2 + .hash_into_with_ad( + self.password.as_bytes(), + &decoded.salt, + secret, + &decoded.ad, + &mut computed, ) - })?; - let argon2 = self.options.to_argon(); - Ok( - argon2 - .map_err(|err| Error::new(Status::InvalidArg, format!("{err}")))? - .verify_password(self.password.as_bytes(), &parsed_hash) - .is_ok(), - ) + .map_err(map_error)?; + Ok(constant_time_eq(&computed, &decoded.hash)) } fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { @@ -297,16 +515,8 @@ pub fn verify( ) -> Result> { Ok(AsyncTask::with_optional_signal( VerifyTask { - password: match password { - Either::A(s) => s, - Either::B(b) => String::from_utf8(b.to_vec()) - .map_err(|err| Error::new(Status::InvalidArg, format!("{err}")))?, - }, - hashed: match hashed { - Either::A(s) => s, - Either::B(b) => String::from_utf8(b.to_vec()) - .map_err(|err| Error::new(Status::InvalidArg, format!("{err}")))?, - }, + password: utf8_input(password)?, + hashed: utf8_input(hashed)?, options: options.unwrap_or_default(), }, abort_signal, @@ -321,16 +531,8 @@ pub fn verify_sync( options: Option, ) -> Result { let mut verify_task = VerifyTask { - password: match password { - Either::A(s) => s, - Either::B(b) => String::from_utf8(b.to_vec()) - .map_err(|err| Error::new(Status::InvalidArg, format!("{err}")))?, - }, - hashed: match hashed { - Either::A(s) => s, - Either::B(b) => String::from_utf8(b.to_vec()) - .map_err(|err| Error::new(Status::InvalidArg, format!("{err}")))?, - }, + password: utf8_input(password)?, + hashed: utf8_input(hashed)?, options: options.unwrap_or_default(), }; let output = verify_task.compute()?; diff --git a/yarn.lock b/yarn.lock index 3d52f025..562ed9f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1437,15 +1437,24 @@ __metadata: languageName: node linkType: hard +"@noble/hashes@npm:^2.3.0": + version: 2.3.0 + resolution: "@noble/hashes@npm:2.3.0" + checksum: 10c0/85651da4c1ea1db15fcacac7f38e4c4e6d60154cd116200b1002c198893bfad2733b31108352fde307126bab789f82f47b5bee8a12462b12f25fd0703aed7dce + languageName: node + linkType: hard + "@node-rs/argon2@workspace:packages/argon2": version: 0.0.0-use.local resolution: "@node-rs/argon2@workspace:packages/argon2" dependencies: "@napi-rs/cli": "npm:^3.8.6" + "@noble/hashes": "npm:^2.3.0" "@oxc-node/cli": "npm:^0.1.0" "@oxc-node/core": "npm:^0.1.0" argon2: "npm:^0.45.1" cross-env: "npm:^10.1.0" + hash-wasm: "npm:^4.12.0" tinybench: "npm:^6.1.3" languageName: unknown linkType: soft @@ -5127,6 +5136,13 @@ __metadata: languageName: node linkType: hard +"hash-wasm@npm:^4.12.0": + version: 4.12.0 + resolution: "hash-wasm@npm:4.12.0" + checksum: 10c0/6cb95055319810b6f673de755289960683a9831807690795f0e8918b2fd7e6ae5b82a9dc47cbace171cf2814b412ef68c315a0ead0b4d96bd3e56a05e4bde136 + languageName: node + linkType: hard + "hasown@npm:2.0.4, hasown@npm:^2.0.2, hasown@npm:^2.0.4": version: 2.0.4 resolution: "hasown@npm:2.0.4"