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.17.2",
"version": "0.18.0",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
Expand Down
17 changes: 17 additions & 0 deletions scripts/sql/key-backups-unmigrated.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- How many key backups are still sealed under a PIN (seal_version 1), and how
-- weak. Counts only; never selects ciphertext.
-- docker exec -i qrypt-chat-supabase-db psql -U postgres -d postgres < scripts/sql/key-backups-unmigrated.sql
SELECT seal_version,
CASE WHEN seal_version >= 2 THEN 'passphrase+recovery key'
WHEN encrypted_keys::jsonb ? 'kdfIterations' THEN 'PIN, PBKDF2 600k'
ELSE 'PIN, PBKDF2 100k' END AS sealed_with,
count(*) AS rows,
max(updated_at) AS newest
FROM public.key_backups
GROUP BY 1, 2
ORDER BY 1, 2;

SELECT algorithm, count(*) AS pin_rows FROM public.user_backup_pins GROUP BY 1;

SELECT count(*) AS legacy_encrypted_key_backups_rows
FROM public.encrypted_key_backups;
132 changes: 16 additions & 116 deletions src/app/anon/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import { useShallow } from 'zustand/react/shallow';
import { useMessagesStore } from '@/lib/stores/messages.js';
import { createSupabaseClient } from '@/lib/supabase.js';
import { keyManager } from '@/lib/crypto/key-manager.js';
import { privateKeyManager } from '@/lib/crypto/private-key-manager.js';
import { postQuantumEncryption } from '@/lib/crypto/post-quantum-encryption.js';
import Message from '@/lib/components/Message.jsx';
import KeyBackupSetup from '@/lib/components/KeyBackupSetup.jsx';

/** Map register-anon error codes to friendly copy. */
const INVITE_ERRORS = {
Expand All @@ -34,9 +34,6 @@ function AnonRegister() {
const [displayName, setDisplayName] = useState('');
const [submitting, setSubmitting] = useState(false);
const [anonSession, setAnonSession] = useState(null);
const [backupPin, setBackupPin] = useState('');
const [confirmBackupPin, setConfirmBackupPin] = useState('');
const [showBackupPin, setShowBackupPin] = useState(false);

useEffect(() => {
if (authenticated) {
Expand Down Expand Up @@ -105,56 +102,14 @@ function AnonRegister() {

setAnonSession(session);
setStep('backup');
msgStore.success('Anonymous account created! Set a Backup PIN to protect your keys.');
msgStore.success('Anonymous account created! Now back up your keys.');
} catch {
msgStore.error('Something went wrong creating your account. Please try again.');
} finally {
setSubmitting(false);
}
}

async function setBackupPinFn(e) {
e?.preventDefault();
if (!backupPin || !/^\d{4,12}$/.test(backupPin)) {
msgStore.error('PIN must be 4-12 digits');
return;
}
if (backupPin !== confirmBackupPin) {
msgStore.error('PINs do not match');
return;
}
setSubmitting(true);
try {
const headers = { 'Content-Type': 'application/json' };
if (anonSession?.access_token) headers['Authorization'] = `Bearer ${anonSession.access_token}`;
const pinRes = await fetch('/api/auth/backup-pin', {
method: 'POST',
headers,
body: JSON.stringify({ pin: backupPin })
});
if (!pinRes.ok) {
const err = await pinRes.json().catch(() => ({}));
throw new Error(err.error || 'Failed to save PIN');
}
const encryptedData = await privateKeyManager.exportPrivateKeys(backupPin);
const backupRes = await fetch('/api/auth/key-backup', {
method: 'PUT',
headers,
body: JSON.stringify({ encrypted_keys: encryptedData, pin: backupPin })
});
if (!backupRes.ok) {
const err = await backupRes.json().catch(() => ({}));
throw new Error(err.error || 'Key backup failed');
}
msgStore.success('Backup PIN set and keys backed up! Welcome to QryptChat!');
router.push('/chat');
} catch (err) {
msgStore.error('Failed to set backup PIN: ' + (err.message || 'Unknown error'));
} finally {
setSubmitting(false);
}
}

return (
<div className="auth-container">
<div className="auth-card">
Expand Down Expand Up @@ -252,80 +207,25 @@ function AnonRegister() {

{step === 'backup' && (
<div className="auth-step">
<h2>Set Your Backup PIN</h2>
<p className="step-description">This PIN protects your encryption keys.</p>
<h2>Back Up Your Encryption Keys</h2>
<div className="backup-warning">
<p>
<strong>Important:</strong> There is no phone or email on this account. If you lose
this PIN <em>and</em> this device, your account cannot be recovered.
this passphrase, the recovery key <em>and</em> this device, your account cannot be recovered.
</p>
</div>
<form onSubmit={setBackupPinFn}>
<div className="input-group">
<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 4-12 digit PIN"
required
disabled={submitting}
className="code-input"
/>
<button
type="button"
className="toggle-password"
onClick={() => setShowBackupPin(!showBackupPin)}
>
{showBackupPin ? '👁️' : '👁️‍🗨️'}
</button>
</div>
</div>
<div className="input-group">
<label>Confirm PIN *</label>
<div className="password-input">
<input
type={showBackupPin ? 'text' : 'password'}
inputMode="numeric"
value={confirmBackupPin}
onChange={(e) =>
setConfirmBackupPin(e.target.value.replace(/\D/g, '').slice(0, 12))
}
placeholder="Confirm your PIN"
required
disabled={submitting}
className="code-input"
/>
</div>
</div>
<div className="button-group">
<button
type="submit"
disabled={submitting || !backupPin || !confirmBackupPin}
className="primary-button"
>
{submitting ? (
<>
<span className="loading-spinner" /> Setting PIN…
</>
) : (
'Set Backup PIN'
)}
</button>
<button
type="button"
className="secondary-button"
onClick={() => {
msgStore.warning('Backup PIN skipped. You can set one later in Settings.');
router.push('/chat');
}}
>
Skip (Not Recommended)
</button>
</div>
</form>
<KeyBackupSetup
accessToken={anonSession?.access_token}
onError={(m) => msgStore.error(m)}
onDone={() => {
msgStore.success('Keys backed up. Welcome to QryptChat!');
router.push('/chat');
}}
onSkip={() => {
msgStore.warning('Backup skipped. You can back up later in Settings.');
router.push('/chat');
}}
/>
</div>
)}

Expand Down
86 changes: 12 additions & 74 deletions src/app/api/auth/backup-pin/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { NextResponse } from 'next/server';
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';
import { PIN_ALGORITHM, 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';
Expand Down Expand Up @@ -145,80 +145,18 @@ export async function GET(request) {

/**
* POST /api/auth/backup-pin
* Set or update the backup PIN for the authenticated user
* Retired. Key backups are now sealed in the browser under a passphrase and a
* recovery key (seal v2), so a new PIN would protect nothing; a stored hash of a
* short PIN is only an offline oracle. Legacy PIN rows are still checked by the
* key-backup route until LEGACY_KEY_MIGRATION=off, and deleted on upgrade.
*/
export async function POST(request) {
try {
const { user, error: authError } = await authenticateUser(request);
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

let body;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}

const { pin, currentPin } = body;

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

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

const { userId, error: lookupError } = await resolveInternalUserId(user);
if (lookupError || !userId) {
console.error('Error setting backup PIN:', lookupError);
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()
.from('user_backup_pins')
.upsert({
user_id: userId,
pin_hash: hash,
pin_salt: salt,
algorithm,
updated_at: new Date().toISOString()
}, { onConflict: 'user_id' });

if (error) {
console.error('Error setting backup PIN:', error);
return NextResponse.json({ error: 'Failed to set backup PIN' }, { status: 500 });
}

console.log(`🔑 Backup PIN set for user ${user.id}`);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error in POST /api/auth/backup-pin:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
const { user, error: authError } = await authenticateUser(request);
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return NextResponse.json(
{ error: 'Backup PINs are retired. Back up your keys with a passphrase instead.', code: 'PIN_RETIRED' },
{ status: 410 }
);
}
Loading
Loading