feat(argon2): switch backend to argon2-rust (#1188)

* feat(argon2): switch backend to argon2-rust

Replace RustCrypto argon2 with argon2-rust 1.0.0 from crates.io.
Keep the public JS API. Verify accepts node-argon2 PHC param order
and associatedData. The bench pins the same raw params, salt, and
tag across implementations.

* fix(argon2): keep Node 22 tests and bound verify threads

Skip the node:crypto.argon2Sync comparison on runtimes that lack it.
Cap worker threads at available parallelism so a PHC p= value cannot
spawn one OS thread per lane. Allocate tag buffers with try_reserve
instead of vec! so a huge outputLen is a JS error, not a process abort.

* fix(argon2): skip node-argon2 interop when its native addon is missing

node-argon2 0.45 has no darwin-x64 prebuild. Intel macOS CI runs x64
Node on an ARM host, so a static import crashed the whole spec file.

* test(argon2): compare against node:crypto argon2

Drop the node-argon2 native addon from tests. Interop uses the
builtin KDF, which is available on every CI host that has it.
This commit is contained in:
LongYinan authored and GitHub committed 2026-08-13 11:01:01 +08:00
1 parent 05156db802
commit 2b8b617be0
9 files changed
+607 -159

No files matched your search

+1 -1
View File
@@ -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"] }
+1 -1
View File
@@ -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. |
+4 -2
View File
@@ -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 }
+25 -3
View File
@@ -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
+67 -4
View File
@@ -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))
})
+180 -39
View File
@@ -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<Uint8Array>
}
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<Record<string, string | number>>) => {
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))
}
+3 -1
View File
@@ -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": {
+310 -108
View File
@@ -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<'a>, 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<Params> {
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<Argon2> {
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<Vec<u8>> {
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<String, &[u8]>) -> Vec<u8> {
match password {
Either::A(s) => s.into_bytes(),
Either::B(b) => b.to_vec(),
}
}
fn utf8_input(value: Either<String, &[u8]>) -> Result<String> {
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<String> {
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<Vec<u8>> {
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<T>() -> Result<T> {
Err(Error::new(
Status::InvalidArg,
format!("Invalid hashed password: {}", Argon2Error::DecodingFail),
))
}
fn parse_phc_u32(value: &str) -> Result<u32> {
value.parse().or_else(|_| decode_fail())
}
fn decode_b64(input: &str) -> Result<Vec<u8>> {
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<u8>,
hash: Vec<u8>,
ad: Vec<u8>,
}
/// 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<DecodedPhc> {
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::<u32>() {
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<u8>,
options: Options,
@@ -124,20 +372,15 @@ impl Task for HashTask {
type JsValue = String;
fn compute(&mut self) -> Result<Self::Output> {
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<Self::JsValue> {
@@ -153,10 +396,7 @@ pub fn hash(
) -> AsyncTask<HashTask> {
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<Options>,
) -> Result<String> {
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<Self::Output> {
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<Self::JsValue> {
@@ -229,10 +452,7 @@ pub fn hash_raw(
) -> AsyncTask<RawHashTask> {
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<Options>,
) -> Result<Buffer> {
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<Self::Output> {
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<Self::JsValue> {
@@ -297,16 +515,8 @@ pub fn verify(
) -> Result<AsyncTask<VerifyTask>> {
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<Options>,
) -> Result<bool> {
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()?;
+16
View File
@@ -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"