@kirkelliott/kdfts 1.1.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,9 +3,9 @@
3
3
  [![CI](https://github.com/dmvjs/kdfts/actions/workflows/ci.yml/badge.svg)](https://github.com/dmvjs/kdfts/actions/workflows/ci.yml)
4
4
  [![license](https://img.shields.io/github/license/dmvjs/kdfts)](LICENSE)
5
5
 
6
- If you're storing passwords or deriving secrets that must survive a server compromise, the salt is what attackers try to precompute. A stolen PRNG seed, a weak entropy source, or a future algorithm break can reconstruct every salt you've ever generated. `kdfts` removes that attack surface by sourcing salt from the [ANU Quantum Random Number Generator](https://quantumnumbers.anu.edu.au) photon shot noise at a beam splitter. Those bytes were never produced by an algorithm. There is no state to steal.
6
+ Argon2id KDF with optional quantum-backed salt provenance. Salt is sourced from the [ANU Quantum Random Number Generator](https://quantumnumbers.anu.edu.au) (photon shot noise at a beam splitter) when available, with `strict` mode for deployments where that provenance is required. Falls back to `crypto.getRandomValues()` otherwise.
7
7
 
8
- Falls back silently to `crypto.getRandomValues()` when the ANU API is unavailable.
8
+ Standard CSPRNGs already satisfy OWASP's salt requirements. The ANU source adds auditable entropy provenance — useful for compliance workflows or systems that need to document their randomness chain, not a substitute for strong KDF parameters.
9
9
 
10
10
  ## Install
11
11
 
@@ -26,13 +26,12 @@ export ANU_API_KEY=your-key-here
26
26
  ```ts
27
27
  import { derive, formatCert } from '@kirkelliott/kdfts'
28
28
 
29
- const { key, salt, certificate } = await derive('my-password', {
29
+ const { hash, certificate } = await derive('my-password', {
30
30
  context: 'myapp:user42:session', // domain separation — optional but recommended
31
31
  })
32
32
 
33
- // Persist theseboth are needed to verify later
34
- const persistedSalt = salt.toString('hex')
35
- const persistedKey = key.toString('hex')
33
+ // Persist this single string it contains everything needed to verify later
34
+ await db.users.update({ passwordHash: hash })
36
35
 
37
36
  console.log(formatCert(certificate))
38
37
  // ════════════════════════════════════════════════════
@@ -40,8 +39,8 @@ console.log(formatCert(certificate))
40
39
  // ════════════════════════════════════════════════════
41
40
  // Source: ANU Quantum Vacuum (photon shot noise)
42
41
  // Timestamp: 2026-03-07T04:00:00.000Z
43
- // KDF: scrypt
44
- // N / r / p: 16384 / 8 / 1
42
+ // KDF: argon2id
43
+ // t / m / p: 3 / 65536 / 4
45
44
  // Key length: 32 bytes (256 bits)
46
45
  // Salt bytes: 32 bytes (256 bits)
47
46
  // Context: myapp:user42:session
@@ -56,12 +55,8 @@ console.log(formatCert(certificate))
56
55
  ```ts
57
56
  import { verify } from '@kirkelliott/kdfts'
58
57
 
59
- const salt = Buffer.from(persistedSalt, 'hex')
60
- const key = Buffer.from(persistedKey, 'hex')
61
-
62
- const valid = await verify('my-password', salt, key, {
63
- context: 'myapp:user42:session',
64
- })
58
+ const valid = await verify('my-password', storedHash)
59
+ // Parameters, salt, and context are all read from the hash string.
65
60
  ```
66
61
 
67
62
  **Reuse a source across multiple derivations** (saves one ANU round-trip):
@@ -82,18 +77,19 @@ const [keyA, keyB] = await Promise.all([
82
77
 
83
78
  | Option | Type | Default | Description |
84
79
  |---|---|---|---|
85
- | `context` | `string` | — | Domain separation string. Mixed in as `password \|\| 0x00 \|\| context`. |
80
+ | `context` | `string` | — | Domain separation string, passed as Argon2id `associatedData`. Embedded in the hash — no need to pass to `verify()`. |
86
81
  | `saltBytes` | `number` | `32` | Bytes of quantum entropy to fetch. |
87
82
  | `keyLength` | `number` | `32` | Output key length in bytes. |
88
- | `cost` | `{ N, r, p }` | `{ N: 16384, r: 8, p: 1 }` | scrypt parameters. Increase `N` for higher-value keys. |
83
+ | `cost` | `{ timeCost, memoryCost, parallelism }` | `{ timeCost: 3, memoryCost: 65536, parallelism: 4 }` | Argon2id parameters. `memoryCost` is in KiB. Embedded in the hash — no need to track separately. |
84
+ | `strict` | `boolean` | `false` | Throw if the ANU source is unavailable instead of falling back to `crypto.getRandomValues()`. |
89
85
  | `source` | `QuantumSource` | — | Pre-created source for reuse or testing. |
90
86
 
91
87
  ## Security notes
92
88
 
93
89
  - Salt is fetched fresh for every `derive` call — never reused
94
- - Context is domain-separated with a null byte: `password || 0x00 || context`
90
+ - Context is passed as Argon2id `associatedData` and embedded in the hash `verify()` reads it automatically
95
91
  - Verification uses `crypto.timingSafeEqual` — no timing oracle on key comparison
96
- - scrypt defaults (`N=16384, r=8, p=1`) follow [OWASP recommendations](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
92
+ - Argon2id defaults (`timeCost=3, memoryCost=65536, parallelism=4`) exceed [OWASP minimum recommendations](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
97
93
  - The certificate records only SHA-256 hashes of the salt and key, never the raw values
98
94
  - Every claim in this README is validated by the [test suite](https://github.com/dmvjs/kdfts/actions/workflows/ci.yml)
99
95
 
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.QuantumSource = exports.verify = exports.formatCert = exports.derive = void 0;
3
+ exports.QuantumSource = exports.verify = exports.formatCert = exports.derive = exports.needsRehash = void 0;
4
+ var argon2_1 = require("argon2");
5
+ Object.defineProperty(exports, "needsRehash", { enumerable: true, get: function () { return argon2_1.needsRehash; } });
4
6
  var kdf_1 = require("./kdf");
5
7
  Object.defineProperty(exports, "derive", { enumerable: true, get: function () { return kdf_1.derive; } });
6
8
  Object.defineProperty(exports, "formatCert", { enumerable: true, get: function () { return kdf_1.formatCert; } });
package/dist/kdf.js CHANGED
@@ -4,68 +4,58 @@ exports.derive = derive;
4
4
  exports.verify = verify;
5
5
  exports.formatCert = formatCert;
6
6
  const node_crypto_1 = require("node:crypto");
7
+ const argon2_1 = require("argon2");
7
8
  const qrng_1 = require("./qrng");
8
- const scryptAsync = (password, salt, keyLen, opts) => new Promise((resolve, reject) => (0, node_crypto_1.scrypt)(password, salt, keyLen, opts, (err, key) => err ? reject(err) : resolve(key)));
9
9
  /**
10
- * Derive a cryptographic key from a password using scrypt with a
10
+ * Derive a cryptographic key from a password using Argon2id with a
11
11
  * quantum-random salt sourced from ANU vacuum fluctuations.
12
+ *
13
+ * Returns a self-describing PHC hash string. Persist it as-is.
12
14
  */
13
15
  async function derive(password, options = {}) {
14
16
  const saltBytes = options.saltBytes ?? 32;
15
17
  const keyLength = options.keyLength ?? 32;
16
18
  const context = options.context ?? null;
17
- const N = options.cost?.N ?? 16384;
18
- const r = options.cost?.r ?? 8;
19
- const p = options.cost?.p ?? 1;
20
- const qs = options.source ?? (await qrng_1.QuantumSource.create());
19
+ const timeCost = options.cost?.timeCost ?? 3;
20
+ const memoryCost = options.cost?.memoryCost ?? 65536;
21
+ const parallelism = options.cost?.parallelism ?? 4;
22
+ const qs = options.source ??
23
+ (await qrng_1.QuantumSource.create(undefined, { strict: options.strict }));
21
24
  const salt = await qs.bytes(saltBytes);
22
- const ikm = context
23
- ? Buffer.concat([
24
- Buffer.from(password),
25
- Buffer.from('\x00'),
26
- Buffer.from(context),
27
- ])
28
- : Buffer.from(password);
29
- const key = await scryptAsync(ikm, salt, keyLength, {
30
- N,
31
- r,
32
- p,
33
- maxmem: 128 * N * r * 2,
25
+ const encoded = await (0, argon2_1.hash)(Buffer.from(password), {
26
+ type: argon2_1.argon2id,
27
+ salt,
28
+ timeCost,
29
+ memoryCost,
30
+ parallelism,
31
+ hashLength: keyLength,
32
+ ...(context !== null && { associatedData: Buffer.from(context) }),
34
33
  });
34
+ // Extract hash bytes from the last segment of the PHC string for the certificate.
35
+ // Format: $argon2id$v=19$m=...,t=...,p=...[,data=...]$<salt_b64>$<hash_b64>
36
+ const parts = encoded.split('$');
37
+ const hashBytes = Buffer.from(parts[parts.length - 1], 'base64');
35
38
  const certificate = {
36
39
  source: qs.source,
37
40
  timestamp: new Date().toISOString(),
38
41
  saltHash: sha256(salt),
39
- keyHash: sha256(key),
40
- params: { N, r, p, keyLength, saltBytes },
42
+ keyHash: sha256(hashBytes),
43
+ params: { timeCost, memoryCost, parallelism, keyLength, saltBytes },
41
44
  context,
42
45
  };
43
- return { key, salt, certificate };
46
+ return { hash: encoded, certificate };
44
47
  }
45
48
  /**
46
- * Re-derive from password + salt and check against the expected key.
47
- * Uses constant-time comparison.
49
+ * Verify a password against a stored PHC hash string.
50
+ * All parameters — including context (associatedData) — are read from the hash.
48
51
  */
49
- async function verify(password, salt, key, options = {}) {
50
- const keyLength = options.keyLength ?? key.length;
51
- const N = options.cost?.N ?? 16384;
52
- const r = options.cost?.r ?? 8;
53
- const p = options.cost?.p ?? 1;
54
- const context = options.context ?? null;
55
- const ikm = context
56
- ? Buffer.concat([
57
- Buffer.from(password),
58
- Buffer.from('\x00'),
59
- Buffer.from(context),
60
- ])
61
- : Buffer.from(password);
62
- const derived = await scryptAsync(ikm, salt, keyLength, {
63
- N,
64
- r,
65
- p,
66
- maxmem: 128 * N * r * 2,
67
- });
68
- return derived.length === key.length && (0, node_crypto_1.timingSafeEqual)(derived, key);
52
+ async function verify(password, hash) {
53
+ try {
54
+ return await (0, argon2_1.verify)(hash, Buffer.from(password));
55
+ }
56
+ catch {
57
+ return false;
58
+ }
69
59
  }
70
60
  /** Format a certificate as a human-readable block. */
71
61
  function formatCert(cert) {
@@ -81,8 +71,8 @@ function formatCert(cert) {
81
71
  line,
82
72
  row('Source:', sourceLabel),
83
73
  row('Timestamp:', cert.timestamp),
84
- row('KDF:', 'scrypt'),
85
- row('N / r / p:', `${cert.params.N} / ${cert.params.r} / ${cert.params.p}`),
74
+ row('KDF:', 'argon2id'),
75
+ row('t / m / p:', `${cert.params.timeCost} / ${cert.params.memoryCost} / ${cert.params.parallelism}`),
86
76
  row('Key length:', `${cert.params.keyLength} bytes (${cert.params.keyLength * 8} bits)`),
87
77
  row('Salt bytes:', `${cert.params.saltBytes} bytes (${cert.params.saltBytes * 8} bits)`),
88
78
  cert.context ? row('Context:', cert.context) : null,
package/dist/qrng.js CHANGED
@@ -3,7 +3,8 @@
3
3
  * Quantum Random Number Generator
4
4
  *
5
5
  * Probes ANU (Australian National University) quantum vacuum fluctuations API.
6
- * Falls back silently to crypto.getRandomValues() if ANU is unavailable.
6
+ * Falls back silently to crypto.getRandomValues() if ANU is unavailable,
7
+ * unless strict mode is enabled.
7
8
  *
8
9
  * API: https://api.quantumnumbers.anu.edu.au
9
10
  * Keys: https://quantumnumbers.anu.edu.au
@@ -20,19 +21,30 @@ class QuantumSource {
20
21
  this.#source = source;
21
22
  this.#apiKey = apiKey;
22
23
  }
23
- /** Create a QuantumSource. Probes ANU first; falls back to crypto silently. */
24
- static async create(apiKey) {
24
+ /**
25
+ * Create a QuantumSource. Probes ANU first.
26
+ * Without strict mode, falls back to crypto.getRandomValues() silently if ANU is unavailable.
27
+ * With strict mode, throws if ANU is unavailable or no API key is configured.
28
+ */
29
+ static async create(apiKey, options) {
25
30
  const key = apiKey ?? process.env.ANU_API_KEY;
31
+ const strict = options?.strict ?? false;
26
32
  if (key) {
27
33
  try {
28
34
  const qs = new QuantumSource('anu', key);
29
35
  await qs.bytes(1); // probe
30
36
  return qs;
31
37
  }
32
- catch {
38
+ catch (err) {
39
+ if (strict) {
40
+ throw new Error(`ANU QRNG unavailable (strict mode): ${err instanceof Error ? err.message : String(err)}`);
41
+ }
33
42
  // fall through to crypto
34
43
  }
35
44
  }
45
+ else if (strict) {
46
+ throw new Error('ANU QRNG strict mode requires an API key — set ANU_API_KEY or pass apiKey');
47
+ }
36
48
  return new QuantumSource('crypto');
37
49
  }
38
50
  /** Which entropy source is active. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kirkelliott/kdfts",
3
- "version": "1.1.1",
4
- "description": "Quantum-seeded KDF — scrypt with salt from ANU vacuum fluctuations",
3
+ "version": "2.0.1",
4
+ "description": "Quantum-seeded KDF — Argon2id with salt from ANU vacuum fluctuations",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "exports": {
@@ -27,5 +27,8 @@
27
27
  "dist",
28
28
  "README.md",
29
29
  "LICENSE"
30
- ]
30
+ ],
31
+ "dependencies": {
32
+ "argon2": "^0.44.0"
33
+ }
31
34
  }