@pramen/auth 0.0.13 → 0.0.15

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 +60 -10
  2. package/package.json +2 -2
  3. package/src/index.ts +61 -10
package/dist/index.js CHANGED
@@ -45,12 +45,17 @@ function unb64(s) {
45
45
  const b64url = (bytes) => b64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
46
46
  const b64urlStr = (s) => b64url(enc(s));
47
47
  // --- password hashing (PBKDF2-SHA256) ---
48
- const PBKDF2_ITERATIONS = 100_000;
48
+ // OWASP 2026 guidance for PBKDF2-HMAC-SHA256 is ~600k iterations. The iteration
49
+ // count (and hash alg) are ENCODED in the stored string — `pbkdf2$sha256$<iters>$
50
+ // <saltB64>$<hashB64>` — and verifyPassword parses them from the stored hash, so a
51
+ // future bump here keeps verifying older hashes; only NEW hashes use the new count.
52
+ const PBKDF2_ITERATIONS = 600_000;
53
+ const PBKDF2_HASH = "SHA-256";
49
54
  export async function hashPassword(password) {
50
55
  const salt = crypto.getRandomValues(new Uint8Array(16));
51
56
  const key = await crypto.subtle.importKey("raw", enc(password), "PBKDF2", false, ["deriveBits"]);
52
- const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" }, key, 256);
53
- return `pbkdf2$${PBKDF2_ITERATIONS}$${b64(salt)}$${b64(new Uint8Array(bits))}`;
57
+ const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: PBKDF2_HASH }, key, 256);
58
+ return `pbkdf2$sha256$${PBKDF2_ITERATIONS}$${b64(salt)}$${b64(new Uint8Array(bits))}`;
54
59
  }
55
60
  /** Constant-time string compare (avoids leaking the hash via timing). */
56
61
  function constantTimeEqual(a, b) {
@@ -61,13 +66,38 @@ function constantTimeEqual(a, b) {
61
66
  diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
62
67
  return diff === 0;
63
68
  }
69
+ /** Parse the self-describing hash string. Supports the current
70
+ * `pbkdf2$sha256$<iters>$<salt>$<hash>` form and the legacy `pbkdf2$<iters>$<salt>$<hash>`
71
+ * (no alg segment). Iterations come FROM the stored string, so raising PBKDF2_ITERATIONS
72
+ * never breaks verification of an already-stored hash. */
73
+ function parseStoredHash(stored) {
74
+ const parts = stored.split("$");
75
+ if (parts[0] !== "pbkdf2")
76
+ return null;
77
+ // 5 parts: pbkdf2 $ sha256 $ iters $ salt $ hash (current)
78
+ // 4 parts: pbkdf2 $ iters $ salt $ hash (legacy — implicit sha256)
79
+ const [algSeg, iterStr, saltB64, hashB64] = parts.length === 5 ? parts.slice(1) : ["sha256", ...parts.slice(1)];
80
+ const iterations = Number(iterStr);
81
+ if (!saltB64 || !hashB64 || !Number.isFinite(iterations) || iterations <= 0)
82
+ return null;
83
+ const hash = algSeg === "sha512" ? "SHA-512" : "SHA-256";
84
+ return { iterations, hash, saltB64, hashB64 };
85
+ }
64
86
  export async function verifyPassword(password, stored) {
65
- const [scheme, iterStr, saltB64, hashB64] = stored.split("$");
66
- if (scheme !== "pbkdf2" || !saltB64 || !hashB64)
87
+ const parsed = parseStoredHash(stored);
88
+ if (!parsed)
67
89
  return false;
68
90
  const key = await crypto.subtle.importKey("raw", enc(password), "PBKDF2", false, ["deriveBits"]);
69
- const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt: unb64(saltB64), iterations: Number(iterStr), hash: "SHA-256" }, key, 256);
70
- return constantTimeEqual(b64(new Uint8Array(bits)), hashB64);
91
+ const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt: unb64(parsed.saltB64), iterations: parsed.iterations, hash: parsed.hash }, key, 256);
92
+ return constantTimeEqual(b64(new Uint8Array(bits)), parsed.hashB64);
93
+ }
94
+ // A fixed placeholder hash (current params), computed once and reused, so a login for a
95
+ // NON-EXISTENT username can still run a full PBKDF2 verify. That equalizes the timing of
96
+ // the "no such user" and "wrong password" paths — neither short-circuits — closing the
97
+ // user-existence timing oracle. Lazily initialized (top-level await isn't available here).
98
+ let dummyHashPromise;
99
+ function dummyPasswordHash() {
100
+ return (dummyHashPromise ??= hashPassword("pramen-login-timing-equalizer-placeholder"));
71
101
  }
72
102
  // --- HS256 token signing (matches the verifier in @pramen/server auth.ts) ---
73
103
  export async function signToken(claims, secret, opts = {}) {
@@ -105,19 +135,39 @@ function parseCreds(raw) {
105
135
  /** signup / login / me. Roles are assigned server-side (default `["user"]`) — the
106
136
  * client never picks its own roles. Spread into your handler map. */
107
137
  export const authHandlers = {
138
+ // NOTE on username enumeration: signup returns a distinct "username is taken" error,
139
+ // which is an enumeration oracle. This is INHERENT to systems where the username is a
140
+ // user-chosen, publicly-visible identifier — the caller learns "taken" the moment the
141
+ // name shows up anywhere, so hiding it at signup buys little. What we CAN close is the
142
+ // timing side channel: both the taken and the available paths run the same expensive
143
+ // PBKDF2 hash before responding, so response time doesn't leak which path was taken.
144
+ // The enumeration-SAFE flow is the passwordless / magic-link path (createMagicLinkAuth),
145
+ // which keys on the email and always returns the same `{ ok: true }`.
108
146
  signup: mutation(async (ctx, input) => {
109
147
  const existing = await ctx.db.exec("SELECT 1 FROM auth_users WHERE username = ? LIMIT 1", input.username);
110
- if (existing.length > 0)
148
+ if (existing.length > 0) {
149
+ // Equalize timing with the available path (which hashes below) so the taken vs.
150
+ // available decision isn't a fast timing oracle on top of the response-body one.
151
+ await hashPassword(input.password);
111
152
  throw new BadRequest("username is taken");
153
+ }
112
154
  const roles = DEFAULT_ROLES;
113
- await ctx.db.exec("INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)", input.username, await hashPassword(input.password), JSON.stringify(roles), Date.now());
155
+ const passwordHash = await hashPassword(input.password);
156
+ await ctx.db.exec("INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)", input.username, passwordHash, JSON.stringify(roles), Date.now());
114
157
  const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
115
158
  return { token, user: { username: input.username, roles } };
116
159
  }, { input: parseCreds }),
117
160
  login: mutation(async (ctx, input) => {
118
161
  const rows = await ctx.db.exec("SELECT username, passwordHash, roles, active FROM auth_users WHERE username = ? LIMIT 1", input.username);
119
162
  const u = rows[0];
120
- if (!u || !(await verifyPassword(input.password, String(u.passwordHash)))) {
163
+ if (!u) {
164
+ // No such user: still run a full PBKDF2 verify against a fixed dummy hash so the
165
+ // not-found path costs the same as a wrong-password path — no timing oracle that
166
+ // distinguishes "unknown username" from "bad password".
167
+ await verifyPassword(input.password, await dummyPasswordHash());
168
+ throw new Unauthorized("invalid username or password");
169
+ }
170
+ if (!(await verifyPassword(input.password, String(u.passwordHash)))) {
121
171
  throw new Unauthorized("invalid username or password");
122
172
  }
123
173
  // Only after the password verifies (so this can't enumerate accounts): a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/auth",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
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.13"
37
+ "@pramen/server": "0.0.15"
38
38
  }
39
39
  }
package/src/index.ts CHANGED
@@ -51,17 +51,22 @@ const b64urlStr = (s: string) => b64url(enc(s));
51
51
 
52
52
  // --- password hashing (PBKDF2-SHA256) ---
53
53
 
54
- const PBKDF2_ITERATIONS = 100_000;
54
+ // OWASP 2026 guidance for PBKDF2-HMAC-SHA256 is ~600k iterations. The iteration
55
+ // count (and hash alg) are ENCODED in the stored string — `pbkdf2$sha256$<iters>$
56
+ // <saltB64>$<hashB64>` — and verifyPassword parses them from the stored hash, so a
57
+ // future bump here keeps verifying older hashes; only NEW hashes use the new count.
58
+ const PBKDF2_ITERATIONS = 600_000;
59
+ const PBKDF2_HASH = "SHA-256";
55
60
 
56
61
  export async function hashPassword(password: string): Promise<string> {
57
62
  const salt = crypto.getRandomValues(new Uint8Array(16));
58
63
  const key = await crypto.subtle.importKey("raw", enc(password), "PBKDF2", false, ["deriveBits"]);
59
64
  const bits = await crypto.subtle.deriveBits(
60
- { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
65
+ { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: PBKDF2_HASH },
61
66
  key,
62
67
  256,
63
68
  );
64
- return `pbkdf2$${PBKDF2_ITERATIONS}$${b64(salt)}$${b64(new Uint8Array(bits))}`;
69
+ return `pbkdf2$sha256$${PBKDF2_ITERATIONS}$${b64(salt)}$${b64(new Uint8Array(bits))}`;
65
70
  }
66
71
 
67
72
  /** Constant-time string compare (avoids leaking the hash via timing). */
@@ -72,16 +77,41 @@ function constantTimeEqual(a: string, b: string): boolean {
72
77
  return diff === 0;
73
78
  }
74
79
 
80
+ /** Parse the self-describing hash string. Supports the current
81
+ * `pbkdf2$sha256$<iters>$<salt>$<hash>` form and the legacy `pbkdf2$<iters>$<salt>$<hash>`
82
+ * (no alg segment). Iterations come FROM the stored string, so raising PBKDF2_ITERATIONS
83
+ * never breaks verification of an already-stored hash. */
84
+ function parseStoredHash(stored: string): { iterations: number; hash: string; saltB64: string; hashB64: string } | null {
85
+ const parts = stored.split("$");
86
+ if (parts[0] !== "pbkdf2") return null;
87
+ // 5 parts: pbkdf2 $ sha256 $ iters $ salt $ hash (current)
88
+ // 4 parts: pbkdf2 $ iters $ salt $ hash (legacy — implicit sha256)
89
+ const [algSeg, iterStr, saltB64, hashB64] = parts.length === 5 ? parts.slice(1) : ["sha256", ...parts.slice(1)];
90
+ const iterations = Number(iterStr);
91
+ if (!saltB64 || !hashB64 || !Number.isFinite(iterations) || iterations <= 0) return null;
92
+ const hash = algSeg === "sha512" ? "SHA-512" : "SHA-256";
93
+ return { iterations, hash, saltB64, hashB64 };
94
+ }
95
+
75
96
  export async function verifyPassword(password: string, stored: string): Promise<boolean> {
76
- const [scheme, iterStr, saltB64, hashB64] = stored.split("$");
77
- if (scheme !== "pbkdf2" || !saltB64 || !hashB64) return false;
97
+ const parsed = parseStoredHash(stored);
98
+ if (!parsed) return false;
78
99
  const key = await crypto.subtle.importKey("raw", enc(password), "PBKDF2", false, ["deriveBits"]);
79
100
  const bits = await crypto.subtle.deriveBits(
80
- { name: "PBKDF2", salt: unb64(saltB64), iterations: Number(iterStr), hash: "SHA-256" },
101
+ { name: "PBKDF2", salt: unb64(parsed.saltB64), iterations: parsed.iterations, hash: parsed.hash },
81
102
  key,
82
103
  256,
83
104
  );
84
- return constantTimeEqual(b64(new Uint8Array(bits)), hashB64);
105
+ return constantTimeEqual(b64(new Uint8Array(bits)), parsed.hashB64);
106
+ }
107
+
108
+ // A fixed placeholder hash (current params), computed once and reused, so a login for a
109
+ // NON-EXISTENT username can still run a full PBKDF2 verify. That equalizes the timing of
110
+ // the "no such user" and "wrong password" paths — neither short-circuits — closing the
111
+ // user-existence timing oracle. Lazily initialized (top-level await isn't available here).
112
+ let dummyHashPromise: Promise<string> | undefined;
113
+ function dummyPasswordHash(): Promise<string> {
114
+ return (dummyHashPromise ??= hashPassword("pramen-login-timing-equalizer-placeholder"));
85
115
  }
86
116
 
87
117
  // --- HS256 token signing (matches the verifier in @pramen/server auth.ts) ---
@@ -128,15 +158,29 @@ function parseCreds(raw: unknown): { username: string; password: string } {
128
158
  /** signup / login / me. Roles are assigned server-side (default `["user"]`) — the
129
159
  * client never picks its own roles. Spread into your handler map. */
130
160
  export const authHandlers = {
161
+ // NOTE on username enumeration: signup returns a distinct "username is taken" error,
162
+ // which is an enumeration oracle. This is INHERENT to systems where the username is a
163
+ // user-chosen, publicly-visible identifier — the caller learns "taken" the moment the
164
+ // name shows up anywhere, so hiding it at signup buys little. What we CAN close is the
165
+ // timing side channel: both the taken and the available paths run the same expensive
166
+ // PBKDF2 hash before responding, so response time doesn't leak which path was taken.
167
+ // The enumeration-SAFE flow is the passwordless / magic-link path (createMagicLinkAuth),
168
+ // which keys on the email and always returns the same `{ ok: true }`.
131
169
  signup: mutation(
132
170
  async (ctx, input: { username: string; password: string }) => {
133
171
  const existing = await ctx.db.exec("SELECT 1 FROM auth_users WHERE username = ? LIMIT 1", input.username);
134
- if (existing.length > 0) throw new BadRequest("username is taken");
172
+ if (existing.length > 0) {
173
+ // Equalize timing with the available path (which hashes below) so the taken vs.
174
+ // available decision isn't a fast timing oracle on top of the response-body one.
175
+ await hashPassword(input.password);
176
+ throw new BadRequest("username is taken");
177
+ }
135
178
  const roles = DEFAULT_ROLES;
179
+ const passwordHash = await hashPassword(input.password);
136
180
  await ctx.db.exec(
137
181
  "INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)",
138
182
  input.username,
139
- await hashPassword(input.password),
183
+ passwordHash,
140
184
  JSON.stringify(roles),
141
185
  Date.now(),
142
186
  );
@@ -153,7 +197,14 @@ export const authHandlers = {
153
197
  input.username,
154
198
  );
155
199
  const u = rows[0];
156
- if (!u || !(await verifyPassword(input.password, String(u.passwordHash)))) {
200
+ if (!u) {
201
+ // No such user: still run a full PBKDF2 verify against a fixed dummy hash so the
202
+ // not-found path costs the same as a wrong-password path — no timing oracle that
203
+ // distinguishes "unknown username" from "bad password".
204
+ await verifyPassword(input.password, await dummyPasswordHash());
205
+ throw new Unauthorized("invalid username or password");
206
+ }
207
+ if (!(await verifyPassword(input.password, String(u.passwordHash)))) {
157
208
  throw new Unauthorized("invalid username or password");
158
209
  }
159
210
  // Only after the password verifies (so this can't enumerate accounts): a