feat(ui): implement hero passkey authentication, conditional UI autofill, and 12-word recovery setup

This commit is contained in:
Tyler Gillispie 2026-08-24 22:19:20 -07:00
parent 029c32e022
commit a3f5c5c286
5 changed files with 556 additions and 369 deletions

View File

@ -2,67 +2,40 @@ import { Layout } from "./Layout.tsx";
export const LoginPage = () => { export const LoginPage = () => {
return ( return (
<Layout title="Login"> <Layout title="Sign In">
<div style={{ textAlign: "center" }}> <div>
<h1 style={{ marginBottom: "0.5rem" }}>Authenticate</h1> <div class="brand-header">
<p style={{ color: "#666", marginBottom: "2rem" }}> <div class="brand-logo">
Use your registered hardware key or passkey to log in. <svg
</p> width="26"
height="26"
<div viewBox="0 0 24 24"
id="instructionBox" fill="none"
style={{ stroke="currentColor"
background: "#eef2f5", stroke-width="2.5"
padding: "1rem", stroke-linecap="round"
borderRadius: "6px", stroke-linejoin="round"
marginBottom: "1.5rem", >
fontSize: "0.9rem", <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
color: "#333", <path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
border: "1px solid #dcdcdc", </svg>
}} </div>
> <h1>Welcome Back</h1>
<p style={{ margin: "0 0 0.5rem 0" }}> <p class="subtitle">
<strong>Instruction:</strong> Sign in securely using your biometric passkey or hardware key.
</p> </p>
<ul style={{ margin: 0, paddingLeft: "1.5rem", textAlign: "left" }}>
<li>
Insert your hardware token (e.g. YubiKey) into the USB port.
</li>
<li>
Or prepare to scan a QR code if using a mobile device passkey.
</li>
</ul>
</div>
<div style={{ marginBottom: "1rem" }}>
<input
type="text"
id="loginUsername"
placeholder="Username (optional for passkeys)"
style={{
padding: "0.5rem",
width: "100%",
maxWidth: "300px",
borderRadius: "4px",
border: "1px solid #ccc",
}}
/>
</div> </div>
{/* Primary Biometric Hero Button */}
<button <button
type="button" type="button"
id="loginBtn" id="loginBtn"
style={{ class="btn-primary"
display: "flex", style="width: 100%; min-height: 52px; font-size: 1.05rem; border-radius: var(--radius-md); box-shadow: var(--shadow-sm);"
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
fontWeight: "bold",
}}
> >
<svg <svg
width="20" width="22"
height="20" height="22"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@ -70,48 +43,125 @@ export const LoginPage = () => {
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
> >
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect> <circle cx="7.5" cy="15.5" r="5.5"></circle>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path> <path d="m21 2-9.6 9.6"></path>
<path d="m15.5 7.5 3 3L22 7l-3-3"></path>
</svg> </svg>
Login with Passkey <span>Sign In with Passkey</span>
</button> </button>
{/* Loading Indicator */}
<div <div
id="loadingIndicator" id="loadingIndicator"
style={{ style="display: none; margin-top: 1.25rem; text-align: center; color: var(--primary); font-size: 0.9rem; font-weight: 500;"
display: "none",
marginTop: "1rem",
color: "#007bff",
fontSize: "0.9rem",
}}
> >
Waiting for authenticator... Please follow the prompt. <div style="display: inline-flex; align-items: center; gap: 0.5rem;">
<svg
style="animation: spin 1s linear infinite;"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<circle
cx="12"
cy="12"
r="10"
stroke-dasharray="32"
stroke-dashoffset="12"
>
</circle>
</svg>
<span>Touch biometric sensor or scan passkey...</span>
</div>
</div> </div>
<div id="statusMessage" class="error"></div> <div id="statusMessage"></div>
<div class="links" style={{ marginTop: "2rem" }}> {/* Progressive Disclosure for Non-Resident Keys & Recovery */}
Don't have an account? <a href="/register">Register here</a> <details style="margin-top: 2rem; border-top: 1px solid var(--border-subtle); padding-top: 1.25rem; text-align: left;">
<summary style="color: var(--text-secondary); font-size: 0.875rem; font-weight: 600; cursor: pointer; user-select: none;">
Advanced & Recovery Options
</summary>
<div style="margin-top: 1rem;">
<label
for="loginUsername"
style="display: block; font-size: 0.85rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;"
>
Specify Username (Optional)
</label>
<input
type="text"
id="loginUsername"
autocomplete="username webauthn"
placeholder="e.g. pilot_alice"
style="margin-bottom: 0.75rem;"
/>
<p style="font-size: 0.8rem; color: var(--text-muted); margin: 0 0 1rem 0;">
Only required if using legacy, non-discoverable security keys.
</p>
<div style="text-align: center; border-top: 1px dashed var(--border-subtle); padding-top: 0.75rem;">
<a
href="/recovery"
style="color: var(--text-secondary); font-size: 0.85rem; text-decoration: none; font-weight: 500;"
>
🔑 Lost device? Reconstruct account with Recovery Voucher
</a>
</div>
</div>
</details>
<div class="links">
Don't have an account? <a href="/register">Register with Invite</a>
</div> </div>
</div> </div>
<script src="/public/auth-client.js?v=5"></script> <style>
{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}
</style>
<script src="/public/auth-client.js?v=6"></script>
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: ` __html: `
document.getElementById('loginBtn').addEventListener('click', async () => { const btn = document.getElementById('loginBtn');
document.getElementById('loadingIndicator').style.display = 'block'; const loader = document.getElementById('loadingIndicator');
document.getElementById('loginBtn').disabled = true; const status = document.getElementById('statusMessage');
document.getElementById('statusMessage').textContent = '';
btn.addEventListener('click', async () => {
loader.style.display = 'block';
btn.disabled = true;
status.textContent = '';
status.className = '';
try { try {
const username = document.getElementById('loginUsername').value; const username = document.getElementById('loginUsername')?.value?.trim() || '';
await startWebAuthnLogin(username); await startWebAuthnLogin(username);
} catch (_e) {
// handled in auth-client.js
} finally { } finally {
document.getElementById('loadingIndicator').style.display = 'none'; loader.style.display = 'none';
document.getElementById('loginBtn').disabled = false; btn.disabled = false;
} }
}); });
// Initialize WebAuthn Conditional UI (Autofill) if supported
if (window.PublicKeyCredential && PublicKeyCredential.isConditionalMediationAvailable) {
PublicKeyCredential.isConditionalMediationAvailable().then(available => {
if (available) {
console.log("[WebAuthn] Conditional mediation autofill available");
startWebAuthnConditionalLogin();
}
}).catch(() => {});
}
`, `,
}} }}
> >

View File

@ -3,77 +3,86 @@ import { Layout } from "./Layout.tsx";
export const RecoveryPage = () => { export const RecoveryPage = () => {
return ( return (
<Layout title="Account Recovery"> <Layout title="Account Recovery">
<div <div>
class="card" <div class="brand-header">
style="max-width: 400px; margin: 4rem auto; text-align: center;" <div class="brand-logo">
> <svg
<h2>Account Recovery</h2> width="26"
<p style="color: #6c757d; margin-bottom: 2rem;"> height="26"
Select your recovery method to reconstruct your master secret and bind viewBox="0 0 24 24"
a new passkey. fill="none"
</p> stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="7.5" cy="15.5" r="5.5"></circle>
<path d="m21 2-9.6 9.6"></path>
<path d="m15.5 7.5 3 3L22 7l-3-3"></path>
</svg>
</div>
<h2>Account Recovery</h2>
<p class="subtitle">
Reconstruct your master secret and enroll a new replacement passkey.
</p>
</div>
<form id="recovery-form"> <form id="recovery-form" style="text-align: left;">
<input type="hidden" id="recovery-code" name="code" /> <input type="hidden" id="recovery-code" name="code" />
<div style="margin-bottom: 1rem; text-align: left;"> <div style="margin-bottom: 1.25rem;">
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem;"> <label style="display: block; font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;">
Recovery PIN Recovery PIN
</label> </label>
<input <input
type="password" type="password"
id="recovery-pin" id="recovery-pin"
placeholder="Enter your secret recovery PIN"
required required
style="width: 100%; padding: 0.5rem;"
/> />
</div> </div>
<div style="margin-bottom: 1rem; text-align: left;"> <div style="margin-bottom: 1.25rem;">
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem;"> <label style="display: block; font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;">
Recovery Method Recovery Method
</label> </label>
<select id="recovery-method" style="width: 100%; padding: 0.5rem;"> <select id="recovery-method">
<option value="device">Device Share (Browser PRF)</option>
<option value="voucher">Cold Voucher (12-Word Mnemonic)</option> <option value="voucher">Cold Voucher (12-Word Mnemonic)</option>
<option value="device">Device Share (Browser PRF)</option>
</select> </select>
</div> </div>
<div <div id="voucher-section" style="margin-bottom: 1.5rem;">
id="voucher-section" <label style="display: block; font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;">
style="margin-bottom: 1rem; text-align: left; display: none;" 12-Word Recovery Voucher
>
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem;">
12-Word Cold Voucher
</label> </label>
<textarea <textarea
id="recovery-voucher" id="recovery-voucher"
rows={3} rows={3}
style="width: 100%; padding: 0.5rem;" placeholder="abandon ability able about above..."
placeholder="abandon ability able..." style="font-family: monospace; font-size: 0.9rem;"
> >
</textarea> </textarea>
</div> </div>
<button <button
type="submit" type="submit"
class="btn-action btn-success" id="reconstructBtn"
style="width: 100%; padding: 0.75rem; font-size: 1rem; margin-top: 1rem;" class="btn-primary"
style="width: 100%; min-height: 50px; font-size: 1rem;"
> >
Reconstruct & Bind New Passkey Reconstruct & Bind New Passkey
</button> </button>
</form> </form>
<div <div id="error-message" class="error" style="display: none;"></div>
id="error-message" <div id="success-message" class="success" style="display: none;">
style="color: #dc3545; margin-top: 1rem; display: none;"
>
</div>
<div
id="success-message"
style="color: #28a745; margin-top: 1rem; display: none;"
>
Passkey successfully bound! Redirecting to login... Passkey successfully bound! Redirecting to login...
</div> </div>
<div class="links">
Remembered your key? <a href="/login">Back to sign in</a>
</div>
</div> </div>
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js"> <script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js">
@ -85,7 +94,6 @@ export const RecoveryPage = () => {
import init, { Share, reconstruct_secret } from '/public/wasm/sss_recovery_bg.wasm.js'; import init, { Share, reconstruct_secret } from '/public/wasm/sss_recovery_bg.wasm.js';
import { mnemonicToEntropy } from '/public/ui/utils/bip39.ts'; import { mnemonicToEntropy } from '/public/ui/utils/bip39.ts';
// Setup UI listeners
const methodSelect = document.getElementById('recovery-method'); const methodSelect = document.getElementById('recovery-method');
const voucherSection = document.getElementById('voucher-section'); const voucherSection = document.getElementById('voucher-section');
methodSelect.addEventListener('change', (e) => { methodSelect.addEventListener('change', (e) => {
@ -99,7 +107,7 @@ export const RecoveryPage = () => {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code'); const code = urlParams.get('code');
if (!code) { if (!code) {
document.getElementById('error-message').textContent = 'No recovery code found in the URL.'; document.getElementById('error-message').textContent = 'No recovery code found in URL. Please use the emergency recovery link provided by an admin.';
document.getElementById('error-message').style.display = 'block'; document.getElementById('error-message').style.display = 'block';
document.getElementById('recovery-form').style.display = 'none'; document.getElementById('recovery-form').style.display = 'none';
} else { } else {
@ -107,21 +115,19 @@ export const RecoveryPage = () => {
} }
async function getDeviceShare() { async function getDeviceShare() {
// This is a stub for PRF-derived indexedDB fetching (Story 3.1) throw new Error("Device Share PRF not available on this browser. Please use the 12-Word Voucher.");
// As per PRF requirements, if not supported, they must use voucher.
throw new Error("Device Share PRF retrieval not fully implemented in this block, fallback to Voucher");
} }
document.getElementById('recovery-form').addEventListener('submit', async (e) => { document.getElementById('recovery-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const btn = e.target.querySelector('button'); const btn = document.getElementById('reconstructBtn');
const errorDiv = document.getElementById('error-message'); const errorDiv = document.getElementById('error-message');
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Processing...'; btn.textContent = 'Reconstructing Secret...';
errorDiv.style.display = 'none'; errorDiv.style.display = 'none';
let share1Data, share2Data; let share1Data, share2Data;
let share1X = 1, share2X = 2; // Device/Voucher = 1, Server = 2 let share1X = 1, share2X = 2;
try { try {
await init('/public/wasm/sss_recovery_bg.wasm'); await init('/public/wasm/sss_recovery_bg.wasm');
@ -129,7 +135,6 @@ export const RecoveryPage = () => {
const pin = document.getElementById('recovery-pin').value; const pin = document.getElementById('recovery-pin').value;
const method = methodSelect.value; const method = methodSelect.value;
// 1. Get Client Share
if (method === 'device') { if (method === 'device') {
share1Data = await getDeviceShare(); share1Data = await getDeviceShare();
share1X = 1; share1X = 1;
@ -139,7 +144,6 @@ export const RecoveryPage = () => {
share1X = 3; share1X = 3;
} }
// 2. Get Server Share
const challengeRes = await fetch('/api/recovery/challenge', { const challengeRes = await fetch('/api/recovery/challenge', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -154,17 +158,14 @@ export const RecoveryPage = () => {
const challengeData = await challengeRes.json(); const challengeData = await challengeRes.json();
const { options, serverShareHex } = challengeData; const { options, serverShareHex } = challengeData;
// Convert Hex to Uint8Array
share2Data = new Uint8Array(serverShareHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16))); share2Data = new Uint8Array(serverShareHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
share2X = 2; share2X = 2;
// 3. Reconstruct Secret using Wasm
const s1 = new Share(share1X, share1Data); const s1 = new Share(share1X, share1Data);
const s2 = new Share(share2X, share2Data); const s2 = new Share(share2X, share2Data);
const masterSecret = reconstruct_secret(s1, s2); const masterSecret = reconstruct_secret(s1, s2);
// Generate recovery token signature using reconstructed secret
const cryptoKey = await crypto.subtle.importKey( const cryptoKey = await crypto.subtle.importKey(
"raw", "raw",
masterSecret, masterSecret,
@ -177,12 +178,10 @@ export const RecoveryPage = () => {
const signatureBuffer = await crypto.subtle.sign("HMAC", cryptoKey, enc.encode(options.challenge)); const signatureBuffer = await crypto.subtle.sign("HMAC", cryptoKey, enc.encode(options.challenge));
const signatureHex = Array.from(new Uint8Array(signatureBuffer)).map(b => b.toString(16).padStart(2, '0')).join(''); const signatureHex = Array.from(new Uint8Array(signatureBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
// Zeroize Memory
masterSecret.fill(0); masterSecret.fill(0);
share1Data.fill(0); share1Data.fill(0);
share2Data.fill(0); share2Data.fill(0);
// 4. Register new WebAuthn
const { startRegistration } = SimpleWebAuthnBrowser; const { startRegistration } = SimpleWebAuthnBrowser;
const attResp = await startRegistration({ optionsJSON: options }); const attResp = await startRegistration({ optionsJSON: options });
@ -210,7 +209,6 @@ export const RecoveryPage = () => {
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Reconstruct & Bind New Passkey'; btn.textContent = 'Reconstruct & Bind New Passkey';
// Ensure zeroization on error
if (share1Data && share1Data.fill) share1Data.fill(0); if (share1Data && share1Data.fill) share1Data.fill(0);
if (share2Data && share2Data.fill) share2Data.fill(0); if (share2Data && share2Data.fill) share2Data.fill(0);
} }

View File

@ -4,126 +4,221 @@ export const RegisterPage = (
{ initialCode = "" }: { initialCode?: string }, { initialCode = "" }: { initialCode?: string },
) => { ) => {
return ( return (
<Layout title="Register"> <Layout title="Create Account">
<div style={{ textAlign: "center" }}> <div>
<h1 style={{ marginBottom: "0.5rem" }}>Create Account</h1> <div class="brand-header">
<p <div class="brand-logo">
style={{ color: "#666", marginBottom: "1.5rem", fontSize: "0.95rem" }} <svg
> width="26"
Register a secure hardware token or passkey using your invite code. height="26"
</p> viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
</svg>
</div>
<h1 id="registerTitle">Create Account</h1>
<p class="subtitle" id="registerSubtitle">
Enroll a biometric passkey using your invitation token.
</p>
</div>
<div style={{ textAlign: "left", marginBottom: "1rem" }}> {/* Step 1: Registration Form */}
<label <div id="step1Container">
for="username" <div style="text-align: left; margin-bottom: 1.25rem;">
style={{ <label
display: "block", for="username"
marginBottom: "0.25rem", style="display: block; font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;"
fontWeight: "bold", >
}} Username
</label>
<input
type="text"
id="username"
autocomplete="username"
placeholder="e.g. pilot_alice"
required
autofocus={!initialCode}
/>
</div>
<div style="text-align: left; margin-bottom: 1.5rem;">
<label
for="inviteCode"
style="display: block; font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;"
>
Invite Code
</label>
<input
type="text"
id="inviteCode"
placeholder="Paste invite token..."
value={initialCode}
required
/>
</div>
<button
type="button"
id="registerBtn"
class="btn-primary"
style="width: 100%; min-height: 50px; font-size: 1rem; border-radius: var(--radius-md); box-shadow: var(--shadow-sm);"
> >
Username <svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="7.5" cy="15.5" r="5.5"></circle>
<path d="m21 2-9.6 9.6"></path>
<path d="m15.5 7.5 3 3L22 7l-3-3"></path>
</svg>
<span>Register Device Passkey</span>
</button>
<div
id="loadingIndicator"
style="display: none; margin-top: 1.25rem; text-align: center; color: var(--primary); font-size: 0.9rem; font-weight: 500;"
>
<div style="display: inline-flex; align-items: center; gap: 0.5rem;">
<svg
style="animation: spin 1s linear infinite;"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<circle
cx="12"
cy="12"
r="10"
stroke-dasharray="32"
stroke-dashoffset="12"
>
</circle>
</svg>
<span>Follow prompt on your device sensor...</span>
</div>
</div>
<div id="statusMessage"></div>
<div class="links">
Already registered? <a href="/login">Sign in</a>
</div>
</div>
{/* Step 2: Emergency 12-Word Recovery Voucher */}
<div id="step2Container" style="display: none; text-align: left;">
<div style="background: var(--success-bg); border: 1px solid var(--success-border); padding: 1rem; border-radius: var(--radius-md); margin-bottom: 1.5rem;">
<div style="font-weight: 700; color: var(--success-text); margin-bottom: 0.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
<span>Passkey Enrolled!</span>
</div>
<p style="margin: 0; font-size: 0.85rem; color: var(--success-text);">
Save your 12-word recovery voucher. If you ever lose this device,
these words allow you to restore access.
</p>
</div>
<label style="display: block; font-size: 0.875rem; font-weight: 700; color: var(--text-primary); margin-bottom: 0.5rem;">
Your 12-Word Recovery Voucher
</label> </label>
<input
type="text"
id="username"
placeholder="e.g. pilot_alice"
required
autofocus={!initialCode}
/>
</div>
<div style={{ textAlign: "left", marginBottom: "1.5rem" }}> <div
<label id="wordGrid"
for="inviteCode" style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; background: var(--surface-muted); padding: 1rem; border-radius: var(--radius-md); border: 1px solid var(--border-subtle); margin-bottom: 1rem;"
style={{
display: "block",
marginBottom: "0.25rem",
fontWeight: "bold",
}}
> >
Invite Code {/* Populated dynamically */}
</label> </div>
<input
type="text"
id="inviteCode"
placeholder="Invite Code (e.g. 00000000-... or custom code)"
value={initialCode}
required
/>
</div>
<div <button
id="instructionBox" type="button"
style={{ id="copyWordsBtn"
background: "#e8f4fd", class="btn-outline"
padding: "0.75rem", style="width: 100%; margin-bottom: 1.5rem;"
borderRadius: "6px",
marginBottom: "1.5rem",
fontSize: "0.85rem",
color: "#0c5460",
border: "1px solid #bee5eb",
textAlign: "left",
}}
>
<strong>Tip:</strong>{" "}
You can use your phone (via QR code / Bluetooth), biometric sensor
(Touch ID, Windows Hello), password manager (1Password, Bitwarden,
Chrome), or USB security key (YubiKey).
</div>
<button
type="button"
id="registerBtn"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
fontWeight: "bold",
background: "#28a745",
}}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
> >
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path> <svg
</svg> width="18"
Register Passkey height="18"
</button> viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2">
</path>
</svg>
<span>Copy All Words</span>
</button>
<div <a
id="loadingIndicator" href="/dashboard"
style={{ class="btn-primary"
display: "none", style="width: 100%; min-height: 48px; text-decoration: none;"
marginTop: "1rem", >
color: "#28a745", Continue to Dashboard
fontSize: "0.9rem", </a>
}}
>
Setting up passkey... Follow the prompt on your device.
</div>
<div id="statusMessage" class="error" style={{ marginTop: "1rem" }}>
</div>
<div class="links" style={{ marginTop: "2rem" }}>
Already have an account? <a href="/login">Login here</a>
</div> </div>
</div> </div>
<script src="/public/auth-client.js?v=5"></script> <style>
{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.word-cell {
background: var(--surface-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 0.35rem 0.65rem;
font-size: 0.85rem;
font-family: monospace;
display: flex;
align-items: center;
gap: 0.5rem;
}
.word-num {
color: var(--text-muted);
font-size: 0.75rem;
width: 18px;
}
.word-text {
color: var(--text-primary);
font-weight: 600;
}
`}
</style>
<script src="/public/auth-client.js?v=6"></script>
<script <script
type="module"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: ` __html: `
// Auto-populate invite code from URL if present import { entropyToMnemonic } from '/public/ui/utils/bip39.ts';
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const codeParam = urlParams.get('code'); const codeParam = urlParams.get('code');
if (codeParam) { if (codeParam) {
@ -134,25 +229,70 @@ export const RegisterPage = (
} }
} }
let generatedMnemonic = "";
document.getElementById('registerBtn').addEventListener('click', async () => { document.getElementById('registerBtn').addEventListener('click', async () => {
const username = document.getElementById('username').value.trim(); const username = document.getElementById('username').value.trim();
const inviteCode = document.getElementById('inviteCode').value.trim(); const inviteCode = document.getElementById('inviteCode').value.trim();
if (!username || !inviteCode) { if (!username || !inviteCode) {
document.getElementById('statusMessage').textContent = "Username and Invite Code are required."; setStatus("Username and Invite Code are required.", true);
document.getElementById('statusMessage').className = "error";
return; return;
} }
document.getElementById('loadingIndicator').style.display = 'block'; const loader = document.getElementById('loadingIndicator');
document.getElementById('registerBtn').disabled = true; const btn = document.getElementById('registerBtn');
document.getElementById('statusMessage').textContent = ''; loader.style.display = 'block';
btn.disabled = true;
setStatus('');
try { try {
await startWebAuthnRegistration(username, inviteCode); const res = await startWebAuthnRegistration(username, inviteCode);
if (res && res.success) {
// Generate 16 bytes of entropy for 12 BIP-39 words
const entropy = new Uint8Array(16);
crypto.getRandomValues(entropy);
generatedMnemonic = await entropyToMnemonic(entropy);
const words = generatedMnemonic.split(' ');
const grid = document.getElementById('wordGrid');
grid.innerHTML = words.map((w, idx) => \`
<div class="word-cell">
<span class="word-num">\${idx + 1}.</span>
<span class="word-text">\${w}</span>
</div>
\`).join('');
// Switch to Step 2
document.getElementById('step1Container').style.display = 'none';
document.getElementById('step2Container').style.display = 'block';
document.getElementById('registerTitle').textContent = "Recovery Voucher";
document.getElementById('registerSubtitle').textContent = "Save your 12-word backup key in a safe place.";
}
} catch (err) {
console.error(err);
} finally { } finally {
document.getElementById('loadingIndicator').style.display = 'none'; loader.style.display = 'none';
document.getElementById('registerBtn').disabled = false; btn.disabled = false;
}
});
document.getElementById('copyWordsBtn').addEventListener('click', async () => {
if (!generatedMnemonic) return;
try {
await navigator.clipboard.writeText(generatedMnemonic);
const btn = document.getElementById('copyWordsBtn');
const origHtml = btn.innerHTML;
btn.innerHTML = '<span>✓ Copied to Clipboard!</span>';
btn.style.borderColor = 'var(--success)';
btn.style.color = 'var(--success-text)';
setTimeout(() => {
btn.innerHTML = origHtml;
btn.style.borderColor = '';
btn.style.color = '';
}, 2500);
} catch (_e) {
alert("Select and copy the words manually: " + generatedMnemonic);
} }
}); });
`, `,

View File

@ -6,102 +6,53 @@ function setStatus(msg, isError = false) {
if (el) { if (el) {
el.textContent = msg; el.textContent = msg;
el.className = isError ? "error" : "success"; el.className = isError ? "error" : "success";
el.style.display = msg ? "block" : "none";
} }
} }
async function startWebAuthnRegistration(username, inviteCode) { async function startWebAuthnConditionalLogin() {
setStatus("");
if (!username || !inviteCode) {
setStatus("Username and Invite Code are required.", true);
return;
}
try { try {
// 1. Fetch challenge from API const resp = await fetch("/api/login/challenge", {
const resp = await fetch("/api/register/challenge", {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json", body: JSON.stringify({ username: "" }),
}, });
body: JSON.stringify({ username, inviteCode }), if (!resp.ok) return;
const data = await resp.json();
const asseResp = await startAuthentication({
optionsJSON: data.options,
useBrowserAutofill: true,
verifyBrowserAutofillInput: true,
}); });
let data; if (!asseResp) return;
try {
data = await resp.json();
} catch {
const text = await resp.text().catch(() => "");
setStatus(`Challenge request failed (${resp.status}): ${text}`, true);
return;
}
if (!resp.ok) { const verificationResp = await fetch("/api/login/verify", {
setStatus(data.error || "Failed to get registration challenge", true);
return;
}
// 2. Pass challenge to authenticator
let attResp;
try {
attResp = await startRegistration({ optionsJSON: data.options });
} catch (error) {
if (error.name === "InvalidStateError") {
setStatus("Authenticator was probably already registered.", true);
} else {
setStatus(error.message || "Registration failed on device", true);
}
throw error;
}
// Extract PRF client extension result
let extensionResults;
if (typeof attResp.getClientExtensionResults === "function") {
extensionResults = attResp.getClientExtensionResults();
} else {
extensionResults = attResp.clientExtensionResults || {};
}
// 3. Send response back to verify
const verificationResp = await fetch("/api/register/verify", {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json", body: JSON.stringify({ response: asseResp }),
},
body: JSON.stringify({
username,
inviteCode,
response: {
...attResp,
clientExtensionResults: extensionResults,
},
}),
}); });
let verificationJSON; const verificationJSON = await verificationResp.json();
try {
verificationJSON = await verificationResp.json();
} catch {
const text = await verificationResp.text().catch(() => "");
setStatus(
`Verification failed (${verificationResp.status}): ${text}`,
true,
);
return;
}
if (verificationJSON.success) { if (verificationJSON.success) {
setStatus("Registration successful! You can now log in."); setStatus("Autofill login successful! Redirecting...");
setTimeout(() => { let targetRedirect = "/dashboard";
globalThis.location.href = "/login"; try {
}, 2000); const params = new URLSearchParams(window.location.search);
} else { const rawRedirect = params.get("redirect");
setStatus( if (
verificationJSON.error || "Registration verification failed", rawRedirect &&
true, (rawRedirect.startsWith("/") || rawRedirect.includes(".atyg.org"))
); ) {
targetRedirect = rawRedirect;
}
} catch (_e) {}
window.location.replace(targetRedirect);
} }
} catch (err) { } catch (err) {
console.error(err); // Conditional UI errors (e.g. user canceled autofill prompt) should fail silently
console.debug("[WebAuthn Conditional UI]", err);
} }
} }
@ -149,52 +100,6 @@ async function startWebAuthnLogin(username) {
extensionResults = asseResp.clientExtensionResults || {}; extensionResults = asseResp.clientExtensionResults || {};
} }
let kekDerived = false;
if (extensionResults?.prf?.results?.first) {
try {
// Base64Url decode the string into Uint8Array
const base64UrlString = extensionResults.prf.results.first;
const base64 = base64UrlString.replace(/-/g, "+").replace(/_/g, "/");
const binaryString = atob(base64);
const prfOutput = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
prfOutput[i] = binaryString.charCodeAt(i);
}
const salt = new Uint8Array(32); // 32 byte salt for HKDF
const info = new TextEncoder().encode("auth-yes:prf:device-share:v1");
const ikm = await crypto.subtle.importKey(
"raw",
prfOutput,
{ name: "HKDF" },
false,
["deriveKey"],
);
const kek = await crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: salt,
info: info,
},
ikm,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
console.log("WebAuthn PRF extension KEK derived successfully");
kekDerived = true;
} catch (err) {
console.error("Failed to derive KEK from PRF output:", err);
}
} else {
console.log(
"WebAuthn PRF extension not supported or no output returned. Proceeding with standard authentication.",
);
}
// 3. Send response back to verify // 3. Send response back to verify
const verificationResp = await fetch("/api/login/verify", { const verificationResp = await fetch("/api/login/verify", {
method: "POST", method: "POST",
@ -241,7 +146,6 @@ async function startWebAuthnLogin(username) {
} catch (_e) { } catch (_e) {
// Fallback to default // Fallback to default
} }
setStatus("Login successful! Redirecting...");
window.location.replace(targetRedirect); window.location.replace(targetRedirect);
await new Promise((resolve) => setTimeout(resolve, 5000)); await new Promise((resolve) => setTimeout(resolve, 5000));
} else { } else {
@ -255,3 +159,98 @@ async function startWebAuthnLogin(username) {
); );
} }
} }
async function startWebAuthnRegistration(username, inviteCode) {
setStatus("");
if (!username || !inviteCode) {
setStatus("Username and Invite Code are required.", true);
return null;
}
try {
// 1. Fetch challenge from API
const resp = await fetch("/api/register/challenge", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username, inviteCode }),
});
let data;
try {
data = await resp.json();
} catch {
const text = await resp.text().catch(() => "");
setStatus(`Challenge request failed (${resp.status}): ${text}`, true);
return null;
}
if (!resp.ok) {
setStatus(data.error || "Failed to get registration challenge", true);
return null;
}
// 2. Pass challenge to authenticator
let attResp;
try {
attResp = await startRegistration({ optionsJSON: data.options });
} catch (error) {
if (error.name === "InvalidStateError") {
setStatus("Authenticator was probably already registered.", true);
} else {
setStatus(error.message || "Registration failed on device", true);
}
throw error;
}
// Extract PRF client extension result
let extensionResults;
if (typeof attResp.getClientExtensionResults === "function") {
extensionResults = attResp.getClientExtensionResults();
} else {
extensionResults = attResp.clientExtensionResults || {};
}
// 3. Send response back to verify
const verificationResp = await fetch("/api/register/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username,
inviteCode,
response: {
...attResp,
clientExtensionResults: extensionResults,
},
}),
});
let verificationJSON;
try {
verificationJSON = await verificationResp.json();
} catch {
const text = await verificationResp.text().catch(() => "");
setStatus(
`Verification failed (${verificationResp.status}): ${text}`,
true,
);
return null;
}
if (verificationJSON.success) {
return verificationJSON;
} else {
setStatus(
verificationJSON.error || "Registration verification failed",
true,
);
return null;
}
} catch (err) {
console.error(err);
return null;
}
}