@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 +14 -18
- package/dist/index.js +3 -1
- package/dist/kdf.js +35 -45
- package/dist/qrng.js +16 -4
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
[](https://github.com/dmvjs/kdfts/actions/workflows/ci.yml)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
|
|
6
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
34
|
-
|
|
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:
|
|
44
|
-
//
|
|
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
|
|
60
|
-
|
|
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
|
|
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` | `{
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const qs = options.source ??
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
:
|
|
29
|
-
|
|
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(
|
|
40
|
-
params: {
|
|
42
|
+
keyHash: sha256(hashBytes),
|
|
43
|
+
params: { timeCost, memoryCost, parallelism, keyLength, saltBytes },
|
|
41
44
|
context,
|
|
42
45
|
};
|
|
43
|
-
return {
|
|
46
|
+
return { hash: encoded, certificate };
|
|
44
47
|
}
|
|
45
48
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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:', '
|
|
85
|
-
row('
|
|
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
|
-
/**
|
|
24
|
-
|
|
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": "
|
|
4
|
-
"description": "Quantum-seeded KDF —
|
|
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
|
}
|