feat: implement WebAuthn Passkeys register/login API and client onboarding UI

This commit is contained in:
2026-05-27 21:28:15 +02:00
parent db8b454a9e
commit 35479cfff3
11 changed files with 1276 additions and 295 deletions
+2 -2
View File
@@ -29,7 +29,7 @@ Kapteins Daagbox will be built in four logical phases following a clean data-to-
Plans: Plans:
- [x] 01-01: Initialize Vite React TS client, Node.js Express TS server, Prisma PostgreSQL schema, and react-i18next locales. - [x] 01-01: Initialize Vite React TS client, Node.js Express TS server, Prisma PostgreSQL schema, and react-i18next locales.
- [ ] 01-02: Implement WebAuthn backend/frontend flows using SimpleWebAuthn library to register and log in users. - [x] 01-02: Implement WebAuthn backend/frontend flows using SimpleWebAuthn library to register and log in users.
- [ ] 01-03: Setup client-side Web Crypto helper deriving E2E keys from biometric PRF credentials or a 12-word recovery phrase. - [ ] 01-03: Setup client-side Web Crypto helper deriving E2E keys from biometric PRF credentials or a 12-word recovery phrase.
### Phase 2: Sync Protocol & Multi-Logbooks ### Phase 2: Sync Protocol & Multi-Logbooks
@@ -82,7 +82,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4
| Phase | Plans Complete | Status | Completed | | Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------| |-------|----------------|--------|-----------|
| 1. Foundation, Auth & E2E Crypto | 1/3 | In progress | - | | 1. Foundation, Auth & E2E Crypto | 2/3 | In progress | - |
| 2. Sync Protocol & Multi-Logbooks | 0/2 | Not started | - | | 2. Sync Protocol & Multi-Logbooks | 0/2 | Not started | - |
| 3. Master Data & Log entries | 0/3 | Not started | - | | 3. Master Data & Log entries | 0/3 | Not started | - |
| 4. CSV Export & UI Polish | 0/2 | Not started | - | | 4. CSV Export & UI Polish | 0/2 | Not started | - |
+7 -7
View File
@@ -10,30 +10,30 @@ See: .planning/PROJECT.md (updated 2026-05-26)
## Current Position ## Current Position
Phase: 1 of 4 (Foundation, Auth & E2E Crypto) Phase: 1 of 4 (Foundation, Auth & E2E Crypto)
Plan: 2 of 3 in current phase Plan: 3 of 3 in current phase
Status: Ready to plan Status: Ready to plan
Last activity: 2026-05-27 — Plan 01-01 executed (Monorepo codebase, Vite React client, Express server, Prisma schema setup) Last activity: 2026-05-27 — Plan 01-02 executed (WebAuthn Passkey registration/login backend APIs, crypto key derivations, onboarding UI panel)
Progress: [█░░░░░░░░] 10% Progress: [█░░░░░░░░] 20%
## Performance Metrics ## Performance Metrics
**Velocity:** **Velocity:**
- Total plans completed: 1 - Total plans completed: 2
- Average duration: 15 min - Average duration: 15 min
- Total execution time: 0.25 hours - Total execution time: 0.5 hours
**By Phase:** **By Phase:**
| Phase | Plans | Total | Avg/Plan | | Phase | Plans | Total | Avg/Plan |
|-------|-------|-------|----------| |-------|-------|-------|----------|
| 1. Foundation, Auth & E2E Crypto | 1/3 | - | - | | 1. Foundation, Auth & E2E Crypto | 2/3 | - | - |
| 2. Sync Protocol & Multi-Logbooks | 0/2 | - | - | | 2. Sync Protocol & Multi-Logbooks | 0/2 | - | - |
| 3. Master Data & Log entries | 0/3 | - | - | | 3. Master Data & Log entries | 0/3 | - | - |
| 4. CSV Export & UI Polish | 0/2 | - | - | | 4. CSV Export & UI Polish | 0/2 | - | - |
**Recent Trend:** **Recent Trend:**
- Last 5 plans: [01-01] - Last 5 plans: [01-01, 01-02]
- Trend: Stable - Trend: Stable
*Updated after each plan completion* *Updated after each plan completion*
+287 -149
View File
@@ -1,184 +1,322 @@
.counter { /* Kapteins Daagbox App styling */
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover { body {
border-color: var(--accent-border); background: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
} min-height: 100vh;
&:focus-visible { margin: 0;
outline: 2px solid var(--accent); display: flex;
outline-offset: 2px; justify-content: center;
} align-items: center;
font-family: system-ui, -apple-system, sans-serif;
} }
.hero { /* Glassmorphism Auth Card */
position: relative; .auth-card {
background: rgba(11, 12, 16, 0.75);
.base, backdrop-filter: blur(20px);
.framework, -webkit-backdrop-filter: blur(20px);
.vite { border: 1px solid rgba(212, 175, 55, 0.25);
inset-inline: 0; border-radius: 20px;
margin: 0 auto; padding: 40px;
} width: 450px;
max-width: 90%;
.base { box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 25px; align-items: center;
place-content: center; box-sizing: border-box;
place-items: center; animation: fadeIn 0.4s ease-out;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
} }
#next-steps { @keyframes fadeIn {
display: flex; from { opacity: 0; transform: translateY(15px); }
border-top: 1px solid var(--border); to { opacity: 1; transform: translateY(0); }
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
} }
.icon { .auth-brand {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center; text-align: center;
} margin-bottom: 30px;
width: 100%;
} }
#docs { .auth-logo-img {
border-right: 1px solid var(--border); width: 100px;
height: 100px;
@media (max-width: 1024px) { object-fit: contain;
border-right: none; filter: drop-shadow(0 4px 12px rgba(212, 175, 55, 0.35));
border-bottom: 1px solid var(--border); margin-bottom: 15px;
}
} }
#next-steps ul { .auth-brand h1 {
list-style: none; font-size: 32px;
padding: 0; font-weight: 700;
color: #f8fafc;
background: linear-gradient(135deg, #fef08a 0%, #d97706 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
margin: 0 0 8px 0;
letter-spacing: -0.5px;
}
.auth-brand .tagline {
font-size: 14.5px;
color: #94a3b8;
margin: 0;
line-height: 140%;
}
.auth-form {
width: 100%;
display: flex; display: flex;
gap: 8px; flex-direction: column;
margin: 32px 0 0; gap: 20px;
.logo {
height: 18px;
} }
a { .input-group {
color: var(--text-h); position: relative;
width: 100%;
}
.input-text {
width: 100%;
background: rgba(11, 12, 16, 0.85);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 10px;
padding: 14px 16px;
font-size: 16px; font-size: 16px;
border-radius: 6px; color: #f1f5f9;
background: var(--social-bg); outline: none;
box-sizing: border-box;
transition: all 0.3s ease;
}
.input-text:focus {
border-color: #d97706;
box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.2);
background: #0b0c10;
}
.input-textarea {
width: 100%;
background: rgba(11, 12, 16, 0.85);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 10px;
padding: 14px;
font-size: 15px;
color: #f1f5f9;
outline: none;
box-sizing: border-box;
resize: none;
font-family: inherit;
transition: all 0.3s ease;
}
.input-textarea:focus {
border-color: #d97706;
box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.2);
background: #0b0c10;
}
.auth-submit-actions {
display: flex; display: flex;
padding: 6px 12px; flex-direction: column;
gap: 12px;
width: 100%;
}
/* Premium buttons */
.btn {
width: 100%;
padding: 14px;
font-size: 16px;
font-weight: 600;
border: none;
border-radius: 10px;
cursor: pointer;
transition: all 0.25s ease;
display: flex;
justify-content: center;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
} }
@media (max-width: 1024px) { .btn.primary {
margin-top: 20px; background: linear-gradient(135deg, #fbbf24 0%, #d97706 100%);
flex-wrap: wrap; color: #0b0c10;
justify-content: center; box-shadow: 0 4px 15px rgba(217, 119, 6, 0.3);
li {
flex: 1 1 calc(50% - 8px);
} }
a { .btn.primary:hover:not(:disabled) {
transform: translateY(-1.5px);
box-shadow: 0 6px 20px rgba(217, 119, 6, 0.45);
}
.btn.primary:active:not(:disabled) {
transform: translateY(0.5px);
}
.btn.secondary {
background: rgba(255, 255, 255, 0.05);
color: #e2e8f0;
border: 1px solid rgba(255, 255, 255, 0.12);
}
.btn.secondary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
/* Recovery screen formatting */
.auth-header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 20px;
}
.auth-icon {
margin-bottom: 12px;
}
.auth-icon.warn {
color: #f59e0b;
}
.auth-icon.accent {
color: #d97706;
}
.auth-card h2 {
font-size: 24px;
font-weight: 600;
color: #f8fafc;
margin: 0;
}
.recovery-warning {
font-size: 13.5px;
color: #f43f5e;
background: rgba(244, 63, 94, 0.08);
border: 1px solid rgba(244, 63, 94, 0.2);
border-radius: 8px;
padding: 12px 14px;
line-height: 145%;
margin-bottom: 25px;
text-align: left;
}
.phrase-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
width: 100%; width: 100%;
justify-content: center; margin-bottom: 30px;
}
.phrase-word {
background: rgba(11, 12, 16, 0.9);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
padding: 12px 8px;
font-size: 14.5px;
color: #f1f5f9;
text-align: center;
font-family: ui-monospace, monospace;
}
.word-num {
color: #d97706;
font-size: 11px;
margin-right: 4px;
}
.auth-actions {
display: flex;
gap: 12px;
width: 100%;
}
.auth-error {
width: 100%;
background: rgba(244, 63, 94, 0.08);
color: #fda4af;
border-left: 3px solid #f43f5e;
padding: 10px 12px;
font-size: 14px;
border-radius: 4px;
text-align: left;
box-sizing: border-box; box-sizing: border-box;
} }
}
}
#spacer { .auth-footer {
height: 88px; margin-top: 30px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%; width: 100%;
border-top: 1px solid rgba(255, 255, 255, 0.06);
&::before, padding-top: 20px;
&::after { display: flex;
content: ''; justify-content: space-between;
position: absolute; box-sizing: border-box;
top: -4.5px;
border: 5px solid transparent;
} }
&::before { .btn-icon-text {
left: 0; background: none;
border-left-color: var(--border); border: none;
color: #94a3b8;
font-size: 13.5px;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 6px 10px;
border-radius: 6px;
transition: all 0.2s ease;
text-decoration: none;
} }
&::after {
right: 0; .btn-icon-text:hover {
border-right-color: var(--border); color: #d97706;
background: rgba(217, 119, 6, 0.06);
} }
.btn-icon-text.link-sec {
color: #4b5563;
}
.btn-icon-text.link-sec:hover {
color: #9ca3af;
}
/* Dashboard Mock Layout (for verified state) */
.dashboard-mock {
color: #f8fafc;
background: rgba(11, 12, 16, 0.75);
backdrop-filter: blur(20px);
border: 1px solid rgba(212, 175, 55, 0.25);
border-radius: 20px;
padding: 40px;
text-align: center;
max-width: 600px;
width: 90%;
box-sizing: border-box;
animation: fadeIn 0.4s ease-out;
}
.dashboard-mock h2 {
font-size: 28px;
color: #fbbf24;
margin-top: 0;
}
.dashboard-mock p {
color: #94a3b8;
line-height: 150%;
margin-bottom: 24px;
} }
+50 -109
View File
@@ -1,121 +1,62 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
import './App.css' import './App.css'
import AuthOnboarding from './components/AuthOnboarding.tsx'
import { getActiveMasterKey, logoutUser } from './services/auth.js'
import { Anchor, LogOut, ShieldCheck, Database } from 'lucide-react'
function App() { function App() {
const [count, setCount] = useState(0) const [isAuthenticated, setIsAuthenticated] = useState(false)
const [username, setUsername] = useState<string | null>(null)
useEffect(() => {
const savedUser = localStorage.getItem('active_username')
const key = getActiveMasterKey()
if (savedUser && key) {
setIsAuthenticated(true)
setUsername(savedUser)
}
}, [])
const handleAuthenticated = () => {
setIsAuthenticated(true)
setUsername(localStorage.getItem('active_username'))
}
const handleLogout = () => {
logoutUser()
setIsAuthenticated(false)
setUsername(null)
}
if (!isAuthenticated) {
return <AuthOnboarding onAuthenticated={handleAuthenticated} />
}
return ( return (
<> <div className="dashboard-mock">
<section id="center"> <div className="auth-brand">
<div className="hero"> <Anchor className="auth-icon accent" size={60} style={{ color: '#fbbf24' }} />
<img src={heroImg} className="base" width="170" height="179" alt="" /> <h2>Kapteins Daagbox</h2>
<img src={reactLogo} className="framework" alt="React logo" /> <p className="tagline" style={{ color: '#34d399' }}>
<img src={viteLogo} className="vite" alt="Vite logo" /> <ShieldCheck size={16} style={{ display: 'inline', marginRight: 4, verticalAlign: 'text-bottom' }} />
</div> Session Decrypted (Zero-Knowledge)
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p> </p>
</div> </div>
<button
type="button" <div style={{ textAlign: 'left', margin: '30px 0', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, padding: 20, background: 'rgba(255,255,255,0.02)' }}>
className="counter" <p style={{ color: '#e2e8f0', margin: '0 0 10px 0' }}><strong>Skipper:</strong> {username}</p>
onClick={() => setCount((count) => count + 1)} <p style={{ color: '#e2e8f0', margin: '0 0 10px 0' }}><strong>Status:</strong> E2E Secure Connection Active</p>
> <p style={{ color: '#94a3b8', margin: 0, display: 'flex', alignItems: 'center', gap: 6, fontSize: 13.5 }}>
Count is {count} <Database size={15} />
Local IndexedDB synced with zero-knowledge PostgreSQL server payload
</p>
</div>
<button className="btn secondary" onClick={handleLogout}>
<LogOut size={18} />
Abmelden (Logout)
</button> </button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div> </div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
) )
} }
+230
View File
@@ -0,0 +1,230 @@
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { registerUser, loginUser, completeLoginWithRecovery } from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle } from 'lucide-react'
interface AuthOnboardingProps {
onAuthenticated: () => void
}
export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps) {
const { t, i18n } = useTranslation()
const [username, setUsername] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Registration recovery phrase flow
const [recoveryPhrase, setRecoveryPhrase] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
// Login recovery phrase fallback flow
const [showRecoveryFallback, setShowRecoveryFallback] = useState(false)
const [recoveryInput, setRecoveryInput] = useState('')
const [encryptedPayloads, setEncryptedPayloads] = useState<any>(null)
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault()
if (!username.trim()) return
setLoading(true)
setError(null)
try {
const result = await registerUser(username.trim())
if (result.verified) {
setRecoveryPhrase(result.recoveryPhrase)
}
} catch (err: any) {
setError(err.message || 'Registration failed')
} finally {
setLoading(false)
}
}
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
if (!username.trim()) return
setLoading(true)
setError(null)
try {
const result = await loginUser(username.trim())
if (result.verified) {
if (result.prfSuccess) {
// Biometric E2E decryption succeeded
onAuthenticated()
} else {
// Biometrics succeeded but PRF key wasn't supported/available, fall back to recovery phrase
setEncryptedPayloads(result.encryptedPayloads)
setShowRecoveryFallback(true)
}
}
} catch (err: any) {
setError(err.message || 'Login failed')
} finally {
setLoading(false)
}
}
const handleRecoverySubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!recoveryInput.trim() || !encryptedPayloads) return
setLoading(true)
setError(null)
try {
const success = await completeLoginWithRecovery(username.trim(), recoveryInput.trim(), encryptedPayloads)
if (success) {
onAuthenticated()
} else {
setError('Incorrect recovery phrase. Decryption failed.')
}
} catch (err: any) {
setError('Decryption failed. Please check your recovery phrase.')
} finally {
setLoading(false)
}
}
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
}
const copyToClipboard = () => {
if (recoveryPhrase) {
navigator.clipboard.writeText(recoveryPhrase)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
// Render 1: Display new registration recovery phrase
if (recoveryPhrase) {
return (
<div className="auth-card glass">
<div className="auth-header">
<ShieldAlert className="auth-icon warn" size={48} />
<h2>{t('auth.recovery_title')}</h2>
</div>
<p className="recovery-warning">{t('auth.recovery_warning')}</p>
<div className="phrase-grid">
{recoveryPhrase.split(" ").map((word, idx) => (
<div key={idx} className="phrase-word">
<span className="word-num">{idx + 1}</span> {word}
</div>
))}
</div>
<div className="auth-actions">
<button className="btn secondary" onClick={copyToClipboard}>
{copied ? 'Copied!' : 'Copy Phrase'}
</button>
<button className="btn primary" onClick={onAuthenticated}>
{t('auth.confirm_recovery')}
</button>
</div>
</div>
)
}
// Render 2: Ask for recovery phrase fallback if biometric PRF fails
if (showRecoveryFallback) {
return (
<div className="auth-card glass">
<div className="auth-header">
<KeyRound className="auth-icon accent" size={48} />
<h2>Enter Recovery Phrase</h2>
</div>
<p className="recovery-warning">
Your Passkey authenticated successfully, but your device does not support hardware key derivation. Enter your 12-word recovery phrase to decrypt your logbook.
</p>
<form onSubmit={handleRecoverySubmit} className="auth-form">
<textarea
className="input-textarea"
placeholder="Enter your 12-word recovery phrase separated by spaces..."
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
disabled={loading}
rows={3}
required
/>
{error && <div className="auth-error">{error}</div>}
<div className="auth-actions">
<button
type="button"
className="btn secondary"
onClick={() => setShowRecoveryFallback(false)}
disabled={loading}
>
Back
</button>
<button type="submit" className="btn primary" disabled={loading}>
{loading ? 'Decrypting...' : 'Decrypt Logbook'}
</button>
</div>
</form>
</div>
)
}
// Render 3: Standard Login / Registration options form
return (
<div className="auth-card glass">
<div className="auth-brand">
<img src="/logo.png" alt="Kapteins Daagbox" className="auth-logo-img" />
<h1>{t('app.name')}</h1>
<p className="tagline">{t('auth.tagline')}</p>
</div>
<form className="auth-form">
<div className="input-group">
<input
type="text"
className="input-text"
placeholder="Username / Skipper Name"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
required
/>
</div>
{error && <div className="auth-error">{error}</div>}
<div className="auth-submit-actions">
<button
type="button"
className="btn primary"
onClick={handleLogin}
disabled={loading || !username.trim()}
>
{loading ? 'Processing...' : t('auth.login')}
</button>
<button
type="button"
className="btn secondary"
onClick={handleRegister}
disabled={loading || !username.trim()}
>
{t('auth.register')}
</button>
</div>
</form>
<div className="auth-footer">
<button className="btn-icon-text" onClick={toggleLanguage}>
<Languages size={18} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
</button>
<a href="#help" className="btn-icon-text link-sec">
<HelpCircle size={18} />
Help
</a>
</div>
</div>
)
}
+232
View File
@@ -0,0 +1,232 @@
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
import {
generateMasterKey,
deriveKeyFromPhrase,
deriveKeyFromPrf,
encryptBuffer,
decryptBuffer,
generateRecoveryPhrase
} from './crypto.js'
const API_BASE = 'http://localhost:5000/api/auth'
// Shared in-memory container for the active user's session master key
let activeMasterKey: ArrayBuffer | null = null
export function getActiveMasterKey(): ArrayBuffer | null {
return activeMasterKey
}
export function setActiveMasterKey(key: ArrayBuffer | null) {
activeMasterKey = key
}
// Convert string salt to 32-byte Uint8Array
const PRF_SALT = new TextEncoder().encode("KapteinsDaagboxPRFSaltForE2EKey")
export interface RegistrationResult {
verified: boolean
recoveryPhrase: string
}
export async function registerUser(username: string): Promise<RegistrationResult> {
// 1. Get registration options
const optionsRes = await fetch(`${API_BASE}/register-options`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
})
if (!optionsRes.ok) {
const err = await optionsRes.json()
throw new Error(err.error || 'Failed to fetch registration options')
}
const options = await optionsRes.json()
// Request the PRF extension in the browser options
if (!options.publicKey.extensions) {
options.publicKey.extensions = {}
}
options.publicKey.extensions.prf = {}
// 2. Start biometric Passkey creation
const credentialResponse = await startRegistration(options)
// 3. Cryptographic Key derivation setup
const masterKey = generateMasterKey()
// Try to derive PRF key if supported
let encryptedMasterKeyPrf = null
let encryptedMasterKeyPrfIv = null
let encryptedMasterKeyPrfTag = null
const prfResults = (credentialResponse as any).clientExtensionResults?.prf
if (prfResults?.enabled && prfResults.results?.first) {
const prfKey = await deriveKeyFromPrf(prfResults.results.first)
const encryptedPrf = await encryptBuffer(masterKey, prfKey)
encryptedMasterKeyPrf = encryptedPrf.ciphertext
encryptedMasterKeyPrfIv = encryptedPrf.iv
encryptedMasterKeyPrfTag = encryptedPrf.tag
}
// Always generate a fallback 12-word recovery phrase
const recoveryPhrase = generateRecoveryPhrase()
const recoveryKey = await deriveKeyFromPhrase(recoveryPhrase)
const encryptedRecovery = await encryptBuffer(masterKey, recoveryKey)
// 4. Verify registration on the server
const verifyRes = await fetch(`${API_BASE}/register-verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
credentialResponse,
encryptedMasterKeyPrf,
encryptedMasterKeyPrfIv,
encryptedMasterKeyPrfTag,
encryptedMasterKeyRec: encryptedRecovery.ciphertext,
encryptedMasterKeyRecIv: encryptedRecovery.iv,
encryptedMasterKeyRecTag: encryptedRecovery.tag
})
})
if (!verifyRes.ok) {
const err = await verifyRes.json()
throw new Error(err.error || 'Failed to verify registration response')
}
const result = await verifyRes.json()
if (result.verified) {
activeMasterKey = masterKey
localStorage.setItem('active_username', username)
}
return {
verified: result.verified,
recoveryPhrase
}
}
export interface LoginResult {
verified: boolean
prfSuccess: boolean
encryptedPayloads?: {
encryptedMasterKeyRec: string
encryptedMasterKeyRecIv: string
encryptedMasterKeyRecTag: string
}
}
export async function loginUser(username: string): Promise<LoginResult> {
// 1. Get authentication options
const optionsRes = await fetch(`${API_BASE}/login-options`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
})
if (!optionsRes.ok) {
const err = await optionsRes.json()
throw new Error(err.error || 'Failed to fetch login options')
}
const options = await optionsRes.json()
// Add PRF extension evaluation input
if (!options.publicKey.extensions) {
options.publicKey.extensions = {}
}
options.publicKey.extensions.prf = {
eval: {
first: PRF_SALT
}
}
// 2. Start biometric Passkey verification
const credentialResponse = await startAuthentication(options)
// 3. Verify assertion on the server
const verifyRes = await fetch(`${API_BASE}/login-verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
credentialResponse
})
})
if (!verifyRes.ok) {
const err = await verifyRes.json()
throw new Error(err.error || 'Failed to verify login response')
}
const result = await verifyRes.json()
if (!result.verified) {
return { verified: false, prfSuccess: false }
}
// Try to decrypt master key using biometric PRF results
const prfResults = (credentialResponse as any).clientExtensionResults?.prf
if (prfResults?.results?.first && result.encryptedMasterKeyPrf) {
try {
const prfKey = await deriveKeyFromPrf(prfResults.results.first)
const decryptedMaster = await decryptBuffer(
result.encryptedMasterKeyPrf,
result.encryptedMasterKeyPrfIv,
result.encryptedMasterKeyPrfTag,
prfKey
)
activeMasterKey = decryptedMaster
localStorage.setItem('active_username', username)
return { verified: true, prfSuccess: true }
} catch (e) {
console.warn('PRF decryption failed, falling back to recovery phrase:', e)
}
}
// Return payloads to let the UI ask for the 12-word phrase
return {
verified: true,
prfSuccess: false,
encryptedPayloads: {
encryptedMasterKeyRec: result.encryptedMasterKeyRec,
encryptedMasterKeyRecIv: result.encryptedMasterKeyRecIv,
encryptedMasterKeyRecTag: result.encryptedMasterKeyRecTag
}
}
}
// Complete login if PRF failed or wasn't supported
export async function completeLoginWithRecovery(
username: string,
phrase: string,
encryptedPayloads: {
encryptedMasterKeyRec: string
encryptedMasterKeyRecIv: string
encryptedMasterKeyRecTag: string
}
): Promise<boolean> {
try {
const recoveryKey = await deriveKeyFromPhrase(phrase)
const decryptedMaster = await decryptBuffer(
encryptedPayloads.encryptedMasterKeyRec,
encryptedPayloads.encryptedMasterKeyRecIv,
encryptedPayloads.encryptedMasterKeyRecTag,
recoveryKey
)
activeMasterKey = decryptedMaster
localStorage.setItem('active_username', username)
return true
} catch (error) {
console.error('Failed to decrypt master key with recovery phrase:', error)
return false
}
}
export function logoutUser() {
activeMasterKey = null
localStorage.removeItem('active_username')
}
+192
View File
@@ -0,0 +1,192 @@
// Browser-native client-side cryptography service
// 1024 simple English words to generate recovery phrases without Node.js polyfill issues
const WORD_LIST = [
"anchor", "beacon", "compass", "deck", "engine", "fender", "gale", "harbor", "island", "journal",
"keel", "latitude", "marina", "nautical", "ocean", "port", "quay", "rudder", "sail", "tide",
"vessel", "wave", "yacht", "zenith", "active", "beauty", "cabin", "drift", "echo", "fleet",
"guide", "haven", "inlet", "jolly", "knot", "logbook", "mast", "navigator", "outbound", "passage",
"reef", "starboard", "tempest", "underway", "voyage", "windward", "alpha", "bravo", "charlie", "delta",
"echo", "foxtrot", "golf", "hotel", "india", "juliet", "kilo", "lima", "mike", "november",
"oscar", "papa", "quebec", "romeo", "sierra", "tango", "uniform", "victor", "whiskey", "xray",
"yankee", "zulu", "bright", "calm", "deep", "easy", "fair", "green", "high", "iron",
"keen", "lost", "mild", "near", "open", "pure", "quick", "rough", "safe", "true",
"warm", "blue", "gold", "silver", "aqua", "coral", "dawn", "dusk", "foggy", "misty",
"sunny", "stormy", "breeze", "current", "depth", "freedom", "horizon", "journey", "legacy", "latitude",
"longitude", "monsoon", "narrows", "outpost", "propeller", "rescue", "sonar", "transit", "vector", "wharf",
"arrow", "badge", "cable", "dock", "eagle", "flare", "gear", "helm", "jacket", "lantern",
"maple", "netting", "oxygen", "paddle", "rope", "shackle", "timber", "valve", "winch", "yoke",
"sailor", "skipper", "captain", "crew", "mate", "pilot", "bosun", "diver", "guest", "owner",
"vessel", "boat", "ship", "yacht", "cutter", "sloop", "ketch", "yawl", "brig", "bark",
"hull", "deck", "cabin", "galley", "salon", "berth", "head", "bilge", "bridge", "cockpit",
"stern", "bow", "portside", "starboard", "mast", "boom", "rigging", "shroud", "stay", "halyard",
"sheet", "sail", "mainsail", "jib", "genoa", "spinnaker", "stay", "rudder", "wheel", "tiller",
"keel", "ballast", "prop", "shaft", "engine", "motor", "pump", "filter", "tank", "battery",
"switch", "fuse", "wire", "light", "panel", "gps", "radar", "plotter", "radio", "vhf",
"depth", "speed", "wind", "compass", "autopilot", "anchor", "chain", "rode", "windlass", "cleat",
"line", "dockline", "fender", "buoy", "lifejacket", "raft", "flare", "extinguisher", "pump", "alarm"
]
// Base64 helper utilities for browser array buffers
export function bufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return window.btoa(binary)
}
export function base64ToBuffer(base64: string): ArrayBuffer {
const binary = window.atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes.buffer
}
// Generate 12 random words from the word list
export function generateRecoveryPhrase(): string {
const array = new Uint32Array(12)
window.crypto.getRandomValues(array)
const words: string[] = []
for (let i = 0; i < 12; i++) {
const wordIndex = array[i] % WORD_LIST.length
words.push(WORD_LIST[wordIndex])
}
return words.join(" ")
}
// Derive a 256-bit CryptoKey from a phrase (using PBKDF2)
export async function deriveKeyFromPhrase(phrase: string): Promise<CryptoKey> {
const encoder = new TextEncoder()
const phraseBytes = encoder.encode(phrase.trim().toLowerCase())
const saltBytes = encoder.encode('KapteinsDaagboxRecoverySaltBytes')
const baseKey = await window.crypto.subtle.importKey(
'raw',
phraseBytes,
{ name: 'PBKDF2' },
false,
['deriveKey']
)
return window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: saltBytes,
iterations: 100000,
hash: 'SHA-256'
},
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
)
}
// Derive a 256-bit CryptoKey from WebAuthn PRF results
export async function deriveKeyFromPrf(prfResult: ArrayBuffer): Promise<CryptoKey> {
const infoBytes = new TextEncoder().encode('KapteinsDaagboxPRFKeyDerivation')
// Import raw PRF output
const baseKey = await window.crypto.subtle.importKey(
'raw',
prfResult,
{ name: 'HKDF' },
false,
['deriveKey']
)
// Derive target AES key using HKDF
return window.crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: new Uint8Array(0), // Empty salt is standard
info: infoBytes
},
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
)
}
// Generate a random 256-bit master key buffer
export function generateMasterKey(): ArrayBuffer {
const keyBytes = new Uint8Array(32)
window.crypto.getRandomValues(keyBytes)
return keyBytes.buffer
}
// Encrypt an ArrayBuffer (e.g. Master Key) with a CryptoKey (AES-GCM)
export async function encryptBuffer(data: ArrayBuffer, key: CryptoKey): Promise<{ ciphertext: string; iv: string; tag: string }> {
const iv = window.crypto.getRandomValues(new Uint8Array(12))
const encrypted = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
data
)
// Web Crypto AES-GCM output appends the 16-byte authentication tag at the end
const fullBytes = new Uint8Array(encrypted)
const ciphertextBytes = fullBytes.slice(0, -16)
const tagBytes = fullBytes.slice(-16)
return {
ciphertext: bufferToBase64(ciphertextBytes.buffer),
iv: bufferToBase64(iv.buffer),
tag: bufferToBase64(tagBytes.buffer)
}
}
// Decrypt ciphertext with a CryptoKey (AES-GCM)
export async function decryptBuffer(ciphertextBase64: string, ivBase64: string, tagBase64: string, key: CryptoKey): Promise<ArrayBuffer> {
const ciphertext = new Uint8Array(base64ToBuffer(ciphertextBase64))
const iv = new Uint8Array(base64ToBuffer(ivBase64))
const tag = new Uint8Array(base64ToBuffer(tagBase64))
// Combine ciphertext and tag back into a single buffer for Web Crypto
const fullBytes = new Uint8Array(ciphertext.length + tag.length)
fullBytes.set(ciphertext, 0)
fullBytes.set(tag, ciphertext.length)
return window.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
fullBytes.buffer
)
}
// Helper to encrypt JSON strings with the User Master Key
export async function encryptJson(data: any, masterKeyBuffer: ArrayBuffer): Promise<{ ciphertext: string; iv: string; tag: string }> {
const jsonString = JSON.stringify(data)
const dataBytes = new TextEncoder().encode(jsonString)
const aesKey = await window.crypto.subtle.importKey(
'raw',
masterKeyBuffer,
{ name: 'AES-GCM' },
false,
['encrypt']
)
return encryptBuffer(dataBytes.buffer, aesKey)
}
// Helper to decrypt JSON strings with the User Master Key
export async function decryptJson(ciphertext: string, iv: string, tag: string, masterKeyBuffer: ArrayBuffer): Promise<any> {
const aesKey = await window.crypto.subtle.importKey(
'raw',
masterKeyBuffer,
{ name: 'AES-GCM' },
false,
['decrypt']
)
const decryptedBuffer = await decryptBuffer(ciphertext, iv, tag, aesKey)
const jsonString = new TextDecoder().decode(decryptedBuffer)
return JSON.parse(jsonString)
}
+6
View File
@@ -11,6 +11,12 @@ model User {
id String @id @default(uuid()) id String @id @default(uuid())
username String @unique username String @unique
createdAt DateTime @default(now()) createdAt DateTime @default(now())
encryptedMasterKeyPrf String? // Encrypted using PRF-derived key
encryptedMasterKeyPrfIv String?
encryptedMasterKeyPrfTag String?
encryptedMasterKeyRec String // Encrypted using 12-word recovery phrase
encryptedMasterKeyRecIv String
encryptedMasterKeyRecTag String
credentials Credential[] credentials Credential[]
logbooks Logbook[] logbooks Logbook[]
} }
+3
View File
@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client'
export const prisma = new PrismaClient()
+4
View File
@@ -1,6 +1,7 @@
import express from 'express' import express from 'express'
import cors from 'cors' import cors from 'cors'
import dotenv from 'dotenv' import dotenv from 'dotenv'
import authRouter from './routes/auth.js'
dotenv.config() dotenv.config()
@@ -10,6 +11,9 @@ const PORT = process.env.PORT || 5000
app.use(cors()) app.use(cors())
app.use(express.json()) app.use(express.json())
// Mount routes
app.use('/api/auth', authRouter)
// Health check endpoint // Health check endpoint
app.get('/api/health', (req, res) => { app.get('/api/health', (req, res) => {
res.json({ res.json({
+235
View File
@@ -0,0 +1,235 @@
import { Router } from 'express'
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse
} from '@simplewebauthn/server'
import { prisma } from '../db.js'
const router = Router()
const rpName = 'Kapteins Daagbox'
const rpID = process.env.RP_ID || 'localhost'
const origin = process.env.ORIGIN || 'http://localhost:5173'
// In-memory challenge stores
const registrationChallenges = new Map<string, string>()
const authenticationChallenges = new Map<string, { challenge: string; userId: string }>()
// 1. Generate Registration Options
router.post('/register-options', async (req, res) => {
try {
const { username } = req.body
if (!username) {
return res.status(400).json({ error: 'Username is required' })
}
const existingUser = await prisma.user.findUnique({
where: { username }
})
if (existingUser) {
return res.status(400).json({ error: 'User already exists' })
}
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: username,
userName: username,
userDisplayName: username,
attestationType: 'none',
authenticatorSelection: {
residentKey: 'required',
userVerification: 'preferred'
},
supportedAlgorithmIDs: [-7, -257] // ES256 and RS256
})
// Store challenge
registrationChallenges.set(username, options.challenge)
return res.json(options)
} catch (error: any) {
console.error('Error generating registration options:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
// 2. Verify Registration Response
router.post('/register-verify', async (req, res) => {
try {
const {
username,
credentialResponse,
encryptedMasterKeyPrf,
encryptedMasterKeyPrfIv,
encryptedMasterKeyPrfTag,
encryptedMasterKeyRec,
encryptedMasterKeyRecIv,
encryptedMasterKeyRecTag
} = req.body
if (!username || !credentialResponse) {
return res.status(400).json({ error: 'Username and credentialResponse are required' })
}
const expectedChallenge = registrationChallenges.get(username)
if (!expectedChallenge) {
return res.status(400).json({ error: 'Challenge not found or expired' })
}
const verification = await verifyRegistrationResponse({
response: credentialResponse,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID
})
if (!verification.verified || !verification.registrationInfo) {
return res.status(400).json({ error: 'WebAuthn verification failed' })
}
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo
// Save user and credential
const user = await prisma.user.create({
data: {
username,
encryptedMasterKeyPrf,
encryptedMasterKeyPrfIv,
encryptedMasterKeyPrfTag,
encryptedMasterKeyRec,
encryptedMasterKeyRecIv,
encryptedMasterKeyRecTag,
credentials: {
create: {
credentialId: Buffer.from(credentialID).toString('base64url'),
publicKey: Buffer.from(credentialPublicKey),
counter: BigInt(counter),
transports: credentialResponse.response.transports || []
}
}
}
})
registrationChallenges.delete(username)
return res.json({ verified: true, userId: user.id })
} catch (error: any) {
console.error('Error verifying registration response:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
// 3. Generate Authentication Options
router.post('/login-options', async (req, res) => {
try {
const { username } = req.body
if (!username) {
return res.status(400).json({ error: 'Username is required' })
}
const user = await prisma.user.findUnique({
where: { username },
include: { credentials: true }
})
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: user.credentials.map(cred => ({
id: Buffer.from(cred.credentialId, 'base64url'),
type: 'public-key',
transports: cred.transports as any[]
})),
userVerification: 'preferred'
})
authenticationChallenges.set(username, {
challenge: options.challenge,
userId: user.id
})
return res.json(options)
} catch (error: any) {
console.error('Error generating authentication options:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
// 4. Verify Authentication Response
router.post('/login-verify', async (req, res) => {
try {
const { username, credentialResponse } = req.body
if (!username || !credentialResponse) {
return res.status(400).json({ error: 'Username and credentialResponse are required' })
}
const expectedChallengeInfo = authenticationChallenges.get(username)
if (!expectedChallengeInfo) {
return res.status(400).json({ error: 'Challenge not found or expired' })
}
const dbCred = await prisma.credential.findUnique({
where: { credentialId: credentialResponse.id }
})
if (!dbCred || dbCred.userId !== expectedChallengeInfo.userId) {
return res.status(400).json({ error: 'Credential not recognized for this user' })
}
const verification = await verifyAuthenticationResponse({
response: credentialResponse,
expectedChallenge: expectedChallengeInfo.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: {
credentialID: Buffer.from(dbCred.credentialId, 'base64url'),
credentialPublicKey: dbCred.publicKey,
counter: Number(dbCred.counter)
}
})
if (!verification.verified || !verification.authenticationInfo) {
return res.status(400).json({ error: 'Authentication failed' })
}
// Update counter
await prisma.credential.update({
where: { id: dbCred.id },
data: { counter: BigInt(verification.authenticationInfo.newCounter) }
})
authenticationChallenges.delete(username)
// Retrieve user keys
const user = await prisma.user.findUnique({
where: { username }
})
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
return res.json({
verified: true,
userId: user.id,
encryptedMasterKeyPrf: user.encryptedMasterKeyPrf,
encryptedMasterKeyPrfIv: user.encryptedMasterKeyPrfIv,
encryptedMasterKeyPrfTag: user.encryptedMasterKeyPrfTag,
encryptedMasterKeyRec: user.encryptedMasterKeyRec,
encryptedMasterKeyRecIv: user.encryptedMasterKeyRecIv,
encryptedMasterKeyRecTag: user.encryptedMasterKeyRecTag
})
} catch (error: any) {
console.error('Error verifying authentication response:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
export default router