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: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "qryptchat-web",
"private": true,
"version": "0.4.0",
"version": "0.5.0",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
Expand Down
10 changes: 5 additions & 5 deletions src/app/anon/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,8 @@ function AnonRegister() {

async function setBackupPinFn(e) {
e?.preventDefault();
if (!backupPin || !/^\d+$/.test(backupPin) || backupPin.length < 6 || backupPin.length > 12) {
msgStore.error('PIN must be 6-12 digits');
if (!backupPin || !/^\d{4,12}$/.test(backupPin)) {
msgStore.error('PIN must be 4-12 digits');
return;
}
if (backupPin !== confirmBackupPin) {
Expand All @@ -140,7 +140,7 @@ function AnonRegister() {
const backupRes = await fetch('/api/auth/key-backup', {
method: 'PUT',
headers,
body: JSON.stringify({ encrypted_keys: encryptedData })
body: JSON.stringify({ encrypted_keys: encryptedData, pin: backupPin })
});
if (!backupRes.ok) {
const err = await backupRes.json().catch(() => ({}));
Expand Down Expand Up @@ -262,14 +262,14 @@ function AnonRegister() {
</div>
<form onSubmit={setBackupPinFn}>
<div className="input-group">
<label>Backup PIN (6-12 digits) *</label>
<label>Backup PIN (4-12 digits) *</label>
<div className="password-input">
<input
type={showBackupPin ? 'text' : 'password'}
inputMode="numeric"
value={backupPin}
onChange={(e) => setBackupPin(e.target.value.replace(/\D/g, '').slice(0, 12))}
placeholder="Enter 6-12 digit PIN"
placeholder="Enter 4-12 digit PIN"
required
disabled={submitting}
className="code-input"
Expand Down
88 changes: 28 additions & 60 deletions src/app/api/auth/backup-pin/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,14 @@
// Handles setting and checking the user's backup PIN hash

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

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

const scrypt = promisify(scryptCallback);

let supabaseServiceRole = null;
function getServiceRoleClient() {
if (!supabaseServiceRole) {
Expand Down Expand Up @@ -102,60 +99,10 @@ async function authenticateUser(request) {
}
}

// scrypt work factors. N=2^17/r=8/p=1 is OWASP's recommended minimum; the previous
// N=16384 (Node's default) cost only ~16MB per derivation, which left the 6-12 digit
// PIN keyspace within reach of an offline GPU sweep. The per-user salt means there is
// no shared work across users on top of that.
const SCRYPT_N = 131072;
const SCRYPT_R = 8;
const SCRYPT_P = 1;
// scrypt needs roughly 128 * N * r bytes; Node's default 32MB cap would reject N=2^17.
const SCRYPT_MAXMEM = 192 * 1024 * 1024;
const SCRYPT_KEYLEN = 64;
const SCRYPT_SALT_BYTES = 16;
export const PIN_ALGORITHM = `scrypt-n${SCRYPT_N}-r${SCRYPT_R}-p${SCRYPT_P}`;

/**
* Derive a PIN hash using scrypt and a per-user random salt.
*
* Replaces the previous unsalted `crypto.subtle.digest('SHA-256', pin)`, which
* a rainbow table over the numeric PIN keyspace reversed instantly once the
* hash column was readable (GHSA-jpfm-vrpc-p6rr).
*
* @param {string} pin
* @param {string} [saltHex] existing salt, hex-encoded; a new one is generated when omitted
* @returns {Promise<{hash: string, salt: string, algorithm: string}>}
*/
async function hashPin(pin, saltHex) {
const salt = saltHex ?? randomBytes(SCRYPT_SALT_BYTES).toString('hex');
const derived = /** @type {Buffer} */ (
await scrypt(pin, salt, SCRYPT_KEYLEN, {
N: SCRYPT_N,
r: SCRYPT_R,
p: SCRYPT_P,
maxmem: SCRYPT_MAXMEM
})
);
return { hash: derived.toString('hex'), salt, algorithm: PIN_ALGORITHM };
}
export { PIN_ALGORITHM };

/**
* Resolve the internal users.id for a Supabase Auth user.
* @param {{id: string}} user
* @returns {Promise<{userId?: string, error?: string}>}
*/
async function resolveInternalUserId(user) {
const { data, error } = await getServiceRoleClient()
.from('users')
.select('id')
.eq('auth_user_id', user.id)
.single();

if (error || !data?.id) {
return { error: error?.message ?? 'User record not found' };
}

return { userId: data.id };
return resolveUserId(getServiceRoleClient(), user);
}

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

const { pin } = body;
const { pin, currentPin } = body;

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

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

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

// Replacing a PIN the server can verify takes the current one. Otherwise a
// stolen session could set its own PIN and walk through the restore gate.
const current = await checkPin(getServiceRoleClient(), userId, typeof currentPin === 'string' ? currentPin : '');
if (current.status === 'error') {
console.error('Error checking backup PIN:', current.error);
return NextResponse.json({ error: 'Failed to set backup PIN' }, { status: 500 });
}
if (current.status === 'locked' || (current.status === 'wrong' && current.retryAfter)) {
const retryAfter = current.retryAfter;
return NextResponse.json(
{ error: 'Too many wrong PINs. Try again later.', code: 'PIN_LOCKED', retryAfter },
{ status: 429, headers: { 'Retry-After': String(retryAfter) } }
);
}
if (current.status === 'required') {
return NextResponse.json({ error: 'Enter your current PIN to change it', code: 'PIN_REQUIRED' }, { status: 403 });
}
if (current.status === 'wrong') {
return NextResponse.json({ error: 'Current PIN is wrong', code: 'PIN_WRONG', remaining: current.remaining }, { status: 403 });
}

const { hash, salt, algorithm } = await hashPin(pin);

const { error } = await getServiceRoleClient()
Expand Down
36 changes: 36 additions & 0 deletions src/app/api/auth/backup-pin/route.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,4 +205,40 @@ describe('backup PIN cookie authentication', () => {
expect(body.error).toBe('Invalid JSON body');
expect(mocks.serviceFrom).not.toHaveBeenCalled();
});

it('accepts a 4-digit PIN', async () => {
const { POST } = await import('./route.js');
const response = await POST({
headers: new Headers({ authorization: 'Bearer access-token' }),
json: vi.fn().mockResolvedValue({ pin: '4821' })
});
expect(response.status).toBe(200);
});

it('will not replace a verifiable PIN without the current one', async () => {
const { hashPin } = await import('@/lib/auth/backup-pin.js');
const existing = await hashPin('4821');
mocks.serviceFrom.mockImplementation((table) => {
if (table === 'users') return createUsersQuery();
if (table === 'user_backup_pins') {
const query = createBackupPinsQuery();
query.maybeSingle = vi.fn(() =>
Promise.resolve({
data: { user_id: 'internal-user-id', pin_hash: existing.hash, pin_salt: existing.salt, algorithm: existing.algorithm, failed_attempts: 0 },
error: null
})
);
return query;
}
throw new Error(`Unexpected table: ${table}`);
});
const { POST } = await import('./route.js');
const response = await POST({
headers: new Headers({ authorization: 'Bearer access-token' }),
json: vi.fn().mockResolvedValue({ pin: '1111' })
});
expect(response.status).toBe(403);
expect((await response.json()).code).toBe('PIN_REQUIRED');
expect(mocks.pinUpsert).not.toHaveBeenCalled();
});
});
Loading
Loading