From 2766dcd933d4598a0d9f225a0e8e3465376ffb58 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 28 Oct 2026 19:28:59 +0000 Subject: [PATCH] fix(keys): seal key backups under a passphrase + recovery key, not a PIN (0.18.0) Server key backups were PBKDF2(4-12 digit PIN) ciphertext. No server key opened them, but anyone with the DB (leak, or root on dev2) could try every 4-6 digit PIN offline in seconds to minutes; 5 of 8 prod rows were also only 100k iterations, and user_backup_pins held a scrypt hash of the same PIN as a second offline oracle. The PIN lockout did not even hold online: own-row RLS policies (plus full anon/authenticated grants after the dev2 move) let any session read its ciphertext straight from PostgREST. - seal v2 (src/lib/crypto/key-backup-seal.js): random AES-256-GCM data key wrapped under PBKDF2-SHA256 600k of a >=12 char passphrase and under a random 160-bit recovery key shown once; AAD binds auth user id + field. All in the browser; the server sees neither secret. - PUT /api/auth/key-backup accepts only seal-v2 envelopes, sets seal_version=2 and deletes the PIN hash. Replacing a verifiable PIN backup still needs that PIN once. - POST returns a v2 blob to its owner; legacy PIN rows stay behind the PIN gate until LEGACY_KEY_MIGRATION=off (410 after the cutoff). - POST /api/auth/backup-pin is retired (410). - Sign-in/sign-up/settings: passphrase + one-time recovery key; signing in with local keys and a PIN backup prompts a re-seal; restoring a PIN backup re-seals right after. - Migration: key_backups.seal_version, key_backups/user_backup_pins/ encrypted_key_backups service-role only. scripts/sql/key-backups-unmigrated.sql counts what is left. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- scripts/sql/key-backups-unmigrated.sql | 17 ++ src/app/anon/page.jsx | 132 +------- src/app/api/auth/backup-pin/route.js | 86 +----- src/app/api/auth/backup-pin/route.test.js | 88 +----- src/app/api/auth/key-backup/route.js | 129 +++++--- src/app/api/auth/key-backup/route.test.js | 196 ++++++++++-- src/app/auth/page.jsx | 109 +++---- src/app/cli/authorize/page.jsx | 2 +- src/lib/auth/backup-pin.js | 10 + src/lib/auth/cli-auth.js | 2 +- src/lib/components/EncryptionWarning.jsx | 2 +- src/lib/components/KeyBackupSetup.jsx | 130 ++++++++ .../components/settings/PrivateKeyManager.jsx | 154 +++------- src/lib/crypto/key-backup-seal.js | 286 ++++++++++++++++++ src/lib/crypto/key-backup-seal.test.js | 120 ++++++++ .../crypto/private-key-manager.backup.test.js | 84 +++++ src/lib/crypto/private-key-manager.js | 263 ++++++++++------ .../20261028120000_key_backups_seal_v2.sql | 56 ++++ 19 files changed, 1238 insertions(+), 630 deletions(-) create mode 100644 scripts/sql/key-backups-unmigrated.sql create mode 100644 src/lib/components/KeyBackupSetup.jsx create mode 100644 src/lib/crypto/key-backup-seal.js create mode 100644 src/lib/crypto/key-backup-seal.test.js create mode 100644 src/lib/crypto/private-key-manager.backup.test.js create mode 100644 supabase/migrations/20261028120000_key_backups_seal_v2.sql diff --git a/package.json b/package.json index bab2c069..802f8a86 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "qryptchat-web", "private": true, - "version": "0.17.2", + "version": "0.18.0", "type": "module", "bin": { "qc": "./bin/qc.js", diff --git a/scripts/sql/key-backups-unmigrated.sql b/scripts/sql/key-backups-unmigrated.sql new file mode 100644 index 00000000..a73548ba --- /dev/null +++ b/scripts/sql/key-backups-unmigrated.sql @@ -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; diff --git a/src/app/anon/page.jsx b/src/app/anon/page.jsx index cdf7e163..2c54b108 100644 --- a/src/app/anon/page.jsx +++ b/src/app/anon/page.jsx @@ -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 = { @@ -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) { @@ -105,7 +102,7 @@ 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 { @@ -113,48 +110,6 @@ function AnonRegister() { } } - 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 (
@@ -252,80 +207,25 @@ function AnonRegister() { {step === 'backup' && (
-

Set Your Backup PIN

-

This PIN protects your encryption keys.

+

Back Up Your Encryption Keys

Important: There is no phone or email on this account. If you lose - this PIN and this device, your account cannot be recovered. + this passphrase, the recovery key and this device, your account cannot be recovered.

-
-
- -
- setBackupPin(e.target.value.replace(/\D/g, '').slice(0, 12))} - placeholder="Enter 4-12 digit PIN" - required - disabled={submitting} - className="code-input" - /> - -
-
-
- -
- - setConfirmBackupPin(e.target.value.replace(/\D/g, '').slice(0, 12)) - } - placeholder="Confirm your PIN" - required - disabled={submitting} - className="code-input" - /> -
-
-
- - -
-
+ 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'); + }} + />
)} diff --git a/src/app/api/auth/backup-pin/route.js b/src/app/api/auth/backup-pin/route.js index 71f15cbb..bb4f018f 100644 --- a/src/app/api/auth/backup-pin/route.js +++ b/src/app/api/auth/backup-pin/route.js @@ -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'; @@ -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 } + ); } diff --git a/src/app/api/auth/backup-pin/route.test.js b/src/app/api/auth/backup-pin/route.test.js index e5fd0da0..c815bd82 100644 --- a/src/app/api/auth/backup-pin/route.test.js +++ b/src/app/api/auth/backup-pin/route.test.js @@ -1,8 +1,6 @@ import { supabaseAuthCookieName } from '@/lib/supabase/auth-cookie.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createHash } from 'node:crypto'; - const mocks = vi.hoisted(() => ({ authGetUser: vi.fn(), serviceFrom: vi.fn(), @@ -154,91 +152,21 @@ describe('backup PIN cookie authentication', () => { expect(mocks.serviceFrom).toHaveBeenCalledWith('user_backup_pins'); }); - it('stores a salted scrypt hash rather than an unsalted SHA-256 digest', async () => { - const { POST } = await import('./route.js'); - const pin = '123456'; - const response = await POST({ - headers: new Headers({ authorization: 'Bearer access-token' }), - json: vi.fn().mockResolvedValue({ pin }) - }); - - expect(response.status).toBe(200); - expect(mocks.pinUpsert).toHaveBeenCalledTimes(1); - - const [record] = mocks.pinUpsert.mock.calls[0]; - expect(record.user_id).toBe('internal-user-id'); - expect(record.algorithm).toMatch(/^scrypt-/); - - // A salt is present and actually mixed in: the stored hash must not be the - // bare SHA-256 of the PIN, which is what GHSA-jpfm-vrpc-p6rr was about. - expect(record.pin_salt).toMatch(/^[0-9a-f]{32}$/); - const unsaltedSha256 = createHash('sha256').update(pin).digest('hex'); - expect(record.pin_hash).not.toBe(unsaltedSha256); - expect(record.pin_hash).toMatch(/^[0-9a-f]{128}$/); - }); - - it('derives a different hash per user for the same PIN', async () => { - const { POST } = await import('./route.js'); - const request = () => ({ - headers: new Headers({ authorization: 'Bearer access-token' }), - json: vi.fn().mockResolvedValue({ pin: '123456' }) - }); - - await POST(request()); - await POST(request()); - - const [first] = mocks.pinUpsert.mock.calls[0]; - const [second] = mocks.pinUpsert.mock.calls[1]; - expect(first.pin_salt).not.toBe(second.pin_salt); - expect(first.pin_hash).not.toBe(second.pin_hash); - }); - - it('returns 400 for malformed JSON instead of a generic 500', async () => { - const { POST } = await import('./route.js'); - const response = await POST({ - headers: new Headers({ authorization: 'Bearer access-token' }), - json: vi.fn().mockRejectedValue(new SyntaxError('Unexpected token')) - }); - const body = await response.json(); - - expect(response.status).toBe(400); - expect(body.error).toBe('Invalid JSON body'); - expect(mocks.serviceFrom).not.toHaveBeenCalled(); - }); - - it('accepts a 4-digit PIN', async () => { + it('no longer sets PINs: backups are sealed with a passphrase now', 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); + expect(response.status).toBe(410); + expect((await response.json()).code).toBe('PIN_RETIRED'); + expect(mocks.pinUpsert).not.toHaveBeenCalled(); }); - 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}`); - }); + it('still requires a session to say so', async () => { + mocks.authGetUser.mockResolvedValue({ data: { user: null }, error: { message: 'bad' } }); 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(); + const response = await POST(new Request('https://qrypt.chat/api/auth/backup-pin', { method: 'POST', body: '{}' })); + expect(response.status).toBe(401); }); }); diff --git a/src/app/api/auth/key-backup/route.js b/src/app/api/auth/key-backup/route.js index 9d66f7c1..23bff419 100644 --- a/src/app/api/auth/key-backup/route.js +++ b/src/app/api/auth/key-backup/route.js @@ -5,7 +5,8 @@ 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_PATTERN, PIN_RULE, resolveInternalUserId } from '@/lib/auth/backup-pin.js'; +import { checkPin, legacyMigrationEnabled, PIN_PATTERN, PIN_RULE, resolveInternalUserId } from '@/lib/auth/backup-pin.js'; +import { SEAL_VERSION, sealEnvelopeProblem } from '@/lib/crypto/key-backup-seal.js'; // node:crypto (scrypt) checks the PIN, so pin this route to the Node runtime. export const runtime = 'nodejs'; @@ -143,7 +144,7 @@ export async function GET(request) { const { data, error } = await getServiceRoleClient() .from('key_backups') - .select('encrypted_keys, created_at, updated_at') + .select('created_at, updated_at, seal_version') .eq('user_id', user.id) .single(); @@ -159,7 +160,8 @@ export async function GET(request) { return NextResponse.json({ backup: { created_at: data.created_at, - updated_at: data.updated_at + updated_at: data.updated_at, + seal_version: data.seal_version ?? 1 } }); @@ -170,11 +172,11 @@ export async function GET(request) { } /** - * POST /api/auth/key-backup { pin } - * Restore: return the encrypted backup once the PIN checks out. Wrong PINs - * count toward a lockout. A backup whose PIN the server cannot verify (no PIN - * row, or a legacy row whose unsalted hash was discarded) is returned with - * `legacy: true`; the client registers the PIN once it has decrypted with it. + * POST /api/auth/key-backup { pin? } + * Restore. A seal-v2 backup comes back to its owner as is: it opens only with + * the passphrase or recovery key, which never reach the server. A legacy PIN + * backup (seal_version 1) is only handed over once the PIN checks out (wrong + * PINs count toward a lockout), and not at all after the legacy cutoff. */ export async function POST(request) { try { @@ -189,15 +191,11 @@ export async function POST(request) { } catch { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); } - const pin = typeof body?.pin === 'string' ? body.pin : ''; - if (pin && !PIN_PATTERN.test(pin)) { - return NextResponse.json({ error: PIN_RULE }, { status: 400 }); - } const db = getServiceRoleClient(); const { data, error } = await db .from('key_backups') - .select('encrypted_keys, created_at, updated_at') + .select('encrypted_keys, created_at, updated_at, seal_version') .eq('user_id', user.id) .single(); if (error) { @@ -206,6 +204,31 @@ export async function POST(request) { return NextResponse.json({ error: 'Failed to fetch key backup' }, { status: 500 }); } + const sealVersion = data.seal_version ?? 1; + const backup = { + encrypted_keys: data.encrypted_keys, + created_at: data.created_at, + updated_at: data.updated_at, + seal_version: sealVersion + }; + if (sealVersion >= SEAL_VERSION) { + return NextResponse.json({ backup, user_id: user.id, legacy: false }); + } + + if (!legacyMigrationEnabled()) { + return NextResponse.json( + { + error: 'This backup used a PIN, and PIN backups are no longer served. Restore from a key file or another device, or start fresh.', + code: 'LEGACY_BACKUP_DISABLED' + }, + { status: 410 } + ); + } + + const pin = typeof body?.pin === 'string' ? body.pin : ''; + if (pin && !PIN_PATTERN.test(pin)) { + return NextResponse.json({ error: PIN_RULE }, { status: 400 }); + } const { userId, error: lookupError } = await resolveInternalUserId(db, user); if (lookupError || !userId) { console.error('Error resolving user for key restore:', lookupError); @@ -215,14 +238,8 @@ export async function POST(request) { const rejected = pinRejection(result); if (rejected) return rejected; - return NextResponse.json({ - backup: { - encrypted_keys: data.encrypted_keys, - created_at: data.created_at, - updated_at: data.updated_at - }, - legacy: result.status === 'unset' - }); + // legacy: the client must re-seal this under a passphrase + recovery key. + return NextResponse.json({ backup, user_id: user.id, legacy: true, pinVerified: result.status === 'ok' }); } catch (error) { console.error('Error in POST /api/auth/key-backup:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); @@ -252,8 +269,15 @@ export async function DELETE(request) { } /** - * PUT /api/auth/key-backup - * Store or update the authenticated user's encrypted key backup + * PUT /api/auth/key-backup { encrypted_keys, pin? } + * Store the user's backup. Only a seal-v2 envelope is accepted: sealed in the + * browser under a passphrase and a recovery key the server never sees. PIN + * backups can no longer be written. + * + * Replacing a legacy PIN backup that has a verifiable PIN still needs that PIN, + * as before, so a stolen session cannot swap it out. Once the seal-v2 backup is + * stored, the PIN hash is deleted: it protects nothing any more, and a stored + * hash of a short PIN is an offline oracle for a number people reuse. */ export async function PUT(request) { try { @@ -275,18 +299,27 @@ export async function PUT(request) { return NextResponse.json({ error: 'Missing or invalid encrypted_keys' }, { status: 400 }); } - // Validate that encrypted_keys is valid JSON (it should be the export format) + let envelope; try { - const backupExport = JSON.parse(encrypted_keys); - if (!backupExport || typeof backupExport !== 'object' || Array.isArray(backupExport)) { - return NextResponse.json({ error: 'encrypted_keys must be a JSON object string' }, { status: 400 }); - } + envelope = JSON.parse(encrypted_keys); } catch { return NextResponse.json({ error: 'encrypted_keys must be a valid JSON string' }, { status: 400 }); } + if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) { + return NextResponse.json({ error: 'encrypted_keys must be a JSON object string' }, { status: 400 }); + } + const problem = sealEnvelopeProblem(envelope); + if (problem) { + return NextResponse.json( + { + error: 'Backups must be sealed with a passphrase and recovery key. Update the app and try again.', + code: 'SEAL_REQUIRED', + detail: problem + }, + { status: 400 } + ); + } - // The backup must be encrypted with the account's backup PIN, since that is - // what gates restore: check it when one is set, adopt it when none is. if (pin && !PIN_PATTERN.test(pin)) { return NextResponse.json({ error: PIN_RULE }, { status: 400 }); } @@ -296,28 +329,29 @@ export async function PUT(request) { console.error('Error resolving user for key backup:', lookupError); return NextResponse.json({ error: 'Failed to store key backup' }, { status: 500 }); } - const result = await checkPin(db, userId, pin); - const rejected = pinRejection(result); - if (rejected) return rejected; - if (result.status === 'unset' && pin) { - const { hash, salt, algorithm } = await hashPin(pin); - const { error: pinError } = await db.from('user_backup_pins').upsert( - { user_id: userId, pin_hash: hash, pin_salt: salt, algorithm, failed_attempts: 0, locked_until: null, updated_at: new Date().toISOString() }, - { onConflict: 'user_id' } - ); - if (pinError) { - console.error('Error setting backup PIN:', pinError); - return NextResponse.json({ error: 'Failed to store key backup' }, { status: 500 }); - } + + const { data: existing, error: existingError } = await db + .from('key_backups') + .select('seal_version') + .eq('user_id', user.id) + .maybeSingle(); + if (existingError) { + console.error('Error reading key backup:', existingError); + return NextResponse.json({ error: 'Failed to store key backup' }, { status: 500 }); + } + if (existing && (existing.seal_version ?? 1) < SEAL_VERSION) { + const result = await checkPin(db, userId, pin); + const rejected = pinRejection(result); + if (rejected) return rejected; } - // Upsert: insert or update on conflict const { data, error } = await db .from('key_backups') .upsert( { user_id: user.id, encrypted_keys, + seal_version: SEAL_VERSION, updated_at: new Date().toISOString() }, { onConflict: 'user_id' } @@ -330,15 +364,18 @@ export async function PUT(request) { return NextResponse.json({ error: 'Failed to store key backup' }, { status: 500 }); } - console.log(`πŸ”‘ Key backup stored for user ${user.id}`); + const { error: pinDeleteError } = await db.from('user_backup_pins').delete().eq('user_id', userId); + if (pinDeleteError) console.error('Error retiring backup PIN:', pinDeleteError.message); + + console.log(`πŸ”‘ Key backup stored for user ${user.id} (seal v${SEAL_VERSION})`); return NextResponse.json({ success: true, backup_id: data.id, + seal_version: SEAL_VERSION, created_at: data.created_at, updated_at: data.updated_at }); - } catch (error) { console.error('Error in PUT /api/auth/key-backup:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); diff --git a/src/app/api/auth/key-backup/route.test.js b/src/app/api/auth/key-backup/route.test.js index f3e60b7e..17071b93 100644 --- a/src/app/api/auth/key-backup/route.test.js +++ b/src/app/api/auth/key-backup/route.test.js @@ -1,5 +1,8 @@ import { supabaseAuthCookieName } from '@/lib/supabase/auth-cookie.js'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateRecoveryKey, sealKeys } from '@/lib/crypto/key-backup-seal.js'; const mocks = vi.hoisted(() => ({ authGetUser: vi.fn(), @@ -36,20 +39,19 @@ function paddedCookieValue() { return 'base64-eyJhY2Nlc3NfdG9rZW4iOiJhYmMiLCJwYWRkaW5nIjoieCJ9=='; } -function createBackupQuery() { +function createBackupQuery(row = {}) { + const data = { + encrypted_keys: '{"version":1}', + created_at: '2026-07-11T00:00:00.000Z', + updated_at: '2026-07-11T00:00:00.000Z', + seal_version: 1, + ...row + }; const query = { select: vi.fn(() => query), eq: vi.fn(() => query), - single: vi.fn(() => - Promise.resolve({ - data: { - encrypted_keys: '{"version":1}', - created_at: '2026-07-11T00:00:00.000Z', - updated_at: '2026-07-11T00:00:00.000Z' - }, - error: null - }) - ) + single: vi.fn(() => Promise.resolve({ data, error: null })), + maybeSingle: vi.fn(() => Promise.resolve({ data: row === null ? null : { seal_version: data.seal_version }, error: null })) }; return query; } @@ -105,7 +107,8 @@ describe('key backup cookie authentication', () => { expect(body).toEqual({ backup: { created_at: '2026-07-11T00:00:00.000Z', - updated_at: '2026-07-11T00:00:00.000Z' + updated_at: '2026-07-11T00:00:00.000Z', + seal_version: 1 } }); expect(mocks.authGetUser).toHaveBeenCalledWith('access-token'); @@ -181,7 +184,7 @@ describe('key backup cookie authentication', () => { }); }); -describe('key backup restore is gated on the PIN', () => { +describe('legacy PIN backup restore is gated on the PIN', () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); @@ -209,7 +212,9 @@ describe('key backup restore is gated on the PIN', () => { const body = await response.json(); expect(response.status).toBe(200); expect(body.backup.encrypted_keys).toBe('{"version":1}'); - expect(body.legacy).toBe(false); + // still a PIN backup: the client must re-seal it with a passphrase + expect(body.legacy).toBe(true); + expect(body.pinVerified).toBe(true); expect(mocks.checkPin).toHaveBeenCalledWith(expect.anything(), 'user-id', '4821'); }); @@ -237,17 +242,160 @@ describe('key backup restore is gated on the PIN', () => { const body = await (await POST(restore('4821'))).json(); expect(body.legacy).toBe(true); }); +}); + +const USER_ID = 'auth-user-id'; + +async function sealedBackup() { + const keys = new TextEncoder().encode('{"keys1024":{"publicKey":"pk","privateKey":"sk"}}'); + const envelope = await sealKeys(keys, { + passphrase: 'correct horse battery staple', + recoveryKey: generateRecoveryKey(), + userId: USER_ID + }); + return JSON.stringify(envelope); +} + +const put = (body) => + new Request('https://qrypt.chat/api/auth/key-backup', { + method: 'PUT', + headers: { authorization: 'Bearer access-token', 'content-type': 'application/json' }, + body: JSON.stringify(body) + }); + +const post = (body = {}) => + new Request('https://qrypt.chat/api/auth/key-backup', { + method: 'POST', + headers: { authorization: 'Bearer access-token', 'content-type': 'application/json' }, + body: JSON.stringify(body) + }); + +describe('seal v2 key backups', () => { + let upserts; + let pinDeletes; + let existing; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + delete process.env.LEGACY_KEY_MIGRATION; + process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://example.supabase.co'; + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key'; + mocks.authGetUser.mockResolvedValue({ data: { user: { id: USER_ID } }, error: null }); + mocks.resolveUser.mockResolvedValue({ userId: 'user-id' }); + upserts = []; + pinDeletes = []; + existing = null; + mocks.serviceFrom.mockImplementation((table) => { + if (table === 'key_backups') { + const q = existing === undefined ? createBackupQuery(null) : createBackupQuery(existing ?? {}); + if (existing === null) q.maybeSingle = vi.fn(() => Promise.resolve({ data: null, error: null })); + q.upsert = vi.fn((row) => { + upserts.push(row); + return q; + }); + q.single = vi.fn(() => + Promise.resolve({ + data: existing ?? { id: 'backup-id', created_at: 'c', updated_at: 'u' }, + error: null + }) + ); + return q; + } + if (table === 'user_backup_pins') { + const q = { + delete: vi.fn(() => q), + eq: vi.fn((col, val) => { + pinDeletes.push(val); + return Promise.resolve({ error: null }); + }) + }; + return q; + } + throw new Error(`Unexpected table: ${table}`); + }); + }); - it('refuses a backup write with the wrong PIN', async () => { - mocks.checkPin.mockResolvedValue({ status: 'wrong', remaining: 4 }); + afterEach(() => { + delete process.env.LEGACY_KEY_MIGRATION; + }); + + it('refuses to store a PIN-sealed (legacy format) backup', async () => { const { PUT } = await import('./route.js'); - const response = await PUT( - new Request('https://qrypt.chat/api/auth/key-backup', { - method: 'PUT', - headers: { authorization: 'Bearer access-token', 'content-type': 'application/json' }, - body: JSON.stringify({ encrypted_keys: '{"version":1}', pin: '0000' }) - }) - ); + const legacy = JSON.stringify({ version: '3.0', algorithm: 'ChaCha20-Poly1305-HKDF', encryptedKeys: 'x' }); + const response = await PUT(put({ encrypted_keys: legacy, pin: '4821' })); + expect(response.status).toBe(400); + expect((await response.json()).code).toBe('SEAL_REQUIRED'); + expect(mocks.serviceFrom).not.toHaveBeenCalled(); + }); + + it('stores a sealed backup as seal_version 2 and retires the PIN hash', async () => { + const { PUT } = await import('./route.js'); + const encrypted_keys = await sealedBackup(); + const response = await PUT(put({ encrypted_keys })); + expect(response.status).toBe(200); + expect((await response.json()).seal_version).toBe(2); + expect(upserts).toHaveLength(1); + expect(upserts[0]).toMatchObject({ user_id: USER_ID, encrypted_keys, seal_version: 2 }); + expect(pinDeletes).toEqual(['user-id']); + expect(mocks.checkPin).not.toHaveBeenCalled(); + }); + + it('needs the old PIN to replace a PIN backup the server can verify', async () => { + existing = { seal_version: 1 }; + mocks.checkPin.mockResolvedValue({ status: 'required' }); + const { PUT } = await import('./route.js'); + const response = await PUT(put({ encrypted_keys: await sealedBackup() })); expect(response.status).toBe(403); + expect((await response.json()).code).toBe('PIN_REQUIRED'); + expect(upserts).toHaveLength(0); + expect(pinDeletes).toHaveLength(0); + }); + + it('upgrades a PIN backup in place once the PIN checks out', async () => { + existing = { seal_version: 1 }; + mocks.checkPin.mockResolvedValue({ status: 'ok' }); + const { PUT } = await import('./route.js'); + const response = await PUT(put({ encrypted_keys: await sealedBackup(), pin: '4821' })); + expect(response.status).toBe(200); + expect(upserts[0].seal_version).toBe(2); + expect(pinDeletes).toEqual(['user-id']); + }); + + it('returns a sealed backup to its owner without any PIN (it opens only with the passphrase)', async () => { + const encrypted_keys = await sealedBackup(); + existing = { encrypted_keys, seal_version: 2, created_at: 'c', updated_at: 'u' }; + const { POST } = await import('./route.js'); + const response = await POST(post()); + const body = await response.json(); + expect(response.status).toBe(200); + expect(body.backup.encrypted_keys).toBe(encrypted_keys); + expect(body.backup.seal_version).toBe(2); + expect(body.user_id).toBe(USER_ID); + expect(body.legacy).toBe(false); + expect(mocks.checkPin).not.toHaveBeenCalled(); + }); + + it('stops serving PIN backups after the cutoff (LEGACY_KEY_MIGRATION=off)', async () => { + process.env.LEGACY_KEY_MIGRATION = 'off'; + existing = { seal_version: 1 }; + mocks.checkPin.mockResolvedValue({ status: 'ok' }); + const { POST } = await import('./route.js'); + const response = await POST(post({ pin: '4821' })); + const body = await response.json(); + expect(response.status).toBe(410); + expect(body.code).toBe('LEGACY_BACKUP_DISABLED'); + expect(body.backup).toBeUndefined(); + expect(mocks.checkPin).not.toHaveBeenCalled(); + }); +}); + +describe('no server-held key', () => { + it('the backup route and the seal module read no secret from the environment', () => { + const route = readFileSync(resolve('src/app/api/auth/key-backup/route.js'), 'utf8'); + const seal = readFileSync(resolve('src/lib/crypto/key-backup-seal.js'), 'utf8'); + const envNames = (src) => [...src.matchAll(/process\.env\.([A-Z0-9_]+)/g)].map((m) => m[1]); + expect(envNames(seal)).toEqual([]); + expect(new Set(envNames(route))).toEqual(new Set(['NEXT_PUBLIC_SUPABASE_URL', 'NEXT_PUBLIC_SUPABASE_ANON_KEY'])); }); }); diff --git a/src/app/auth/page.jsx b/src/app/auth/page.jsx index 0748572f..78fa11c7 100644 --- a/src/app/auth/page.jsx +++ b/src/app/auth/page.jsx @@ -11,6 +11,7 @@ import { keyManager } from '@/lib/crypto/key-manager.js'; import { privateKeyManager } from '@/lib/crypto/private-key-manager.js'; import { indexedDBManager } from '@/lib/crypto/indexed-db-manager.js'; import Message from '@/lib/components/Message.jsx'; +import KeyBackupSetup from '@/lib/components/KeyBackupSetup.jsx'; import { startAuthentication, startRegistration, browserSupportsWebAuthn } from '@simplewebauthn/browser'; /** Why a sign-in came back to /auth, in words a person can act on. */ @@ -43,9 +44,8 @@ export default function AuthPage() { const [canResend, setCanResend] = useState(true); const [verifiedSession, setVerifiedSession] = useState(null); const [avatarUrl, setAvatarUrl] = useState(null); - const [backupPin, setBackupPin] = useState(''); - const [confirmBackupPin, setConfirmBackupPin] = useState(''); - const [showBackupPin, setShowBackupPin] = useState(false); + const [backupLegacy, setBackupLegacy] = useState(false); + const [legacyPin, setLegacyPin] = useState(''); const [restorePin, setRestorePin] = useState(''); const [showRestorePin, setShowRestorePin] = useState(false); const [isRestoring, setIsRestoring] = useState(false); @@ -82,26 +82,22 @@ export default function AuthPage() { localStorage.setItem('supabase.auth.token', JSON.stringify(session)); await createSupabaseClient().auth.setSession(session); - const authHeaders = { Authorization: `Bearer ${session.access_token}` }; if (isNew) { try { await keyManager.generateUserKeys(); } catch {} setVerifiedSession(session); setStep('backup'); return; } const hasLocalKeys = await indexedDBManager.get('qryptchat_pq_keypair'); - let hasBackup = false; - try { hasBackup = (await fetch('/api/auth/key-backup', { headers: authHeaders })).ok; } catch {} - if (hasBackup && !hasLocalKeys) { setVerifiedSession(session); setStep('restore'); return; } - if (!hasLocalKeys && !hasBackup) { - // Brand-new keys for this account, then prompt to set a backup PIN. + const info = await privateKeyManager.getServerBackupInfo({ accessToken: session.access_token }); + if (info.exists && !hasLocalKeys) { setVerifiedSession(session); setStep('restore'); return; } + if (!hasLocalKeys && !info.exists) { + // Brand-new keys for this account, then prompt for a backup passphrase. try { await keyManager.generateUserKeys(); } catch {} setVerifiedSession(session); setStep('backup'); return; } - let hasPin = false; - try { - const r = await fetch('/api/auth/backup-pin', { headers: authHeaders }); - hasPin = r.ok ? (await r.json()).hasPin : false; - } catch {} - if (!hasPin && hasLocalKeys) { setVerifiedSession(session); setStep('backup'); return; } + // Keys here but no backup, or an old PIN backup: (re-)seal with a passphrase. + if (hasLocalKeys && (!info.exists || info.sealVersion < 2)) { + setBackupLegacy(info.exists); setVerifiedSession(session); setStep('backup'); return; + } router.push(afterSignInPath()); } @@ -234,19 +230,16 @@ export default function AuthPage() { router.push(afterSignInPath()); } else { const sessionToken = result.session?.access_token; - const authHeaders = sessionToken ? { 'Authorization': `Bearer ${sessionToken}` } : {}; try { const hasLocalKeys = await indexedDBManager.get('qryptchat_pq_keypair'); - const backupRes = await fetch('/api/auth/key-backup', { headers: authHeaders }); - const hasBackup = backupRes.ok; - const pinCheckRes = await fetch('/api/auth/backup-pin', { headers: authHeaders }); - const pinData = pinCheckRes.ok ? await pinCheckRes.json() : { hasPin: false }; - if (hasBackup && !hasLocalKeys) { + const info = await privateKeyManager.getServerBackupInfo({ accessToken: sessionToken }); + if (info.exists && !hasLocalKeys) { setVerifiedSession(result.session); setStep('restore'); return; } - if (!pinData.hasPin && hasLocalKeys) { + if (hasLocalKeys && (!info.exists || info.sealVersion < 2)) { + setBackupLegacy(info.exists); setVerifiedSession(result.session); setStep('backup'); return; @@ -281,38 +274,25 @@ export default function AuthPage() { try { await keyManager.generateUserKeys(); setStep('backup'); - msgStore.success('Account created! Please set a Backup PIN.'); + msgStore.success('Account created! Now back up your keys.'); } catch { msgStore.warning('Account created but failed to generate encryption keys.'); router.push(afterSignInPath()); } } else { msgStore.error(data.error || 'Failed to create account'); } } catch { msgStore.error('Failed to create account. Please try again.'); } } - 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; } - try { - const headers = { 'Content-Type': 'application/json' }; - if (verifiedSession?.access_token) headers['Authorization'] = `Bearer ${verifiedSession.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 backupHeaders = { 'Content-Type': 'application/json' }; - if (verifiedSession?.access_token) backupHeaders['Authorization'] = `Bearer ${verifiedSession.access_token}`; - const backupRes = await fetch('/api/auth/key-backup', { method: 'PUT', headers: backupHeaders, body: JSON.stringify({ encrypted_keys: encryptedData, pin: backupPin }) }); - if (!backupRes.ok) { const e = await backupRes.json().catch(() => ({})); throw new Error(e.error || 'Key backup failed'); } - msgStore.success('Backup PIN set and keys backed up! Welcome to QryptChat!'); - router.push(afterSignInPath()); - } catch (err) { msgStore.error('Failed to set backup PIN: ' + (err.message || 'Unknown error')); } - } - async function restoreWithPin(e) { e?.preventDefault(); if (!restorePin || restoreAttempts >= MAX_RESTORE) return; setIsRestoring(true); try { - await privateKeyManager.restoreKeysFromServer(restorePin); + const { legacy } = await privateKeyManager.restoreKeysFromServer(restorePin, { accessToken: verifiedSession?.access_token }); msgStore.success('Encryption keys restored successfully!'); + if (legacy) { + // An old PIN backup: re-seal it under a passphrase right away. + setLegacyPin(restorePin); setBackupLegacy(true); + setRestorePin(''); setRestoreAttempts(0); + setStep('backup'); return; + } setRestorePin(''); setRestoreAttempts(0); router.push(afterSignInPath()); } catch (err) { @@ -322,9 +302,9 @@ export default function AuthPage() { const remaining = MAX_RESTORE - restoreAttempts - 1; setRestoreAttempts((a) => a + 1); setRestorePin(''); - if (/wrong pin|too many/i.test(message)) msgStore.error(message); + if (/wrong|too many|older backup|no longer served/i.test(message)) msgStore.error(message); else if (remaining <= 0) msgStore.error('Too many failed attempts.'); - else msgStore.error(`Wrong PIN. ${remaining} attempt${remaining === 1 ? '' : 's'} remaining.`); + else msgStore.error(message || 'Restore failed.'); } finally { setIsRestoring(false); } } @@ -443,42 +423,27 @@ export default function AuthPage() { {step === 'backup' && (
-

Set Your Backup PIN

-

This PIN protects your encryption keys.

-

Important: Remember this PIN! Without it, you cannot restore your encryption keys.

-
-
- -
- setBackupPin(e.target.value.replace(/\D/g, '').slice(0, 12))} placeholder="Enter 4-12 digit PIN" required disabled={loading} className="code-input" /> - -
-
-
- -
- setConfirmBackupPin(e.target.value.replace(/\D/g, '').slice(0, 12))} placeholder="Confirm your PIN" required disabled={loading} className="code-input" /> -
-
-
- - -
-
+

{backupLegacy ? 'Upgrade Your Key Backup' : 'Back Up Your Encryption Keys'}

+ msgStore.error(m)} + onDone={() => { setLegacyPin(''); msgStore.success('Keys backed up. Welcome to QryptChat!'); router.push(afterSignInPath()); }} + onSkip={() => { setLegacyPin(''); msgStore.warning('Backup skipped. You can back up later in Settings.'); router.push(afterSignInPath()); }} + />
)} {step === 'restore' && (

Restore Your Encryption Keys

-

Enter your Backup PIN to restore your encryption keys on this device.

+

Enter your backup passphrase or recovery key (older backups: your PIN) to restore your encryption keys on this device. It is checked on this device; we never see it.

- +
- setRestorePin(e.target.value.replace(/\D/g, '').slice(0, 12))} placeholder="Enter your PIN" required disabled={isRestoring} className="code-input" /> + setRestorePin(e.target.value)} placeholder="Passphrase or recovery key" required disabled={isRestoring} className="code-input" />
diff --git a/src/app/cli/authorize/page.jsx b/src/app/cli/authorize/page.jsx index 7f33d927..b8f3e3e5 100644 --- a/src/app/cli/authorize/page.jsx +++ b/src/app/cli/authorize/page.jsx @@ -48,7 +48,7 @@ function Authorize() { const k1024 = postQuantumEncryption.userKeys; const k768 = postQuantumEncryption.userKeys768; if (!k1024?.publicKey || !k1024?.privateKey) { - throw new Error('This browser has no encryption keys. Restore them first (Settings > Keys, backup PIN), then run qc login again.'); + throw new Error('This browser has no encryption keys. Restore them first (Settings > Keys, backup passphrase), then run qc login again.'); } const keys = { keys1024: { ...k1024, algorithm: postQuantumEncryption.kemName }, diff --git a/src/lib/auth/backup-pin.js b/src/lib/auth/backup-pin.js index cf1398d6..a4392f9d 100644 --- a/src/lib/auth/backup-pin.js +++ b/src/lib/auth/backup-pin.js @@ -25,6 +25,16 @@ const SCRYPT_KEYLEN = 64; const SCRYPT_SALT_BYTES = 16; export const PIN_ALGORITHM = `scrypt-n${SCRYPT_N}-r${SCRYPT_R}-p${SCRYPT_P}`; +/** + * Old key backups (seal_version 1) are sealed under a 4-12 digit PIN. They stay + * restorable, behind the PIN check, until LEGACY_KEY_MIGRATION=off; that switch + * is the dated cutoff, after which only seal-v2 backups are served and no new + * PIN is accepted. + */ +export function legacyMigrationEnabled() { + return process.env.LEGACY_KEY_MIGRATION !== 'off'; +} + /** Wrong PINs allowed before each lock. */ export const ATTEMPTS_PER_LOCK = 5; const FIRST_LOCK_MS = 15 * 60 * 1000; diff --git a/src/lib/auth/cli-auth.js b/src/lib/auth/cli-auth.js index ae9a7988..5aa28aaa 100644 --- a/src/lib/auth/cli-auth.js +++ b/src/lib/auth/cli-auth.js @@ -134,7 +134,7 @@ export async function assertAccountKey(service, authUserId, publicKey) { if (data?.public_key && data.public_key !== publicKey) { throw new CliAuthError( 'key_mismatch', - "This browser's encryption keys are not your account's current keys, so a terminal signed in with them could not read your messages. Restore your keys (Settings > Keys, backup PIN) or approve from the device you normally chat on.", + "This browser's encryption keys are not your account's current keys, so a terminal signed in with them could not read your messages. Restore your keys (Settings > Keys, backup passphrase) or approve from the device you normally chat on.", 409 ); } diff --git a/src/lib/components/EncryptionWarning.jsx b/src/lib/components/EncryptionWarning.jsx index b1c6be16..955b958a 100644 --- a/src/lib/components/EncryptionWarning.jsx +++ b/src/lib/components/EncryptionWarning.jsx @@ -84,7 +84,7 @@ export default function EncryptionWarning() { This device has different keys from your account

Messages sent to you are encrypted to your account's key, which this browser doesn't hold, so they - won't open here. Restore your keys (backup PIN or key file) to read everything. + won't open here. Restore your keys (backup passphrase, recovery key or key file) to read everything.

{error &&

{error}

}
diff --git a/src/lib/components/KeyBackupSetup.jsx b/src/lib/components/KeyBackupSetup.jsx new file mode 100644 index 00000000..53591373 --- /dev/null +++ b/src/lib/components/KeyBackupSetup.jsx @@ -0,0 +1,130 @@ +'use client'; + +import { useState } from 'react'; +import { privateKeyManager } from '@/lib/crypto/private-key-manager.js'; +import { PASSPHRASE_MIN, passphraseProblem } from '@/lib/crypto/key-backup-seal.js'; + +/** + * Choose a backup passphrase, seal the keys in the browser, then show the + * one-time recovery key. Used at sign-up, on sign-in (to upgrade an old PIN + * backup) and in Settings. + * + * @param {{ + * accessToken?: string, + * legacy?: boolean, // an old PIN backup exists and is being replaced + * initialLegacyPin?: string, // that backup's PIN, when the user just restored with it + * onDone: () => void, + * onSkip?: () => void, + * onError?: (message: string) => void, + * compact?: boolean, // Settings card styling instead of the auth page's + * }} props + */ +export default function KeyBackupSetup({ accessToken, legacy = false, initialLegacyPin = '', onDone, onSkip, onError, compact = false }) { + const [passphrase, setPassphrase] = useState(''); + const [confirm, setConfirm] = useState(''); + const [legacyPin, setLegacyPin] = useState(initialLegacyPin); + const [needsPin, setNeedsPin] = useState(false); + const [show, setShow] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [recoveryKey, setRecoveryKey] = useState(''); + const [saved, setSaved] = useState(false); + + const fail = (message) => { + setError(message); + onError?.(message); + }; + + async function submit(e) { + e?.preventDefault(); + const problem = passphraseProblem(passphrase); + if (problem) return fail(problem); + if (passphrase !== confirm) return fail('Passphrases do not match'); + setBusy(true); + setError(''); + try { + const { recoveryKey: key } = await privateKeyManager.backupKeysToServer(passphrase, { + accessToken, + legacyPin: legacyPin || undefined + }); + setPassphrase(''); + setConfirm(''); + setLegacyPin(''); + setRecoveryKey(key); + } catch (err) { + if (err?.code === 'PIN_REQUIRED' || err?.code === 'PIN_WRONG') setNeedsPin(true); + fail(String(err?.message || 'Backup failed').replace(/^Failed to backup keys to server: /, '')); + } finally { + setBusy(false); + } + } + + const input = compact ? 'pkm-input' : 'code-input'; + const primary = compact ? 'btn btn-primary btn-sm' : 'primary-button'; + const secondary = compact ? 'btn btn-secondary btn-sm' : 'secondary-button'; + + if (recoveryKey) { + return ( +
+

+ Your recovery key. It opens your backup if you forget the passphrase. It is shown only + this once; we do not keep a copy and cannot recover your keys without it or the passphrase. +

+
{recoveryKey}
+
+ +
+ + + +
+ ); + } + + return ( + + {legacy && ( +

+ Your backup still uses a short PIN, which can be guessed if our database ever leaks. Choose a passphrase to + re-seal it. +

+ )} +

+ Your keys are sealed on this device with a passphrase (at least {PASSPHRASE_MIN} characters; a few words + works well). We never see it. If you lose both the passphrase and the recovery key we show next, your backup + is gone and older messages cannot be read. +

+
+ setPassphrase(e.target.value)} placeholder="Backup passphrase" disabled={busy} className={input} /> +
+
+ setConfirm(e.target.value)} placeholder="Confirm passphrase" disabled={busy} className={input} /> +
+ {needsPin && ( +
+ setLegacyPin(e.target.value.replace(/\D/g, '').slice(0, 12))} placeholder="Your old backup PIN (needed once to replace it)" disabled={busy} className={input} /> +
+ )} + + {error && compact &&

{error}

} +
+ + {onSkip && ( + + )} +
+ + ); +} diff --git a/src/lib/components/settings/PrivateKeyManager.jsx b/src/lib/components/settings/PrivateKeyManager.jsx index 8d62c2ff..215afd17 100644 --- a/src/lib/components/settings/PrivateKeyManager.jsx +++ b/src/lib/components/settings/PrivateKeyManager.jsx @@ -4,9 +4,7 @@ import { useState, useEffect } from 'react'; import { useAuthStore } from '@/lib/stores/auth.js'; import { keyManager } from '@/lib/crypto/key-manager.js'; import { privateKeyManager } from '@/lib/crypto/private-key-manager.js'; - -const PIN_PATTERN = /^\d{4,12}$/; -const digits = (v) => v.replace(/\D/g, '').slice(0, 12); +import KeyBackupSetup from '@/lib/components/KeyBackupSetup.jsx'; export default function PrivateKeyManager() { const user = useAuthStore((s) => s.user); @@ -14,18 +12,11 @@ export default function PrivateKeyManager() { const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const [hasKeys, setHasKeys] = useState(false); - const [hasPin, setHasPin] = useState(false); const [hasBackup, setHasBackup] = useState(false); - const [pinLoading, setPinLoading] = useState(true); + const [sealVersion, setSealVersion] = useState(null); const [backupLoading, setBackupLoading] = useState(true); - const [pin, setPin] = useState(''); - const [confirmPin, setConfirmPin] = useState(''); - const [currentPin, setCurrentPin] = useState(''); - const [importBackupPin, setImportBackupPin] = useState(''); - const [changingPin, setChangingPin] = useState(false); - const [backupPin, setBackupPin] = useState(''); - const [restorePin, setRestorePin] = useState(''); - const [showPin, setShowPin] = useState(false); + const [showBackupSetup, setShowBackupSetup] = useState(false); + const [restoreSecret, setRestoreSecret] = useState(''); const [importFile, setImportFile] = useState(null); const [importPassword, setImportPassword] = useState(''); const [importLoading, setImportLoading] = useState(false); @@ -36,24 +27,19 @@ export default function PrivateKeyManager() { const [exportLoading, setExportLoading] = useState(false); useEffect(() => { - if (user) { checkKeys(); checkPin(); checkBackup(); } + if (user) { checkKeys(); checkBackup(); } }, [user?.id]); async function checkKeys() { try { await keyManager.initialize(); setHasKeys(await keyManager.hasUserKeys()); } catch {} } - async function checkPin() { - try { - setPinLoading(true); - const res = await fetch('/api/auth/backup-pin', { credentials: 'include', headers: privateKeyManager._authHeaders() }); - if (res.ok) { const d = await res.json(); setHasPin(d.hasPin); } - } catch {} finally { setPinLoading(false); } - } - async function checkBackup() { - try { setBackupLoading(true); setHasBackup(await privateKeyManager.hasServerBackup()); } - catch {} finally { setBackupLoading(false); } + try { + setBackupLoading(true); + const info = await privateKeyManager.getServerBackupInfo(); + setHasBackup(info.exists); setSealVersion(info.sealVersion); + } catch {} finally { setBackupLoading(false); } } async function generateKeys() { @@ -66,44 +52,30 @@ export default function PrivateKeyManager() { finally { setLoading(false); } } - async function backupKeys() { - if (!backupPin) return; - setLoading(true); setError(''); setSuccess(''); - try { - await privateKeyManager.backupKeysToServer(backupPin); - setHasBackup(true); setHasPin(true); setBackupPin(''); setSuccess('Keys backed up!'); - } catch (err) { setError((err.message || 'Backup failed').replace(/^Failed to backup keys to server: /, '')); } - finally { setLoading(false); } - } - async function restoreKeys() { - if (!restorePin) return; + if (!restoreSecret) return; setLoading(true); setError(''); setSuccess(''); try { - await privateKeyManager.restoreKeysFromServer(restorePin); - setHasKeys(true); setRestorePin(''); setSuccess('Keys restored!'); - } catch (err) { setError((err.message || 'Wrong PIN or restore failed').replace(/^Failed to restore keys from server: /, '')); } + const { legacy } = await privateKeyManager.restoreKeysFromServer(restoreSecret); + setHasKeys(true); setRestoreSecret(''); + if (legacy) { setShowBackupSetup(true); setSuccess('Keys restored. Now re-seal your backup with a passphrase.'); } + else setSuccess('Keys restored!'); + } catch (err) { setError((err.message || 'Restore failed').replace(/^Failed to restore keys from server: /, '')); } finally { setLoading(false); } } async function importFromFile() { if (!importFile || !importPassword) return; - if (importAndBackup && !PIN_PATTERN.test(importBackupPin)) { setError('Enter your 4–12 digit backup PIN to save a server backup'); return; } setImportLoading(true); setError(''); setSuccess(''); try { const text = await importFile.text(); await privateKeyManager.importPrivateKeys(text, importPassword); setHasKeys(true); - if (importAndBackup) { - await privateKeyManager.backupKeysToServer(importBackupPin); - setHasBackup(true); setHasPin(true); - } - setImportFile(null); setImportPassword(''); - setImportBackupPin(''); - setSuccess(`Keys imported${importAndBackup ? ' and backed up to server' : ''} successfully!`); + if (importAndBackup) setShowBackupSetup(true); + setSuccess(`Keys imported successfully!${importAndBackup ? ' Choose a backup passphrase below to save them to the server.' : ''}`); } catch (err) { setError(err.message || 'Import failed β€” wrong password or invalid file'); } finally { setImportLoading(false); } @@ -123,37 +95,6 @@ export default function PrivateKeyManager() { finally { setExportLoading(false); } } - async function setNewPin() { - if (!pin || pin !== confirmPin || !PIN_PATTERN.test(pin)) { setError('PIN must be 4–12 digits and match'); return; } - if (hasPin && !currentPin) { setError('Enter your current PIN'); return; } - // The server backup is encrypted with the PIN, so a new PIN means a new - // backup; without keys on this device the old backup could not be redone. - if (hasPin && hasBackup && !hasKeys) { setError('Restore your keys on this device before changing the PIN'); return; } - setLoading(true); setError(''); setSuccess(''); - try { - const res = await fetch('/api/auth/backup-pin', { - method: 'POST', credentials: 'include', - headers: { 'Content-Type': 'application/json', ...privateKeyManager._authHeaders() }, - body: JSON.stringify({ pin, ...(hasPin ? { currentPin } : {}) }), - }); - if (!res.ok) { - const d = await res.json().catch(() => ({})); - setError(d.code === 'PIN_WRONG' && typeof d.remaining === 'number' - ? `Current PIN is wrong. ${d.remaining} attempt${d.remaining === 1 ? '' : 's'} left before a lockout.` - : d.code === 'PIN_LOCKED' ? `Too many wrong PINs. Try again in ${Math.max(1, Math.ceil((d.retryAfter || 60) / 60))} min.` - : d.error || 'Failed to set PIN'); - return; - } - let note = 'Backup PIN set!'; - if (hasBackup && hasKeys) { - try { await privateKeyManager.backupKeysToServer(pin); note = 'Backup PIN changed and server backup re-encrypted.'; } - catch { note = 'PIN changed, but re-encrypting the server backup failed. Use "Update Backup" below.'; } - } - setHasPin(true); setPin(''); setConfirmPin(''); setCurrentPin(''); setChangingPin(false); setSuccess(note); - } catch (err) { setError(err.message || 'Failed'); } - finally { setLoading(false); } - } - const dot = (on) => (
); @@ -171,50 +112,38 @@ export default function PrivateKeyManager() { {!hasKeys && }
-
-

Backup PIN

- {pinLoading ? Checking... : ( - <> -
- {dot(hasPin)}{hasPin ? 'Backup PIN is set' : 'No backup PIN configured'} -
- {(!hasPin || changingPin) && ( -
- {hasPin && setCurrentPin(digits(e.target.value))} placeholder="Current PIN" className="pkm-input" />} - setPin(digits(e.target.value))} placeholder={hasPin ? 'New 4–12 digit PIN' : '4–12 digit PIN'} className="pkm-input" /> - setConfirmPin(digits(e.target.value))} placeholder="Confirm PIN" className="pkm-input" /> -

4 digits works: wrong guesses lock restore for longer each time. 6 or more is stronger.

- -
- - {changingPin && } -
-
- )} - {hasPin && !changingPin && } - - )} -
-

Server Key Backup / Restore

{backupLoading ? Checking... : ( <>
- {dot(hasBackup)}{hasBackup ? 'Encrypted backup exists on server' : 'No server backup'} + {dot(hasBackup && sealVersion >= 2)} + {!hasBackup ? 'No server backup' : sealVersion >= 2 ? 'Backup sealed with your passphrase' : 'Old PIN backup: re-seal it with a passphrase'}
- {hasKeys && ( +

+ Backups are sealed on this device. We store only the sealed copy and cannot open it: if you lose both the + passphrase and the recovery key, the backup is gone and you will need to re-import your keys from a file + or start with new keys. +

+ {hasKeys && (showBackupSetup || !hasBackup || sealVersion < 2 ? (
-

Backup your keys (encrypted with your backup PIN):

- setBackupPin(digits(e.target.value))} placeholder={hasPin ? 'Your backup PIN' : 'Choose a 4–12 digit backup PIN'} className="pkm-input" style={{ marginBottom: '.5rem' }} /> - + { setShowBackupSetup(false); setHasBackup(true); setSealVersion(2); setSuccess('Keys backed up!'); }} + onSkip={hasBackup && sealVersion >= 2 ? () => setShowBackupSetup(false) : undefined} + />
- )} + ) : ( + + ))} {hasBackup && (
-

Restore keys from server backup:

- setRestorePin(digits(e.target.value))} placeholder="Backup PIN" className="pkm-input" style={{ marginBottom: '.5rem' }} /> - +

Restore keys from the server backup:

+ setRestoreSecret(e.target.value)} placeholder={sealVersion >= 2 ? 'Passphrase or recovery key' : 'Old backup PIN'} className="pkm-input" style={{ marginBottom: '.5rem' }} /> +
)} @@ -292,9 +221,6 @@ export default function PrivateKeyManager() { /> Also save encrypted backup to server - {importAndBackup && ( - setImportBackupPin(digits(e.target.value))} placeholder={hasPin ? 'Your backup PIN' : 'Choose a 4–12 digit backup PIN'} className="pkm-input" /> - )}