@pramen/auth 0.0.42 → 0.0.44
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/dist/index.js +49 -11
- package/package.json +2 -2
- package/src/index.ts +53 -19
package/dist/index.js
CHANGED
|
@@ -46,17 +46,56 @@ function unb64(s) {
|
|
|
46
46
|
const b64url = (bytes) => b64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
47
47
|
const b64urlStr = (s) => b64url(enc(s));
|
|
48
48
|
// --- password hashing (PBKDF2-SHA256) ---
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
/** workerd's hard ceiling on PBKDF2 iterations.
|
|
50
|
+
*
|
|
51
|
+
* It does not clamp — it throws:
|
|
52
|
+
*
|
|
53
|
+
* NotSupportedError: Pbkdf2 failed: iteration counts above 100000 are not
|
|
54
|
+
* supported (requested 600000).
|
|
55
|
+
*
|
|
56
|
+
* Bun, Node and browsers have no such cap, so anything above this passes every local
|
|
57
|
+
* test — including under lopata, which is a Bun runtime — and then fails on the first
|
|
58
|
+
* real deployment. This value is the platform limit, not a tuning knob. */
|
|
59
|
+
const PBKDF2_MAX_ITERATIONS = 100_000;
|
|
60
|
+
/** Iterations for NEW hashes.
|
|
61
|
+
*
|
|
62
|
+
* OWASP 2026 guidance for PBKDF2-HMAC-SHA256 is ~600k, and this was 600k until it
|
|
63
|
+
* turned out that every signup and login 500s on Workers (see PBKDF2_MAX_ITERATIONS).
|
|
64
|
+
* pramen deploys to workerd, so the platform ceiling is the real bound: WebCrypto there
|
|
65
|
+
* offers no scrypt or argon2 either, making 100k the strongest KDF available.
|
|
66
|
+
*
|
|
67
|
+
* The count (and hash alg) are ENCODED in the stored string —
|
|
68
|
+
* `pbkdf2$sha256$<iters>$<saltB64>$<hashB64>` — and verifyPassword parses them back
|
|
69
|
+
* out, so changing this never breaks verification of an already-stored hash, as long
|
|
70
|
+
* as the stored count is itself within the cap. */
|
|
71
|
+
const PBKDF2_ITERATIONS = Math.min(600_000, PBKDF2_MAX_ITERATIONS);
|
|
54
72
|
const PBKDF2_HASH = "SHA-256";
|
|
73
|
+
/** Derive 256 PBKDF2 bits, turning workerd's iteration-cap rejection into a diagnosis.
|
|
74
|
+
*
|
|
75
|
+
* Hashing can no longer request too many, but VERIFY takes its count from the stored
|
|
76
|
+
* hash — so a row written at 600k by pramen <= 0.0.43, or by a Bun/Node-only
|
|
77
|
+
* deployment sharing a database with a Worker, cannot be verified on Workers at all.
|
|
78
|
+
* That would otherwise surface as `NotSupportedError` from deep inside WebCrypto, or —
|
|
79
|
+
* worse, if a caller swallowed it — as a plain "wrong password" locking the account
|
|
80
|
+
* out with nothing in the logs to explain why. */
|
|
81
|
+
async function deriveBits(password, salt, iterations, hash) {
|
|
82
|
+
const key = await crypto.subtle.importKey("raw", enc(password), "PBKDF2", false, ["deriveBits"]);
|
|
83
|
+
try {
|
|
84
|
+
return new Uint8Array(await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations, hash }, key, 256));
|
|
85
|
+
}
|
|
86
|
+
catch (cause) {
|
|
87
|
+
if (iterations > PBKDF2_MAX_ITERATIONS) {
|
|
88
|
+
throw new Error(`password hash uses ${iterations} PBKDF2 iterations, above the ${PBKDF2_MAX_ITERATIONS} this runtime allows. `
|
|
89
|
+
+ `It was written by pramen <= 0.0.43 or on a runtime without the cap (Bun/Node), and cannot be verified here. `
|
|
90
|
+
+ `Reset the affected passwords, or re-import the rows under a foreign scheme (see registerPasswordVerifier).`, { cause });
|
|
91
|
+
}
|
|
92
|
+
throw cause;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
55
95
|
export async function hashPassword(password) {
|
|
56
96
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
return `pbkdf2$sha256$${PBKDF2_ITERATIONS}$${b64(salt)}$${b64(new Uint8Array(bits))}`;
|
|
97
|
+
const bits = await deriveBits(password, salt, PBKDF2_ITERATIONS, PBKDF2_HASH);
|
|
98
|
+
return `pbkdf2$sha256$${PBKDF2_ITERATIONS}$${b64(salt)}$${b64(bits)}`;
|
|
60
99
|
}
|
|
61
100
|
/** Constant-time string compare (avoids leaking the hash via timing). */
|
|
62
101
|
function constantTimeEqual(a, b) {
|
|
@@ -119,9 +158,8 @@ export async function verifyPassword(password, stored) {
|
|
|
119
158
|
const parsed = parseStoredHash(stored);
|
|
120
159
|
if (!parsed)
|
|
121
160
|
return false;
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
return constantTimeEqual(b64(new Uint8Array(bits)), parsed.hashB64);
|
|
161
|
+
const bits = await deriveBits(password, unb64(parsed.saltB64), parsed.iterations, parsed.hash);
|
|
162
|
+
return constantTimeEqual(b64(bits), parsed.hashB64);
|
|
125
163
|
}
|
|
126
164
|
// A fixed placeholder hash (current params), computed once and reused, so a login for a
|
|
127
165
|
// NON-EXISTENT username can still run a full PBKDF2 verify. That equalizes the timing of
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/auth",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.44",
|
|
4
4
|
"description": "Optional credential→JWT login for pramen — signup/login/me + PBKDF2 hashing, issuing HS256 tokens the pramen verifier accepts.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,6 +34,6 @@
|
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@pramen/server": "0.0.
|
|
37
|
+
"@pramen/server": "0.0.44"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/index.ts
CHANGED
|
@@ -52,22 +52,61 @@ const b64urlStr = (s: string) => b64url(enc(s));
|
|
|
52
52
|
|
|
53
53
|
// --- password hashing (PBKDF2-SHA256) ---
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
/** workerd's hard ceiling on PBKDF2 iterations.
|
|
56
|
+
*
|
|
57
|
+
* It does not clamp — it throws:
|
|
58
|
+
*
|
|
59
|
+
* NotSupportedError: Pbkdf2 failed: iteration counts above 100000 are not
|
|
60
|
+
* supported (requested 600000).
|
|
61
|
+
*
|
|
62
|
+
* Bun, Node and browsers have no such cap, so anything above this passes every local
|
|
63
|
+
* test — including under lopata, which is a Bun runtime — and then fails on the first
|
|
64
|
+
* real deployment. This value is the platform limit, not a tuning knob. */
|
|
65
|
+
const PBKDF2_MAX_ITERATIONS = 100_000;
|
|
66
|
+
|
|
67
|
+
/** Iterations for NEW hashes.
|
|
68
|
+
*
|
|
69
|
+
* OWASP 2026 guidance for PBKDF2-HMAC-SHA256 is ~600k, and this was 600k until it
|
|
70
|
+
* turned out that every signup and login 500s on Workers (see PBKDF2_MAX_ITERATIONS).
|
|
71
|
+
* pramen deploys to workerd, so the platform ceiling is the real bound: WebCrypto there
|
|
72
|
+
* offers no scrypt or argon2 either, making 100k the strongest KDF available.
|
|
73
|
+
*
|
|
74
|
+
* The count (and hash alg) are ENCODED in the stored string —
|
|
75
|
+
* `pbkdf2$sha256$<iters>$<saltB64>$<hashB64>` — and verifyPassword parses them back
|
|
76
|
+
* out, so changing this never breaks verification of an already-stored hash, as long
|
|
77
|
+
* as the stored count is itself within the cap. */
|
|
78
|
+
const PBKDF2_ITERATIONS = Math.min(600_000, PBKDF2_MAX_ITERATIONS);
|
|
60
79
|
const PBKDF2_HASH = "SHA-256";
|
|
61
80
|
|
|
81
|
+
/** Derive 256 PBKDF2 bits, turning workerd's iteration-cap rejection into a diagnosis.
|
|
82
|
+
*
|
|
83
|
+
* Hashing can no longer request too many, but VERIFY takes its count from the stored
|
|
84
|
+
* hash — so a row written at 600k by pramen <= 0.0.43, or by a Bun/Node-only
|
|
85
|
+
* deployment sharing a database with a Worker, cannot be verified on Workers at all.
|
|
86
|
+
* That would otherwise surface as `NotSupportedError` from deep inside WebCrypto, or —
|
|
87
|
+
* worse, if a caller swallowed it — as a plain "wrong password" locking the account
|
|
88
|
+
* out with nothing in the logs to explain why. */
|
|
89
|
+
async function deriveBits(password: string, salt: Uint8Array, iterations: number, hash: string): Promise<Uint8Array> {
|
|
90
|
+
const key = await crypto.subtle.importKey("raw", enc(password), "PBKDF2", false, ["deriveBits"]);
|
|
91
|
+
try {
|
|
92
|
+
return new Uint8Array(await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations, hash }, key, 256));
|
|
93
|
+
} catch (cause) {
|
|
94
|
+
if (iterations > PBKDF2_MAX_ITERATIONS) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`password hash uses ${iterations} PBKDF2 iterations, above the ${PBKDF2_MAX_ITERATIONS} this runtime allows. `
|
|
97
|
+
+ `It was written by pramen <= 0.0.43 or on a runtime without the cap (Bun/Node), and cannot be verified here. `
|
|
98
|
+
+ `Reset the affected passwords, or re-import the rows under a foreign scheme (see registerPasswordVerifier).`,
|
|
99
|
+
{ cause },
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
throw cause;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
62
106
|
export async function hashPassword(password: string): Promise<string> {
|
|
63
107
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
{ name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: PBKDF2_HASH },
|
|
67
|
-
key,
|
|
68
|
-
256,
|
|
69
|
-
);
|
|
70
|
-
return `pbkdf2$sha256$${PBKDF2_ITERATIONS}$${b64(salt)}$${b64(new Uint8Array(bits))}`;
|
|
108
|
+
const bits = await deriveBits(password, salt, PBKDF2_ITERATIONS, PBKDF2_HASH);
|
|
109
|
+
return `pbkdf2$sha256$${PBKDF2_ITERATIONS}$${b64(salt)}$${b64(bits)}`;
|
|
71
110
|
}
|
|
72
111
|
|
|
73
112
|
/** Constant-time string compare (avoids leaking the hash via timing). */
|
|
@@ -154,13 +193,8 @@ export async function verifyPassword(password: string, stored: string): Promise<
|
|
|
154
193
|
}
|
|
155
194
|
const parsed = parseStoredHash(stored);
|
|
156
195
|
if (!parsed) return false;
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
{ name: "PBKDF2", salt: unb64(parsed.saltB64), iterations: parsed.iterations, hash: parsed.hash },
|
|
160
|
-
key,
|
|
161
|
-
256,
|
|
162
|
-
);
|
|
163
|
-
return constantTimeEqual(b64(new Uint8Array(bits)), parsed.hashB64);
|
|
196
|
+
const bits = await deriveBits(password, unb64(parsed.saltB64), parsed.iterations, parsed.hash);
|
|
197
|
+
return constantTimeEqual(b64(bits), parsed.hashB64);
|
|
164
198
|
}
|
|
165
199
|
|
|
166
200
|
// A fixed placeholder hash (current params), computed once and reused, so a login for a
|