Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/appFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ function mountAuthAndVault(
? {
login: rateLimiters.disabled(),
mfaLogin: rateLimiters.disabled(),
verifyPassword: rateLimiters.disabled(),
register: rateLimiters.disabled(),
verifyEmail: rateLimiters.disabled(),
vote: rateLimiters.disabled(),
Expand All @@ -186,6 +187,7 @@ function mountAuthAndVault(
: {
login: rateLimiters.loginLimiter(),
mfaLogin: rateLimiters.mfaLoginLimiter(),
verifyPassword: rateLimiters.verifyPasswordLimiter(),
register: rateLimiters.registerLimiter(),
verifyEmail: rateLimiters.verifyEmailLimiter(),
vote: rateLimiters.voteLimiter(),
Expand Down
26 changes: 26 additions & 0 deletions middleware/rateLimit.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ function mfaLoginKey(req) {
return `login-totp|${ipBucket(req)}`;
}

// Per-session bucket for authenticated password re-checks. This endpoint is
// a credential oracle only after an attacker already has a live session+CSRF
// pair; keying by session contains abuse to that stolen session instead of
// letting it burn the whole user's account budget. The IP fallback keeps the
// limiter safe if a future caller mounts it in the wrong order.
function verifyPasswordKey(req) {
const sessionId =
req.session && req.session.id != null ? String(req.session.id) : null;
if (sessionId) return `verify-password|s${sessionId}`;
return `verify-password|ip|${ipBucket(req)}`;
}

function registerKey(req) {
return `register|${ipBucket(req)}`;
}
Expand Down Expand Up @@ -117,6 +129,18 @@ function mfaLoginLimiter() {
});
}

function verifyPasswordLimiter() {
return rateLimit({
windowMs: 15 * MINUTE,
max: 10,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: verifyPasswordKey,
message: { error: 'too_many_attempts' },
handler: trippedHandler('verify-password'),
});
}

function registerLimiter() {
return rateLimit({
windowMs: 60 * MINUTE,
Expand Down Expand Up @@ -182,6 +206,7 @@ function disabled() {
module.exports = {
loginLimiter,
mfaLoginLimiter,
verifyPasswordLimiter,
registerLimiter,
verifyEmailLimiter,
voteLimiter,
Expand All @@ -190,6 +215,7 @@ module.exports = {
// Exported for direct unit testing.
loginKey,
mfaLoginKey,
verifyPasswordKey,
registerKey,
verifyEmailKey,
voteKey,
Expand Down
25 changes: 25 additions & 0 deletions middleware/rateLimit.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const {
loginKey,
mfaLoginKey,
verifyPasswordKey,
registerKey,
reconcileKey,
voteKey,
Expand Down Expand Up @@ -70,6 +71,30 @@ describe('rate-limit key generators', () => {
});
});

describe('verifyPasswordKey', () => {
function mkSession(body, ip, session) {
return { ...mk(body, ip, { id: 42 }), session };
}

test('buckets by authenticated session.id when present', () => {
const a = verifyPasswordKey(mkSession({}, '1.2.3.4', { id: 100 }));
const b = verifyPasswordKey(mkSession({}, '9.9.9.9', { id: 100 }));
expect(a).toBe(b);
expect(a).toBe('verify-password|s100');
});

test('two sessions for the same user get distinct password-check buckets', () => {
const a = verifyPasswordKey(mkSession({}, '1.2.3.4', { id: 100 }));
const b = verifyPasswordKey(mkSession({}, '1.2.3.4', { id: 200 }));
expect(a).not.toBe(b);
});

test('falls back to IP bucket when the user is missing', () => {
const a = verifyPasswordKey(mk({}, '1.2.3.4'));
expect(a).toBe('verify-password|ip|1.2.3.4');
});
});

describe('registerKey', () => {
test('is scoped per IPv4 only', () => {
const a = registerKey(mk({ email: 'a@b.com' }, '1.2.3.4'));
Expand Down
66 changes: 66 additions & 0 deletions routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ const VerifySchema = z.object({
token: z.string().regex(/^[0-9a-f]{64}$/),
});

// Body schema for /auth/verify-password — a re-prove-password ceremony with
// no side effects. Used by callers that need to bind a client-side derived
// secret to the account credential before they act on it (e.g. the vault
// first-write flow, where the same password derives both authHash AND the
// client-only vaultKey, and an unverified mismatch would silently lock the
// vault under a key that diverges from the account credential).
const VerifyPasswordSchema = z.object({
authHash: HEX_32_SCHEMA,
});

// PR 7 — account deletion (GDPR "right to erasure").
//
// Requires the user to re-prove possession of the current password
Expand Down Expand Up @@ -110,6 +120,10 @@ function asyncHandler(fn) {
Promise.resolve(fn(req, res, next)).catch(next);
}

function noopMiddleware(_req, _res, next) {
next();
}

function createAuthRouter({
users,
sessions,
Expand All @@ -128,6 +142,13 @@ function createAuthRouter({
if (typeof runAtomic !== 'function') {
throw new Error('createAuthRouter: runAtomic is required');
}
const effectiveLimiters = {
...limiters,
verifyPassword:
limiters && typeof limiters.verifyPassword === 'function'
? limiters.verifyPassword
: noopMiddleware,
};
// `vaults` is optional in principle (some test harnesses mount auth
// alone without a vault store), but /auth/change-password refuses to
// serve when it is missing and the caller requests a vault-bearing
Expand Down Expand Up @@ -890,6 +911,51 @@ function createAuthRouter({
})
);

// -------------------------------------------------------------------------
// POST /auth/verify-password
//
// Read-only credential check. Caller submits an authHash derived from
// a typed password (same client-side PBKDF2 → HKDF chain as /login,
// /change-password, /totp/setup), and the server confirms it matches
// the stored credential for the authenticated user.
//
// 204 on match, 401 on mismatch, 400 on malformed body, 503 on KDF
// misconfiguration. The handler never mutates server state — no
// session rotation, no counter bump, nothing — so it's safe to call
// as a precondition step inside other client flows without disturbing
// the live session.
//
// Specifically motivated by the vault first-write path: the same
// password is fed into both `deriveAuthHash` (compared here) and
// `deriveVaultKey` (used client-side only). Verifying authHash
// against the server before saving the encrypted blob guarantees
// the vaultKey that wraps the blob is consistent with the account
// credential. Without this check a typo at first import diverges the
// two and locks the vault to a credential the user no longer
// remembers — a bug we hit in practice (Apr 2026).
// -------------------------------------------------------------------------
router.post(
'/verify-password',
sessionMw.requireAuth,
csrfMw.require,
effectiveLimiters.verifyPassword,
(req, res) => {
Comment on lines +938 to +942
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add rate limiting to verify-password endpoint

POST /auth/verify-password is wired with only requireAuth and CSRF checks, so it exposes an unthrottled 401/204 credential oracle (verifyPasswordStepUp) for any actor who obtains a live session+CSRF token (for example via session theft or XSS). Unlike /auth/change-password, successful guesses here have no user-visible side effect (no password rotation email/session churn), which makes password discovery significantly stealthier and easier to automate; this endpoint should use a limiter (ideally per user/session) similar to other auth-sensitive probes.

Useful? React with 👍 / 👎.

const parsed = VerifyPasswordSchema.safeParse(req.body);
if (!parsed.success) return badRequest(res, 'invalid_body');
if (
!verifyPasswordStepUp(
req,
res,
parsed.data.authHash,
'auth.verify_password'
)
) {
return undefined;
}
return res.status(204).end();
}
);

// -------------------------------------------------------------------------
// DELETE /auth/account
//
Expand Down
132 changes: 131 additions & 1 deletion tests/auth.routes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,119 @@ describe('auth routes', () => {
expect(res.status).toBe(401);
});

// ---------------------------------------------------------------------
// POST /auth/verify-password — read-only "is this the user's current
// password?" probe. Used by callers that need to bind a client-side
// derivation to the account credential before acting on it (vault
// first-write being the motivating case). MUST be a no-op on success
// (204) and not rotate sessions / counters / anything.
// ---------------------------------------------------------------------

describe('POST /auth/verify-password', () => {
test('204 on matching authHash; the session and the stored credential are untouched', async () => {
const agent = request.agent(ctx.app);
await agent
.post('/auth/register')
.send({ email: 'user@example.com', authHash: SAMPLE_AUTH });
const token = ctx.mailer.outbox[0].html.match(/token=([0-9a-f]{64})/)[1];
await agent.post('/auth/verify-email').send({ token });
const loginRes = await agent
.post('/auth/login')
.send({ email: 'user@example.com', authHash: SAMPLE_AUTH });
const csrf = extractCookies(loginRes).csrf;

const res = await agent
.post('/auth/verify-password')
.set('X-CSRF-Token', csrf)
.send({ authHash: SAMPLE_AUTH });
expect(res.status).toBe(204);
// 204 → no body.
expect(res.text).toBe('');

// The current session is still usable (verify did not log us out).
const me = await agent.get('/auth/me');
expect(me.status).toBe(200);

// And the credential itself was not rotated by the call — a fresh
// login with the same authHash still succeeds.
const fresh = await request(ctx.app)
.post('/auth/login')
.send({ email: 'user@example.com', authHash: SAMPLE_AUTH });
expect(fresh.status).toBe(200);
});

test('401 on mismatching authHash; subsequent login with the real password still works', async () => {
const agent = request.agent(ctx.app);
await agent
.post('/auth/register')
.send({ email: 'user@example.com', authHash: SAMPLE_AUTH });
const token = ctx.mailer.outbox[0].html.match(/token=([0-9a-f]{64})/)[1];
await agent.post('/auth/verify-email').send({ token });
const loginRes = await agent
.post('/auth/login')
.send({ email: 'user@example.com', authHash: SAMPLE_AUTH });
const csrf = extractCookies(loginRes).csrf;

const bad = await agent
.post('/auth/verify-password')
.set('X-CSRF-Token', csrf)
.send({ authHash: 'deadbeef'.repeat(8) });
expect(bad.status).toBe(401);
expect(bad.body.error).toBe('invalid_credentials');

// Re-prove the credential really wasn't rotated.
const fresh = await request(ctx.app)
.post('/auth/login')
.send({ email: 'user@example.com', authHash: SAMPLE_AUTH });
expect(fresh.status).toBe(200);
});

test('400 on malformed body', async () => {
const agent = request.agent(ctx.app);
await agent
.post('/auth/register')
.send({ email: 'user@example.com', authHash: SAMPLE_AUTH });
const token = ctx.mailer.outbox[0].html.match(/token=([0-9a-f]{64})/)[1];
await agent.post('/auth/verify-email').send({ token });
const loginRes = await agent
.post('/auth/login')
.send({ email: 'user@example.com', authHash: SAMPLE_AUTH });
const csrf = extractCookies(loginRes).csrf;

const res = await agent
.post('/auth/verify-password')
.set('X-CSRF-Token', csrf)
.send({ authHash: 'not-hex' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid_body');
});

test('401 when unauthenticated', async () => {
const res = await request(ctx.app)
.post('/auth/verify-password')
.send({ authHash: SAMPLE_AUTH });
expect(res.status).toBe(401);
});

test('403 when CSRF header is missing', async () => {
const agent = request.agent(ctx.app);
await agent
.post('/auth/register')
.send({ email: 'user@example.com', authHash: SAMPLE_AUTH });
const token = ctx.mailer.outbox[0].html.match(/token=([0-9a-f]{64})/)[1];
await agent.post('/auth/verify-email').send({ token });
await agent
.post('/auth/login')
.send({ email: 'user@example.com', authHash: SAMPLE_AUTH });

const res = await agent
.post('/auth/verify-password')
.send({ authHash: SAMPLE_AUTH });
expect(res.status).toBe(403);
expect(res.body.error).toBe('csrf_missing');
});
});

// ---------------------------------------------------------------------
// PR 7 — atomic vault rewrap inside /auth/change-password
// ---------------------------------------------------------------------
Expand Down Expand Up @@ -1463,7 +1576,7 @@ describe('createAuthRouter factory contract (Codex round-2 P3)', () => {
},
sessionMw: { requireAuth: mw, parse: mw, setSessionCookie: noop, clearSessionCookie: noop },
csrfMw: { require: mw, parse: mw, issueCookie: noop, clearCookie: noop },
limiters: { login: mw, mfaLogin: mw, register: mw, verifyEmail: mw, vote: mw },
limiters: { login: mw, mfaLogin: mw, verifyPassword: mw, register: mw, verifyEmail: mw, vote: mw },
baseUrl: 'http://api.test',
frontendUrl: 'http://app.test',
scheduler: (fn) => fn(),
Expand Down Expand Up @@ -1498,6 +1611,23 @@ describe('createAuthRouter factory contract (Codex round-2 P3)', () => {
expect(() => createAuthRouter(buildArgs({ vaults: undefined }))).not.toThrow();
});

test('accepts previous limiter shape without verifyPassword', () => {
const mw = (_req, _res, next) => next();
expect(() =>
createAuthRouter(
buildArgs({
limiters: {
login: mw,
mfaLogin: mw,
register: mw,
verifyEmail: mw,
vote: mw,
},
})
)
).not.toThrow();
});

test('still rejects missing runAtomic regardless of vaults', () => {
expect(() =>
createAuthRouter(buildArgs({ vaults: undefined, runAtomic: undefined }))
Expand Down
2 changes: 2 additions & 0 deletions tests/govProposals.routes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ function buildApp({
limiters: {
login: rateLimiters.disabled(),
mfaLogin: rateLimiters.disabled(),
verifyPassword: rateLimiters.disabled(),
register: rateLimiters.disabled(),
verifyEmail: rateLimiters.disabled(),
vote: rateLimiters.disabled(),
Expand Down Expand Up @@ -571,6 +572,7 @@ describe('drafts CRUD', () => {
limiters: {
login: rateLimiters.disabled(),
mfaLogin: rateLimiters.disabled(),
verifyPassword: rateLimiters.disabled(),
register: rateLimiters.disabled(),
verifyEmail: rateLimiters.disabled(),
vote: rateLimiters.disabled(),
Expand Down
Loading