Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions server/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
112 changes: 78 additions & 34 deletions src/pages/Entry.jsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
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';
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);
Expand All @@ -26,6 +26,7 @@ export default function Entry() {
checkVault();
}, []);

// ── Decide which flow to enter ────────────────────────────────────────────
const checkVault = async () => {
try {
const exists = await hasVault();
Expand All @@ -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 <Unlock>
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 (
<div className="min-h-screen flex items-center justify-center bg-base-100 px-4">
Expand All @@ -81,39 +103,60 @@ export default function Entry() {
<div className="h-12 w-12 rounded-2xl bg-primary text-primary-content flex items-center justify-center text-xl font-semibold">
B
</div>
<div className="text-lg font-semibold">Initializing Bloop</div>
<div className="text-sm text-base-content/60">
Securing your vault and preparing the call stack.
</div>
<span className="loading loading-spinner loading-md"></span>
<div className="text-lg font-semibold">Bloop</div>
<div className="text-sm text-base-content/60">{loadingStatus}</div>
<span className="loading loading-spinner loading-md" />
</div>
</div>
</div>
);
}

const handleReset = async () => {
await clearVault();
window.location.reload();
};

// ── Error screen ──────────────────────────────────────────────────────────
if (error) {
const isServerError = error.includes('server');
return (
<div className="min-h-screen flex items-center justify-center bg-base-100 px-4">
<div className="card bg-base-100 border border-base-200 shadow-md w-full max-w-md">
<div className="card-body gap-4">
<div className="text-xl font-semibold">Vault Error</div>
<div className="alert alert-error">{error}</div>
<div className="flex items-center gap-3">
<div className="h-11 w-11 rounded-2xl bg-error/10 text-error flex items-center justify-center text-lg font-semibold">
!
</div>
<div className="text-xl font-semibold">
{isServerError ? 'Connection Error' : 'Vault Error'}
</div>
</div>

<div className="alert alert-error text-sm">{error}</div>

<div className="grid grid-cols-2 gap-2">
<button
className="btn btn-outline"
onClick={() => window.location.reload()}
onClick={() => {
setError(null);
setLoading(true);
setLoadingStatus('Retrying…');
effectRan.current = false;
checkVault().finally(() => {
// checkVault sets loading=false internally on success/failure
});
}}
>
Retry
</button>
<button
className="btn btn-error btn-outline"
onClick={handleReset}
onClick={async () => {
if (
window.confirm(
'This will permanently delete your local vault.\n\nYour number will still exist on the server — you will need a new activation code to re-register.\n\nAre you sure?',
)
) {
await clearVault();
window.location.reload();
}
}}
>
Reset Vault
</button>
Expand All @@ -124,12 +167,13 @@ export default function Entry() {
);
}

// ── Route to the right UI ─────────────────────────────────────────────────
if (vaultState === 'none') {
return <Setup onComplete={() => navigate('/')} />;
}

if (vaultState === 'password') {
return <Unlock mode="password" onUnlock={() => navigate('/')} />;
return <Unlock onUnlock={() => navigate('/')} />;
}

return null;
Expand Down
Loading