@@ -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.
-
+
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.
-
+
{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.