@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.
- package/dist/index.js +60 -10
- package/package.json +2 -2
- 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
|
-
|
|
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:
|
|
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
|
|
66
|
-
if (
|
|
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:
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
|
77
|
-
if (
|
|
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:
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|