@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.
Files changed (3) hide show
  1. package/dist/index.js +49 -11
  2. package/package.json +2 -2
  3. 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
- // OWASP 2026 guidance for PBKDF2-HMAC-SHA256 is ~600k iterations. The iteration
50
- // count (and hash alg) are ENCODED in the stored string — `pbkdf2$sha256$<iters>$
51
- // <saltB64>$<hashB64>` and verifyPassword parses them from the stored hash, so a
52
- // future bump here keeps verifying older hashes; only NEW hashes use the new count.
53
- const PBKDF2_ITERATIONS = 600_000;
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 key = await crypto.subtle.importKey("raw", enc(password), "PBKDF2", false, ["deriveBits"]);
58
- const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: PBKDF2_HASH }, key, 256);
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 key = await crypto.subtle.importKey("raw", enc(password), "PBKDF2", false, ["deriveBits"]);
123
- const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt: unb64(parsed.saltB64), iterations: parsed.iterations, hash: parsed.hash }, key, 256);
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.42",
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.42"
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
- // OWASP 2026 guidance for PBKDF2-HMAC-SHA256 is ~600k iterations. The iteration
56
- // count (and hash alg) are ENCODED in the stored string — `pbkdf2$sha256$<iters>$
57
- // <saltB64>$<hashB64>` and verifyPassword parses them from the stored hash, so a
58
- // future bump here keeps verifying older hashes; only NEW hashes use the new count.
59
- const PBKDF2_ITERATIONS = 600_000;
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 key = await crypto.subtle.importKey("raw", enc(password), "PBKDF2", false, ["deriveBits"]);
65
- const bits = await crypto.subtle.deriveBits(
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 key = await crypto.subtle.importKey("raw", enc(password), "PBKDF2", false, ["deriveBits"]);
158
- const bits = await crypto.subtle.deriveBits(
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