diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 026d9bc..d93a2dc 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -53,12 +53,16 @@ auth.post('/verify', zValidator('json', verifySchema), async (c) => { return c.json(errorResponse, 400); } - db.run('DELETE FROM challenges WHERE id = ?', [challengeId]); - + // Check expiry BEFORE deleting so we can give a distinct response + // and so the row is still present for any retry within the window. if (Date.now() > challengeEntry.expires_at) { - return c.json(errorResponse, 400); + db.run('DELETE FROM challenges WHERE id = ?', [challengeId]); + return c.json({ error: 'challenge_expired' }, 400); } + // Consume the challenge (single-use) + db.run('DELETE FROM challenges WHERE id = ?', [challengeId]); + const numberEntry = db .query('SELECT * FROM numbers WHERE number = ?') .get(number) as { diff --git a/src/pages/Entry.jsx b/src/pages/Entry.jsx index fb70d3d..abb3fbb 100644 --- a/src/pages/Entry.jsx +++ b/src/pages/Entry.jsx @@ -1,13 +1,12 @@ import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { requestChallenge, verifyChallenge } from '../services/api'; +import { verifyConnection } from '../services/api'; import { clearVault, getStoredKeys, getStoredNumber, getVaultMode, hasVault, - signChallenge, unlockVault, } from '../services/crypto'; import Setup from './Setup'; @@ -15,7 +14,8 @@ import Unlock from './Unlock'; export default function Entry() { const [loading, setLoading] = useState(true); - const [vaultState, setVaultState] = useState(null); // 'none', 'silent', 'password' + const [loadingStatus, setLoadingStatus] = useState('Initializing…'); + const [vaultState, setVaultState] = useState(null); // 'none' | 'silent' | 'password' const [error, setError] = useState(null); const navigate = useNavigate(); const effectRan = useRef(false); @@ -26,6 +26,7 @@ export default function Entry() { checkVault(); }, []); + // ── Decide which flow to enter ──────────────────────────────────────────── const checkVault = async () => { try { const exists = await hasVault(); @@ -38,41 +39,62 @@ export default function Entry() { const mode = await getVaultMode(); if (mode === 'silent') { - // Auto unlock await attemptSilentUnlock(); } else { + // Password mode — hand off to setVaultState('password'); setLoading(false); } } catch (e) { console.error(e); - setError(e.message); + setError(e.message || 'Unexpected error during startup.'); setLoading(false); } }; + // ── Silent-mode auto-unlock + server verification ───────────────────────── const attemptSilentUnlock = async () => { try { - await unlockVault(); - await performAuth(); + setLoadingStatus('Unlocking vault…'); + await unlockVault(); // no password needed in silent mode + + setLoadingStatus('Loading credentials…'); + const number = await getStoredNumber(); + const keys = await getStoredKeys(); + if (!number || !keys) throw new Error('Vault is empty or corrupted.'); + + // ── VERIFICATION — challenge-response ────────────────────────────── + setLoadingStatus('Verifying credentials with server…'); + await verifyConnection(number, keys.signing.private); + navigate('/'); } catch (e) { - console.error('Silent unlock failed', e); - setError('Silent unlock failed: ' + e.message); - setLoading(false); - } - }; + console.error('Silent unlock failed:', e); + + const msg = e.message || ''; + let friendlyMsg; - const performAuth = async () => { - const number = await getStoredNumber(); - const keys = await getStoredKeys(); - if (!number || !keys) throw new Error('Vault empty'); + if ( + msg === 'invalid' || + msg === 'challenge_expired' || + msg.includes('rejected') + ) { + friendlyMsg = + 'Server rejected your credentials. ' + + 'Your keys may be out of sync with the server.'; + } else if (msg.includes('Cannot reach server')) { + friendlyMsg = + 'Cannot reach the server. Make sure it is running, then retry.'; + } else { + friendlyMsg = msg || 'Silent unlock failed.'; + } - const { challengeId, challenge } = await requestChallenge(number); - const signature = await signChallenge(keys.signing.private, challenge); - await verifyChallenge(challengeId, number, signature); + setError(friendlyMsg); + setLoading(false); + } }; + // ── Loading screen ──────────────────────────────────────────────────────── if (loading) { return (
@@ -81,39 +103,60 @@ export default function Entry() {
B
-
Initializing Bloop
-
- Securing your vault and preparing the call stack. -
- +
Bloop
+
{loadingStatus}
+
); } - const handleReset = async () => { - await clearVault(); - window.location.reload(); - }; - + // ── Error screen ────────────────────────────────────────────────────────── if (error) { + const isServerError = error.includes('server'); return (
-
Vault Error
-
{error}
+
+
+ ! +
+
+ {isServerError ? 'Connection Error' : 'Vault Error'} +
+
+ +
{error}
+
@@ -124,12 +167,13 @@ export default function Entry() { ); } + // ── Route to the right UI ───────────────────────────────────────────────── if (vaultState === 'none') { return navigate('/')} />; } if (vaultState === 'password') { - return navigate('/')} />; + return navigate('/')} />; } return null; diff --git a/src/pages/Setup.jsx b/src/pages/Setup.jsx index 274bebc..0295bbb 100644 --- a/src/pages/Setup.jsx +++ b/src/pages/Setup.jsx @@ -1,27 +1,34 @@ import { useState, useTransition } from 'react'; import { useNavigate } from 'react-router-dom'; -import { activateNumber, issueNumber } from '../services/api'; +import { activateNumber, issueNumber, verifyConnection } from '../services/api'; import { generateKeys, + getStoredKeys, + getStoredNumber, importVaultExport, setupVault, } from '../services/crypto'; export default function Setup({ onComplete }) { - const [step, setStep] = useState('input'); // input, mode, processing + const [step, setStep] = useState('input'); // input | mode | processing const [number, setNumber] = useState(''); const [code, setCode] = useState(''); - const [mode, setMode] = useState('silent'); // silent, password + const [mode, setMode] = useState('silent'); // silent | password const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [error, setError] = useState(null); + const [status, setStatus] = useState(''); + + // Import flow const [importData, setImportData] = useState(null); const [importPassword, setImportPassword] = useState(''); const [importError, setImportError] = useState(null); const [importFileName, setImportFileName] = useState(''); + const [isPending, startTransition] = useTransition(); const navigate = useNavigate(); + // ── Issue a brand-new number ────────────────────────────────────────────── const handleIssue = async () => { try { setError(null); @@ -33,6 +40,7 @@ export default function Setup({ onComplete }) { } }; + // ── Activate + verify + save vault ─────────────────────────────────────── const handleActivateAndSetup = () => { if (mode === 'password' && password !== confirmPassword) { setError('Passwords do not match'); @@ -45,22 +53,50 @@ export default function Setup({ onComplete }) { startTransition(async () => { setError(null); + try { + // 1. Generate a fresh key-pair on this device + setStatus('Generating keys…'); const keys = await generateKeys(); + // 2. Register the public keys with the server (uses the activation code) + setStatus('Activating number…'); await activateNumber(code, keys.signing.public, keys.encryption.public); + // 3. ── VERIFICATION STEP ────────────────────────────────────────── + // Run a full challenge-response round-trip RIGHT NOW to confirm + // the server accepted the keys and that signing works correctly. + // If this fails the user will see a clear error and nothing is saved. + setStatus('Verifying connection…'); + await verifyConnection(number, keys.signing.private); + + // 4. Only now – everything checks out – persist the vault locally + setStatus('Saving vault…'); await setupVault(number, mode, password, keys); + setStatus(''); if (onComplete) onComplete(); else navigate('/'); } catch (e) { console.error(e); - setError(e.message || 'Setup failed'); + setStatus(''); + + // Give the user a human-readable message depending on what failed + const msg = e.message || ''; + if (msg === 'invalid_code' || msg.includes('invalid_code')) { + setError('Invalid activation code. Please check and try again.'); + } else if (msg === 'invalid' || msg === 'challenge_expired') { + setError('Verification failed — the server rejected the credentials.'); + } else if (msg.includes('Cannot reach server')) { + setError('Cannot reach the server. Make sure it is running.'); + } else { + setError(msg || 'Setup failed. Please try again.'); + } } }); }; + // ── Import a .bloop backup file ─────────────────────────────────────────── const handleImportFile = async (file) => { if (!file) return; try { @@ -88,19 +124,46 @@ export default function Setup({ onComplete }) { startTransition(async () => { setImportError(null); try { + // 1. Decrypt and restore the vault locally await importVaultExport( importData, requiresPassword ? importPassword : null, ); + + // 2. ── VERIFICATION STEP ────────────────────────────────────────── + // After import, check that the restored keys are accepted by the + // server before we let the user in. + setImportError(null); + const [storedNumber, storedKeys] = await Promise.all([ + getStoredNumber(), + getStoredKeys(), + ]); + if (!storedNumber || !storedKeys) { + throw new Error('Imported vault is empty or corrupted.'); + } + await verifyConnection(storedNumber, storedKeys.signing.private); + if (onComplete) onComplete(); else navigate('/'); } catch (e) { console.error(e); - setImportError(e.message || 'Import failed'); + const msg = e.message || ''; + if (msg === 'invalid' || msg.includes('verification')) { + setImportError( + 'Verification failed — the credentials in this file were rejected by the server.', + ); + } else if (msg.includes('Cannot reach server')) { + setImportError( + 'Cannot reach the server. Make sure it is running.', + ); + } else { + setImportError(msg || 'Import failed'); + } } }); }; + // ── Render: step = input ────────────────────────────────────────────────── if (step === 'input') { return (
@@ -117,6 +180,7 @@ export default function Setup({ onComplete }) {
+
+
+
Or
+
Import a connection file to restore an existing number. @@ -167,6 +234,7 @@ export default function Setup({ onComplete }) { />
+
+ {error &&
{error}
}
@@ -183,6 +252,7 @@ export default function Setup({ onComplete }) { ); } + // ── Render: step = import ───────────────────────────────────────────────── if (step === 'import') { const requiresPassword = importData?.mode === 'password' || importData?.encrypted; @@ -201,6 +271,7 @@ export default function Setup({ onComplete }) { +
@@ -224,6 +295,7 @@ export default function Setup({ onComplete }) {
)}
+ {requiresPassword && ( setImportPassword(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleImport()} /> )} + + {/* Status while verifying */} + {isPending && ( +
+ + Verifying credentials with server… +
+ )}
+
@@ -247,12 +330,13 @@ export default function Setup({ onComplete }) { disabled={isPending || (requiresPassword && !importPassword)} > {isPending ? ( - + ) : ( 'Import & Connect' )}
+ {importError && (
{importError}
)} @@ -262,6 +346,7 @@ export default function Setup({ onComplete }) { ); } + // ── Render: step = mode (security selection) ────────────────────────────── return (
@@ -277,6 +362,7 @@ export default function Setup({ onComplete }) {
+
diff --git a/src/pages/Unlock.jsx b/src/pages/Unlock.jsx index a69f938..efda3de 100644 --- a/src/pages/Unlock.jsx +++ b/src/pages/Unlock.jsx @@ -1,10 +1,9 @@ import { useState, useTransition } from 'react'; import { useNavigate } from 'react-router-dom'; -import { requestChallenge, verifyChallenge } from '../services/api'; +import { verifyConnection } from '../services/api'; import { getStoredKeys, getStoredNumber, - signChallenge, unlockVault, } from '../services/crypto'; @@ -18,26 +17,47 @@ export default function Unlock({ onUnlock }) { const handleUnlock = () => { startTransition(async () => { setError(null); - setStatus('Unlocking vault...'); + setStatus(''); + try { + // ── Step 1: decrypt the local vault with the password ────────────── + setStatus('Unlocking vault…'); await unlockVault(password); - setStatus('Authenticating...'); + // ── Step 2: load number + keys from the now-unlocked vault ───────── + setStatus('Loading credentials…'); const number = await getStoredNumber(); const keys = await getStoredKeys(); + if (!number || !keys) throw new Error('Vault is empty or corrupted.'); - if (!number || !keys) throw new Error('Vault corrupted or empty'); - - const { challengeId, challenge } = await requestChallenge(number); - const signature = await signChallenge(keys.signing.private, challenge); - await verifyChallenge(challengeId, number, signature); + // ── Step 3: VERIFICATION — challenge-response with the server ─────── + // Only if this succeeds do we let the user through. + setStatus('Verifying credentials with server…'); + await verifyConnection(number, keys.signing.private); + setStatus(''); if (onUnlock) onUnlock(); else navigate('/'); } catch (e) { console.error(e); - setError(e.message || 'Unlock failed'); setStatus(''); + + const msg = e.message || ''; + if (msg === 'Invalid password') { + setError('Incorrect password. Please try again.'); + } else if ( + msg === 'invalid' || + msg === 'challenge_expired' || + msg.includes('rejected') + ) { + setError( + 'Server rejected credentials. Your keys may be out of sync.', + ); + } else if (msg.includes('Cannot reach server')) { + setError('Cannot reach the server. Make sure it is running.'); + } else { + setError(msg || 'Unlock failed. Please try again.'); + } } }); }; @@ -57,15 +77,26 @@ export default function Unlock({ onUnlock }) { + setPassword(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleUnlock()} + onKeyDown={(e) => e.key === 'Enter' && !isPending && handleUnlock()} + disabled={isPending} autoFocus /> + + {/* Granular step feedback */} + {isPending && status && ( +
+ + {status} +
+ )} +
- {status && ( -
{status}
- )} + {error &&
{error}
} diff --git a/src/services/api.js b/src/services/api.js index c02b718..2fe77aa 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -1,64 +1,87 @@ +import { signChallenge } from './crypto.js'; + const API_URL = 'http://localhost:3000/api'; -export async function issueNumber() { - const res = await fetch(`${API_URL}/admin/issue-number`, { - method: 'POST', - }); +// ─── Low-level fetch wrapper ────────────────────────────────────────────────── +// Wraps every fetch so that: +// • Network failures (server unreachable) surface as a clear message. +// • Non-2xx responses are always parsed and their `error` field is thrown. + +async function apiFetch(path, options = {}) { + let res; + try { + res = await fetch(`${API_URL}${path}`, options); + } catch { + // fetch() itself threw – server is unreachable or the network is down. + throw new Error('Cannot reach server. Check that it is running.'); + } + + if (!res.ok) { + let errMsg = `Server error (${res.status})`; + try { + const body = await res.json(); + if (body?.error) errMsg = body.error; + } catch { + // body wasn't JSON – keep the generic message above + } + throw new Error(errMsg); + } + return res.json(); } +// ─── Public API ─────────────────────────────────────────────────────────────── + +export async function issueNumber() { + return apiFetch('/admin/issue-number', { method: 'POST' }); +} + export async function activateNumber(code, signingKey, encryptionKey) { - const res = await fetch(`${API_URL}/activate`, { + return apiFetch('/activate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - code, - signingKey, - encryptionKey, - }), + body: JSON.stringify({ code, signingKey, encryptionKey }), }); - - if (!res.ok) { - const err = await res.json(); - throw new Error(err.error || 'Activation failed'); - } - - return res.json(); } export async function requestChallenge(number) { - const res = await fetch(`${API_URL}/auth/challenge`, { + return apiFetch('/auth/challenge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ number }), }); - return res.json(); } export async function verifyChallenge(challengeId, number, signature) { - const res = await fetch(`${API_URL}/auth/verify`, { + return apiFetch('/auth/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - challengeId, - number, - signature, - }), + body: JSON.stringify({ challengeId, number, signature }), }); - - if (!res.ok) { - const err = await res.json(); - throw new Error(err.error || 'Verification failed'); - } - - return res.json(); } export async function getKeys(number) { - const res = await fetch(`${API_URL}/keys/${number}`); - if (!res.ok) { - const err = await res.json(); - throw new Error(err.error || 'Not found'); + return apiFetch(`/keys/${number}`); +} + +// ─── Connection verification ────────────────────────────────────────────────── +// Performs a full challenge-response round-trip for `number` using the supplied +// private signing key JWK. Resolves on success, rejects with a descriptive +// Error on any failure (wrong credentials, expired challenge, network error …). + +export async function verifyConnection(number, privateSigningKeyJwk) { + // Step 1 – ask the server for a challenge + const { challengeId, challenge } = await requestChallenge(number); + + // Step 2 – sign it with the local private key + let signature; + try { + signature = await signChallenge(privateSigningKeyJwk, challenge); + } catch { + throw new Error('Failed to sign challenge – keys may be corrupted.'); } - return res.json(); + + // Step 3 – send the signature back; server verifies & returns { verified, number } + const result = await verifyChallenge(challengeId, number, signature); + return result; // { verified: true, number } }