@kirkelliott/kdfts 1.1.1 → 2.0.0

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
@@ -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):
@@ -85,15 +80,15 @@ const [keyA, keyB] = await Promise.all([
85
80
  | `context` | `string` | — | Domain separation string. Mixed in as `password \|\| 0x00 \|\| context`. |
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. |
89
84
  | `source` | `QuantumSource` | — | Pre-created source for reuse or testing. |
90
85
 
91
86
  ## Security notes
92
87
 
93
88
  - Salt is fetched fresh for every `derive` call — never reused
94
- - Context is domain-separated with a null byte: `password || 0x00 || context`
89
+ - Context is passed as Argon2id `associatedData` and embedded in the hash `verify()` reads it automatically
95
90
  - 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)
91
+ - Argon2id defaults (`timeCost=3, memoryCost=65536, parallelism=4`) exceed [OWASP minimum recommendations](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
97
92
  - The certificate records only SHA-256 hashes of the salt and key, never the raw values
98
93
  - Every claim in this README is validated by the [test suite](https://github.com/dmvjs/kdfts/actions/workflows/ci.yml)
99
94
 
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.0",
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
  }