feat: implement WebAuthn Passkeys register/login API and client onboarding UI
This commit is contained in:
+308
-170
@@ -1,184 +1,322 @@
|
||||
.counter {
|
||||
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;
|
||||
/* Kapteins Daagbox App styling */
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
body {
|
||||
background: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
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 {
|
||||
/* Glassmorphism Auth Card */
|
||||
.auth-card {
|
||||
background: rgba(11, 12, 16, 0.75);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(212, 175, 55, 0.25);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
width: 450px;
|
||||
max-width: 90%;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(15px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.auth-brand {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auth-logo-img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 4px 12px rgba(212, 175, 55, 0.35));
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.auth-brand h1 {
|
||||
font-size: 32px;
|
||||
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;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
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) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
.input-group {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
.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;
|
||||
color: #f1f5f9;
|
||||
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;
|
||||
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;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: linear-gradient(135deg, #fbbf24 0%, #d97706 100%);
|
||||
color: #0b0c10;
|
||||
box-shadow: 0 4px 15px rgba(217, 119, 6, 0.3);
|
||||
}
|
||||
|
||||
.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%;
|
||||
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;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
margin-top: 30px;
|
||||
width: 100%;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding-top: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.btn-icon-text {
|
||||
background: none;
|
||||
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;
|
||||
}
|
||||
|
||||
.btn-icon-text:hover {
|
||||
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;
|
||||
}
|
||||
|
||||
+52
-111
@@ -1,121 +1,62 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import heroImg from './assets/hero.png'
|
||||
import { useState, useEffect } from 'react'
|
||||
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() {
|
||||
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 (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||
<img src={reactLogo} className="framework" alt="React logo" />
|
||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="counter"
|
||||
onClick={() => setCount((count) => count + 1)}
|
||||
>
|
||||
Count is {count}
|
||||
</button>
|
||||
</section>
|
||||
<div className="dashboard-mock">
|
||||
<div className="auth-brand">
|
||||
<Anchor className="auth-icon accent" size={60} style={{ color: '#fbbf24' }} />
|
||||
<h2>Kapteins Daagbox</h2>
|
||||
<p className="tagline" style={{ color: '#34d399' }}>
|
||||
<ShieldCheck size={16} style={{ display: 'inline', marginRight: 4, verticalAlign: 'text-bottom' }} />
|
||||
Session Decrypted (Zero-Knowledge)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="ticks"></div>
|
||||
<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)' }}>
|
||||
<p style={{ color: '#e2e8f0', margin: '0 0 10px 0' }}><strong>Skipper:</strong> {username}</p>
|
||||
<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 }}>
|
||||
<Database size={15} />
|
||||
Local IndexedDB synced with zero-knowledge PostgreSQL server payload
|
||||
</p>
|
||||
</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 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>
|
||||
</>
|
||||
<button className="btn secondary" onClick={handleLogout}>
|
||||
<LogOut size={18} />
|
||||
Abmelden (Logout)
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user