Key Derivation
How passwords are stretched with Argon2id before becoming keys — the Argon2Kdf primitive (Argon2KdfOptions), the WebAssembly-vs-pure-JS backend, and argon2BackendInfo() (Argon2BackendInfo) for detecting which path ran.
A password is never used as a key directly. Every sealed record is protected by a key stretched from the password with Argon2id, the memory-hard KDF from RFC 9106. This page covers the Argon2Kdf primitive that performs that stretch, the two interchangeable backends it can run on, and argon2BackendInfo() for telling which one actually ran.
You rarely call Argon2Kdf yourself — a provider derives keys for you during unlock. It is exposed for custom encryption work, and understanding the backend resolution matters because one of the two paths is far slower than the other.
Argon2id parameters
Argon2Kdf is configured through Argon2KdfOptions. Each field is optional and falls back to a default drawn from RFC 9106's recommendations.
interface Argon2KdfOptions {
/** Iterations (time cost). Default: 3. */
time?: number;
/** Memory cost in KiB. Default: 65536 (64 MiB). */
memory?: number;
/** Parallelism. Default: 1. */
parallelism?: number;
/** Output hash length in bytes. Default: 32. */
hashLength?: number;
}| Option | Default | Notes |
|---|---|---|
time | 3 | Argon2 iteration (t) cost. |
memory | 65536 (64 MiB) | Memory (m) cost, in KiB. |
parallelism | 1 | Lanes (p). |
hashLength | 32 | Derived key length in bytes. |
Parameters are validated before any work runs. An out-of-spec value fails loudly rather than silently degrading — for example a time of 0 would otherwise skip the work loop entirely and return the same constant for every password.
Argon2Kdf
Argon2Kdf implements the synchronous Cipher contract, with its id set to "argon2id". Because a KDF is one-way, only the forward direction does anything.
import { Argon2Kdf } from "wative-core";
const kdf = new Argon2Kdf({ time: 3, memory: 64 * 1024 });
// encrypt(password, salt): derive a key. Both arguments are Uint8Array.
const key = kdf.encrypt(passwordBytes, saltBytes); // 32-byte Uint8Array
// decrypt() always throws — Argon2 is irreversible.
kdf.decrypt(key, saltBytes); // WativeError("ALGORITHM_IRREVERSIBLE")| Member | Signature | Notes |
|---|---|---|
encrypt | encrypt(password: Uint8Array, salt: Uint8Array): Uint8Array | Derives the key. |
decrypt | decrypt(input: Uint8Array, secret: Uint8Array): never | Always throws WativeError("ALGORITHM_IRREVERSIBLE"). |
encrypt enforces its inputs. Both arguments must be a Uint8Array, and the salt must be at least 16 bytes — RFC 9106 §3.1 mandates that minimum for Argon2id, and shorter salts are rejected so sealed records stay portable to strict verifiers. A bad argument throws WativeError("ENCRYPT_FAILED").
Argon2Kdf also asserts the returned key is exactly hashLength bytes long. Some Argon2 builds floor the output to a multiple of four bytes, so a request for 65 would silently yield 64 — a short key with no error. The length check turns that into a throw instead.
WebAssembly vs pure-JS backend
Key derivation runs through one of two byte-identical backends. The default is resolved once per process, synchronously:
wasm— a vendored WebAssembly Argon2 build. Chosen when it both compiles and reproduces a known-answer vector. This is the normal, fast path.noble— a pure-JavaScript implementation (@noble/hashes). Produces identical output, but roughly 17× slower.
The library degrades to noble rather than throwing, because the situations that block WebAssembly are not rescuable by trying harder:
The most common cause is a Content-Security-Policy without 'wasm-unsafe-eval', which blocks WebAssembly compilation entirely — sync and async, main thread and worker alike. Some engines also ship no WebAssembly object at all. Granting 'wasm-unsafe-eval' in your CSP restores the fast path.
The fallback is announced once on the console. If you would rather fail loudly than run slowly, set the environment variable WATIVE_REQUIRE_WASM_ARGON2=1 and the default resolution throws WativeError("UNSUPPORTED_OP") instead of degrading. See Environments for where each backend applies.
argon2BackendInfo()
argon2BackendInfo(): Argon2BackendInfo reports which implementation the process settled on and why — the way to detect a silent fallback programmatically.
interface Argon2BackendInfo {
readonly backend: string; // "wasm" | "noble" | "unresolved"
readonly wasm: boolean; // true only when wasm compiled AND verified
readonly reason?: string; // present only on a fallback / unresolved
readonly overrides: readonly string[];
}| Field | Meaning |
|---|---|
backend | The default backend id: "wasm", "noble", or "unresolved". |
wasm | true only when the WebAssembly module compiled and reproduced its known vector. |
reason | On a fallback, why WebAssembly could not be used; on "unresolved", why nothing has resolved yet. |
overrides | Backend ids installed on individual providers. Non-empty means some derives do not use backend. |
import { argon2BackendInfo } from "wative-core";
const info = argon2BackendInfo();
if (info.backend === "noble") {
console.warn(`Argon2 is running in pure JS: ${info.reason}`);
}argon2BackendInfo() is a pure read — it deliberately does not trigger resolution, since doing so on an engine that caps synchronous compilation could itself pin the slow path. Before any container has been unlocked it honestly reports backend: "unresolved".
The V3 provider override
The backend field is the process-wide default, which is not necessarily what every derive uses. A provider may carry its own backend, and HybridProviderV3 does: it seals with native Argon2id bindings rather than the resolved default.
So a process using HybridProviderV3 will report backend: "wasm" while every one of its derives runs natively. The overrides array is what tells you that — it lists the backend ids installed per-provider (for example "node-rs"). A non-empty overrides means some key derivations in the process bypass backend entirely.