Skip to content

Commit 795a7b0

Browse files
ralyodioclaude
andauthored
Key backup from Settings works for every login; 4-digit PINs behind a server-side lockout (0.5.0) (#283)
* Key backup: works from Settings for every login, 4-digit PINs behind a server-side lockout - Settings, restore and key reset send the Bearer session (passkey, CoinPay and phone sessions have no cookie, so backup silently 401'd). - PINs may be 4-12 digits. GET /api/auth/key-backup no longer returns the encrypted keys; restore is POST {pin}, checked against the scrypt hash with 5 tries then 15 min, doubling to 24 h. Backups must use the account PIN. - Changing a verifiable PIN needs the current one; the server backup is re-encrypted with the new PIN. - Legacy backups (no verifiable hash) still restore; the client registers the PIN once it decrypts, upgrading them. - DELETE /api/auth/key-backup exists now (Complete Key Reset called it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Release 0.5.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6e21785 commit 795a7b0

13 files changed

Lines changed: 656 additions & 120 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "qryptchat-web",
33
"private": true,
4-
"version": "0.4.0",
4+
"version": "0.5.0",
55
"type": "module",
66
"bin": {
77
"qc": "./bin/qc.js",

‎src/app/anon/page.jsx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,8 @@ function AnonRegister() {
115115

116116
async function setBackupPinFn(e) {
117117
e?.preventDefault();
118-
if (!backupPin || !/^\d+$/.test(backupPin) || backupPin.length < 6 || backupPin.length > 12) {
119-
msgStore.error('PIN must be 6-12 digits');
118+
if (!backupPin || !/^\d{4,12}$/.test(backupPin)) {
119+
msgStore.error('PIN must be 4-12 digits');
120120
return;
121121
}
122122
if (backupPin !== confirmBackupPin) {
@@ -140,7 +140,7 @@ function AnonRegister() {
140140
const backupRes = await fetch('/api/auth/key-backup', {
141141
method: 'PUT',
142142
headers,
143-
body: JSON.stringify({ encrypted_keys: encryptedData })
143+
body: JSON.stringify({ encrypted_keys: encryptedData, pin: backupPin })
144144
});
145145
if (!backupRes.ok) {
146146
const err = await backupRes.json().catch(() => ({}));
@@ -262,14 +262,14 @@ function AnonRegister() {
262262
</div>
263263
<form onSubmit={setBackupPinFn}>
264264
<div className="input-group">
265-
<label>Backup PIN (6-12 digits) *</label>
265+
<label>Backup PIN (4-12 digits) *</label>
266266
<div className="password-input">
267267
<input
268268
type={showBackupPin ? 'text' : 'password'}
269269
inputMode="numeric"
270270
value={backupPin}
271271
onChange={(e) => setBackupPin(e.target.value.replace(/\D/g, '').slice(0, 12))}
272-
placeholder="Enter 6-12 digit PIN"
272+
placeholder="Enter 4-12 digit PIN"
273273
required
274274
disabled={submitting}
275275
className="code-input"

‎src/app/api/auth/backup-pin/route.js‎

Lines changed: 28 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,14 @@
22
// Handles setting and checking the user's backup PIN hash
33

44
import { NextResponse } from 'next/server';
5-
import { randomBytes, scrypt as scryptCallback } from 'node:crypto';
6-
import { promisify } from 'node:util';
75
import { createClient } from '@supabase/supabase-js';
86
import { supabaseAuthCookieName } from '@/lib/supabase/auth-cookie.js';
97
import { createServiceRoleClient } from '@/lib/supabase/service-role.js';
8+
import { checkPin, hashPin, PIN_ALGORITHM, PIN_PATTERN, PIN_RULE, resolveInternalUserId as resolveUserId } from '@/lib/auth/backup-pin.js';
109

1110
// node:crypto is required for scrypt, so pin this route to the Node runtime.
1211
export const runtime = 'nodejs';
1312

14-
const scrypt = promisify(scryptCallback);
15-
1613
let supabaseServiceRole = null;
1714
function getServiceRoleClient() {
1815
if (!supabaseServiceRole) {
@@ -102,60 +99,10 @@ async function authenticateUser(request) {
10299
}
103100
}
104101

105-
// scrypt work factors. N=2^17/r=8/p=1 is OWASP's recommended minimum; the previous
106-
// N=16384 (Node's default) cost only ~16MB per derivation, which left the 6-12 digit
107-
// PIN keyspace within reach of an offline GPU sweep. The per-user salt means there is
108-
// no shared work across users on top of that.
109-
const SCRYPT_N = 131072;
110-
const SCRYPT_R = 8;
111-
const SCRYPT_P = 1;
112-
// scrypt needs roughly 128 * N * r bytes; Node's default 32MB cap would reject N=2^17.
113-
const SCRYPT_MAXMEM = 192 * 1024 * 1024;
114-
const SCRYPT_KEYLEN = 64;
115-
const SCRYPT_SALT_BYTES = 16;
116-
export const PIN_ALGORITHM = `scrypt-n${SCRYPT_N}-r${SCRYPT_R}-p${SCRYPT_P}`;
117-
118-
/**
119-
* Derive a PIN hash using scrypt and a per-user random salt.
120-
*
121-
* Replaces the previous unsalted `crypto.subtle.digest('SHA-256', pin)`, which
122-
* a rainbow table over the numeric PIN keyspace reversed instantly once the
123-
* hash column was readable (GHSA-jpfm-vrpc-p6rr).
124-
*
125-
* @param {string} pin
126-
* @param {string} [saltHex] existing salt, hex-encoded; a new one is generated when omitted
127-
* @returns {Promise<{hash: string, salt: string, algorithm: string}>}
128-
*/
129-
async function hashPin(pin, saltHex) {
130-
const salt = saltHex ?? randomBytes(SCRYPT_SALT_BYTES).toString('hex');
131-
const derived = /** @type {Buffer} */ (
132-
await scrypt(pin, salt, SCRYPT_KEYLEN, {
133-
N: SCRYPT_N,
134-
r: SCRYPT_R,
135-
p: SCRYPT_P,
136-
maxmem: SCRYPT_MAXMEM
137-
})
138-
);
139-
return { hash: derived.toString('hex'), salt, algorithm: PIN_ALGORITHM };
140-
}
102+
export { PIN_ALGORITHM };
141103

142-
/**
143-
* Resolve the internal users.id for a Supabase Auth user.
144-
* @param {{id: string}} user
145-
* @returns {Promise<{userId?: string, error?: string}>}
146-
*/
147104
async function resolveInternalUserId(user) {
148-
const { data, error } = await getServiceRoleClient()
149-
.from('users')
150-
.select('id')
151-
.eq('auth_user_id', user.id)
152-
.single();
153-
154-
if (error || !data?.id) {
155-
return { error: error?.message ?? 'User record not found' };
156-
}
157-
158-
return { userId: data.id };
105+
return resolveUserId(getServiceRoleClient(), user);
159106
}
160107

161108
/**
@@ -214,13 +161,13 @@ export async function POST(request) {
214161
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
215162
}
216163

217-
const { pin } = body;
164+
const { pin, currentPin } = body;
218165

219-
if (!pin || typeof pin !== 'string' || pin.length < 6 || pin.length > 12) {
220-
return NextResponse.json({ error: 'PIN must be 6-12 digits' }, { status: 400 });
166+
if (!pin || typeof pin !== 'string' || pin.length < 4 || pin.length > 12) {
167+
return NextResponse.json({ error: PIN_RULE }, { status: 400 });
221168
}
222169

223-
if (!/^\d+$/.test(pin)) {
170+
if (!PIN_PATTERN.test(pin)) {
224171
return NextResponse.json({ error: 'PIN must contain only digits' }, { status: 400 });
225172
}
226173

@@ -230,6 +177,27 @@ export async function POST(request) {
230177
return NextResponse.json({ error: 'Failed to set backup PIN' }, { status: 500 });
231178
}
232179

180+
// Replacing a PIN the server can verify takes the current one. Otherwise a
181+
// stolen session could set its own PIN and walk through the restore gate.
182+
const current = await checkPin(getServiceRoleClient(), userId, typeof currentPin === 'string' ? currentPin : '');
183+
if (current.status === 'error') {
184+
console.error('Error checking backup PIN:', current.error);
185+
return NextResponse.json({ error: 'Failed to set backup PIN' }, { status: 500 });
186+
}
187+
if (current.status === 'locked' || (current.status === 'wrong' && current.retryAfter)) {
188+
const retryAfter = current.retryAfter;
189+
return NextResponse.json(
190+
{ error: 'Too many wrong PINs. Try again later.', code: 'PIN_LOCKED', retryAfter },
191+
{ status: 429, headers: { 'Retry-After': String(retryAfter) } }
192+
);
193+
}
194+
if (current.status === 'required') {
195+
return NextResponse.json({ error: 'Enter your current PIN to change it', code: 'PIN_REQUIRED' }, { status: 403 });
196+
}
197+
if (current.status === 'wrong') {
198+
return NextResponse.json({ error: 'Current PIN is wrong', code: 'PIN_WRONG', remaining: current.remaining }, { status: 403 });
199+
}
200+
233201
const { hash, salt, algorithm } = await hashPin(pin);
234202

235203
const { error } = await getServiceRoleClient()

‎src/app/api/auth/backup-pin/route.test.js‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,40 @@ describe('backup PIN cookie authentication', () => {
205205
expect(body.error).toBe('Invalid JSON body');
206206
expect(mocks.serviceFrom).not.toHaveBeenCalled();
207207
});
208+
209+
it('accepts a 4-digit PIN', async () => {
210+
const { POST } = await import('./route.js');
211+
const response = await POST({
212+
headers: new Headers({ authorization: 'Bearer access-token' }),
213+
json: vi.fn().mockResolvedValue({ pin: '4821' })
214+
});
215+
expect(response.status).toBe(200);
216+
});
217+
218+
it('will not replace a verifiable PIN without the current one', async () => {
219+
const { hashPin } = await import('@/lib/auth/backup-pin.js');
220+
const existing = await hashPin('4821');
221+
mocks.serviceFrom.mockImplementation((table) => {
222+
if (table === 'users') return createUsersQuery();
223+
if (table === 'user_backup_pins') {
224+
const query = createBackupPinsQuery();
225+
query.maybeSingle = vi.fn(() =>
226+
Promise.resolve({
227+
data: { user_id: 'internal-user-id', pin_hash: existing.hash, pin_salt: existing.salt, algorithm: existing.algorithm, failed_attempts: 0 },
228+
error: null
229+
})
230+
);
231+
return query;
232+
}
233+
throw new Error(`Unexpected table: ${table}`);
234+
});
235+
const { POST } = await import('./route.js');
236+
const response = await POST({
237+
headers: new Headers({ authorization: 'Bearer access-token' }),
238+
json: vi.fn().mockResolvedValue({ pin: '1111' })
239+
});
240+
expect(response.status).toBe(403);
241+
expect((await response.json()).code).toBe('PIN_REQUIRED');
242+
expect(mocks.pinUpsert).not.toHaveBeenCalled();
243+
});
208244
});

0 commit comments

Comments
 (0)