feat(ui): implement hero passkey authentication, conditional UI autofill, and 12-word recovery setup
This commit is contained in:
parent
029c32e022
commit
a3f5c5c286
@ -2,67 +2,40 @@ import { Layout } from "./Layout.tsx";
|
||||
|
||||
export const LoginPage = () => {
|
||||
return (
|
||||
<Layout title="Login">
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<h1 style={{ marginBottom: "0.5rem" }}>Authenticate</h1>
|
||||
<p style={{ color: "#666", marginBottom: "2rem" }}>
|
||||
Use your registered hardware key or passkey to log in.
|
||||
</p>
|
||||
|
||||
<div
|
||||
id="instructionBox"
|
||||
style={{
|
||||
background: "#eef2f5",
|
||||
padding: "1rem",
|
||||
borderRadius: "6px",
|
||||
marginBottom: "1.5rem",
|
||||
fontSize: "0.9rem",
|
||||
color: "#333",
|
||||
border: "1px solid #dcdcdc",
|
||||
}}
|
||||
<Layout title="Sign In">
|
||||
<div>
|
||||
<div class="brand-header">
|
||||
<div class="brand-logo">
|
||||
<svg
|
||||
width="26"
|
||||
height="26"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<p style={{ margin: "0 0 0.5rem 0" }}>
|
||||
<strong>Instruction:</strong>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>Welcome Back</h1>
|
||||
<p class="subtitle">
|
||||
Sign in securely using your biometric passkey or hardware key.
|
||||
</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>
|
||||
|
||||
{/* Primary Biometric Hero Button */}
|
||||
<button
|
||||
type="button"
|
||||
id="loginBtn"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.5rem",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
class="btn-primary"
|
||||
style="width: 100%; min-height: 52px; font-size: 1.05rem; border-radius: var(--radius-md); box-shadow: var(--shadow-sm);"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@ -70,48 +43,125 @@ export const LoginPage = () => {
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
<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>
|
||||
Login with Passkey
|
||||
<span>Sign In with Passkey</span>
|
||||
</button>
|
||||
|
||||
{/* Loading Indicator */}
|
||||
<div
|
||||
id="loadingIndicator"
|
||||
style={{
|
||||
display: "none",
|
||||
marginTop: "1rem",
|
||||
color: "#007bff",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
style="display: none; margin-top: 1.25rem; text-align: center; color: var(--primary); font-size: 0.9rem; font-weight: 500;"
|
||||
>
|
||||
Waiting for authenticator... Please follow the prompt.
|
||||
</div>
|
||||
|
||||
<div id="statusMessage" class="error"></div>
|
||||
|
||||
<div class="links" style={{ marginTop: "2rem" }}>
|
||||
Don't have an account? <a href="/register">Register here</a>
|
||||
<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>
|
||||
|
||||
<script src="/public/auth-client.js?v=5"></script>
|
||||
<div id="statusMessage"></div>
|
||||
|
||||
{/* Progressive Disclosure for Non-Resident Keys & Recovery */}
|
||||
<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>
|
||||
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<script src="/public/auth-client.js?v=6"></script>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
document.getElementById('loginBtn').addEventListener('click', async () => {
|
||||
document.getElementById('loadingIndicator').style.display = 'block';
|
||||
document.getElementById('loginBtn').disabled = true;
|
||||
document.getElementById('statusMessage').textContent = '';
|
||||
const btn = document.getElementById('loginBtn');
|
||||
const loader = document.getElementById('loadingIndicator');
|
||||
const status = document.getElementById('statusMessage');
|
||||
|
||||
btn.addEventListener('click', async () => {
|
||||
loader.style.display = 'block';
|
||||
btn.disabled = true;
|
||||
status.textContent = '';
|
||||
status.className = '';
|
||||
|
||||
try {
|
||||
const username = document.getElementById('loginUsername').value;
|
||||
const username = document.getElementById('loginUsername')?.value?.trim() || '';
|
||||
await startWebAuthnLogin(username);
|
||||
} catch (_e) {
|
||||
// handled in auth-client.js
|
||||
} finally {
|
||||
document.getElementById('loadingIndicator').style.display = 'none';
|
||||
document.getElementById('loginBtn').disabled = false;
|
||||
loader.style.display = 'none';
|
||||
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(() => {});
|
||||
}
|
||||
`,
|
||||
}}
|
||||
>
|
||||
|
||||
@ -3,77 +3,86 @@ import { Layout } from "./Layout.tsx";
|
||||
export const RecoveryPage = () => {
|
||||
return (
|
||||
<Layout title="Account Recovery">
|
||||
<div
|
||||
class="card"
|
||||
style="max-width: 400px; margin: 4rem auto; text-align: center;"
|
||||
<div>
|
||||
<div class="brand-header">
|
||||
<div class="brand-logo">
|
||||
<svg
|
||||
width="26"
|
||||
height="26"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
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 style="color: #6c757d; margin-bottom: 2rem;">
|
||||
Select your recovery method to reconstruct your master secret and bind
|
||||
a new passkey.
|
||||
<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" />
|
||||
|
||||
<div style="margin-bottom: 1rem; text-align: left;">
|
||||
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem;">
|
||||
<div style="margin-bottom: 1.25rem;">
|
||||
<label style="display: block; font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;">
|
||||
Recovery PIN
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="recovery-pin"
|
||||
placeholder="Enter your secret recovery PIN"
|
||||
required
|
||||
style="width: 100%; padding: 0.5rem;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem; text-align: left;">
|
||||
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem;">
|
||||
<div style="margin-bottom: 1.25rem;">
|
||||
<label style="display: block; font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;">
|
||||
Recovery Method
|
||||
</label>
|
||||
<select id="recovery-method" style="width: 100%; padding: 0.5rem;">
|
||||
<option value="device">Device Share (Browser PRF)</option>
|
||||
<select id="recovery-method">
|
||||
<option value="voucher">Cold Voucher (12-Word Mnemonic)</option>
|
||||
<option value="device">Device Share (Browser PRF)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="voucher-section"
|
||||
style="margin-bottom: 1rem; text-align: left; display: none;"
|
||||
>
|
||||
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem;">
|
||||
12-Word Cold Voucher
|
||||
<div id="voucher-section" style="margin-bottom: 1.5rem;">
|
||||
<label style="display: block; font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;">
|
||||
12-Word Recovery Voucher
|
||||
</label>
|
||||
<textarea
|
||||
id="recovery-voucher"
|
||||
rows={3}
|
||||
style="width: 100%; padding: 0.5rem;"
|
||||
placeholder="abandon ability able..."
|
||||
placeholder="abandon ability able about above..."
|
||||
style="font-family: monospace; font-size: 0.9rem;"
|
||||
>
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-action btn-success"
|
||||
style="width: 100%; padding: 0.75rem; font-size: 1rem; margin-top: 1rem;"
|
||||
id="reconstructBtn"
|
||||
class="btn-primary"
|
||||
style="width: 100%; min-height: 50px; font-size: 1rem;"
|
||||
>
|
||||
Reconstruct & Bind New Passkey
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div
|
||||
id="error-message"
|
||||
style="color: #dc3545; margin-top: 1rem; display: none;"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
id="success-message"
|
||||
style="color: #28a745; margin-top: 1rem; display: none;"
|
||||
>
|
||||
<div id="error-message" class="error" style="display: none;"></div>
|
||||
<div id="success-message" class="success" style="display: none;">
|
||||
Passkey successfully bound! Redirecting to login...
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
Remembered your key? <a href="/login">Back to sign in</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 { mnemonicToEntropy } from '/public/ui/utils/bip39.ts';
|
||||
|
||||
// Setup UI listeners
|
||||
const methodSelect = document.getElementById('recovery-method');
|
||||
const voucherSection = document.getElementById('voucher-section');
|
||||
methodSelect.addEventListener('change', (e) => {
|
||||
@ -99,7 +107,7 @@ export const RecoveryPage = () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('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('recovery-form').style.display = 'none';
|
||||
} else {
|
||||
@ -107,21 +115,19 @@ export const RecoveryPage = () => {
|
||||
}
|
||||
|
||||
async function getDeviceShare() {
|
||||
// This is a stub for PRF-derived indexedDB fetching (Story 3.1)
|
||||
// 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");
|
||||
throw new Error("Device Share PRF not available on this browser. Please use the 12-Word Voucher.");
|
||||
}
|
||||
|
||||
document.getElementById('recovery-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector('button');
|
||||
const btn = document.getElementById('reconstructBtn');
|
||||
const errorDiv = document.getElementById('error-message');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Processing...';
|
||||
btn.textContent = 'Reconstructing Secret...';
|
||||
errorDiv.style.display = 'none';
|
||||
|
||||
let share1Data, share2Data;
|
||||
let share1X = 1, share2X = 2; // Device/Voucher = 1, Server = 2
|
||||
let share1X = 1, share2X = 2;
|
||||
|
||||
try {
|
||||
await init('/public/wasm/sss_recovery_bg.wasm');
|
||||
@ -129,7 +135,6 @@ export const RecoveryPage = () => {
|
||||
const pin = document.getElementById('recovery-pin').value;
|
||||
const method = methodSelect.value;
|
||||
|
||||
// 1. Get Client Share
|
||||
if (method === 'device') {
|
||||
share1Data = await getDeviceShare();
|
||||
share1X = 1;
|
||||
@ -139,7 +144,6 @@ export const RecoveryPage = () => {
|
||||
share1X = 3;
|
||||
}
|
||||
|
||||
// 2. Get Server Share
|
||||
const challengeRes = await fetch('/api/recovery/challenge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@ -154,17 +158,14 @@ export const RecoveryPage = () => {
|
||||
const challengeData = await challengeRes.json();
|
||||
const { options, serverShareHex } = challengeData;
|
||||
|
||||
// Convert Hex to Uint8Array
|
||||
share2Data = new Uint8Array(serverShareHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
|
||||
share2X = 2;
|
||||
|
||||
// 3. Reconstruct Secret using Wasm
|
||||
const s1 = new Share(share1X, share1Data);
|
||||
const s2 = new Share(share2X, share2Data);
|
||||
|
||||
const masterSecret = reconstruct_secret(s1, s2);
|
||||
|
||||
// Generate recovery token signature using reconstructed secret
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
masterSecret,
|
||||
@ -177,12 +178,10 @@ export const RecoveryPage = () => {
|
||||
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('');
|
||||
|
||||
// Zeroize Memory
|
||||
masterSecret.fill(0);
|
||||
share1Data.fill(0);
|
||||
share2Data.fill(0);
|
||||
|
||||
// 4. Register new WebAuthn
|
||||
const { startRegistration } = SimpleWebAuthnBrowser;
|
||||
const attResp = await startRegistration({ optionsJSON: options });
|
||||
|
||||
@ -210,7 +209,6 @@ export const RecoveryPage = () => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Reconstruct & Bind New Passkey';
|
||||
|
||||
// Ensure zeroization on error
|
||||
if (share1Data && share1Data.fill) share1Data.fill(0);
|
||||
if (share2Data && share2Data.fill) share2Data.fill(0);
|
||||
}
|
||||
|
||||
@ -4,85 +4,69 @@ export const RegisterPage = (
|
||||
{ initialCode = "" }: { initialCode?: string },
|
||||
) => {
|
||||
return (
|
||||
<Layout title="Register">
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<h1 style={{ marginBottom: "0.5rem" }}>Create Account</h1>
|
||||
<p
|
||||
style={{ color: "#666", marginBottom: "1.5rem", fontSize: "0.95rem" }}
|
||||
<Layout title="Create Account">
|
||||
<div>
|
||||
<div class="brand-header">
|
||||
<div class="brand-logo">
|
||||
<svg
|
||||
width="26"
|
||||
height="26"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
Register a secure hardware token or passkey using your invite code.
|
||||
<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 */}
|
||||
<div id="step1Container">
|
||||
<div style="text-align: left; margin-bottom: 1.25rem;">
|
||||
<label
|
||||
for="username"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "0.25rem",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
style="display: block; font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
autocomplete="username"
|
||||
placeholder="e.g. pilot_alice"
|
||||
required
|
||||
autofocus={!initialCode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: "left", marginBottom: "1.5rem" }}>
|
||||
<div style="text-align: left; margin-bottom: 1.5rem;">
|
||||
<label
|
||||
for="inviteCode"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "0.25rem",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
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="Invite Code (e.g. 00000000-... or custom code)"
|
||||
placeholder="Paste invite token..."
|
||||
value={initialCode}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="instructionBox"
|
||||
style={{
|
||||
background: "#e8f4fd",
|
||||
padding: "0.75rem",
|
||||
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",
|
||||
}}
|
||||
class="btn-primary"
|
||||
style="width: 100%; min-height: 50px; font-size: 1rem; border-radius: var(--radius-md); box-shadow: var(--shadow-sm);"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
@ -94,36 +78,147 @@ export const RegisterPage = (
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
||||
<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>
|
||||
Register Passkey
|
||||
<span>Register Device Passkey</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="loadingIndicator"
|
||||
style={{
|
||||
display: "none",
|
||||
marginTop: "1rem",
|
||||
color: "#28a745",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
style="display: none; margin-top: 1.25rem; text-align: center; color: var(--primary); font-size: 0.9rem; font-weight: 500;"
|
||||
>
|
||||
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 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>
|
||||
|
||||
<script src="/public/auth-client.js?v=5"></script>
|
||||
<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>
|
||||
|
||||
<div
|
||||
id="wordGrid"
|
||||
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;"
|
||||
>
|
||||
{/* Populated dynamically */}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id="copyWordsBtn"
|
||||
class="btn-outline"
|
||||
style="width: 100%; margin-bottom: 1.5rem;"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
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>
|
||||
|
||||
<a
|
||||
href="/dashboard"
|
||||
class="btn-primary"
|
||||
style="width: 100%; min-height: 48px; text-decoration: none;"
|
||||
>
|
||||
Continue to Dashboard →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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
|
||||
type="module"
|
||||
dangerouslySetInnerHTML={{
|
||||
__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 codeParam = urlParams.get('code');
|
||||
if (codeParam) {
|
||||
@ -134,25 +229,70 @@ export const RegisterPage = (
|
||||
}
|
||||
}
|
||||
|
||||
let generatedMnemonic = "";
|
||||
|
||||
document.getElementById('registerBtn').addEventListener('click', async () => {
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const inviteCode = document.getElementById('inviteCode').value.trim();
|
||||
|
||||
if (!username || !inviteCode) {
|
||||
document.getElementById('statusMessage').textContent = "Username and Invite Code are required.";
|
||||
document.getElementById('statusMessage').className = "error";
|
||||
setStatus("Username and Invite Code are required.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('loadingIndicator').style.display = 'block';
|
||||
document.getElementById('registerBtn').disabled = true;
|
||||
document.getElementById('statusMessage').textContent = '';
|
||||
const loader = document.getElementById('loadingIndicator');
|
||||
const btn = document.getElementById('registerBtn');
|
||||
loader.style.display = 'block';
|
||||
btn.disabled = true;
|
||||
setStatus('');
|
||||
|
||||
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 {
|
||||
document.getElementById('loadingIndicator').style.display = 'none';
|
||||
document.getElementById('registerBtn').disabled = false;
|
||||
loader.style.display = 'none';
|
||||
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);
|
||||
}
|
||||
});
|
||||
`,
|
||||
|
||||
@ -6,102 +6,53 @@ function setStatus(msg, isError = false) {
|
||||
if (el) {
|
||||
el.textContent = msg;
|
||||
el.className = isError ? "error" : "success";
|
||||
el.style.display = msg ? "block" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
async function startWebAuthnRegistration(username, inviteCode) {
|
||||
setStatus("");
|
||||
if (!username || !inviteCode) {
|
||||
setStatus("Username and Invite Code are required.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
async function startWebAuthnConditionalLogin() {
|
||||
try {
|
||||
// 1. Fetch challenge from API
|
||||
const resp = await fetch("/api/register/challenge", {
|
||||
const resp = await fetch("/api/login/challenge", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ username, inviteCode }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "" }),
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
|
||||
const asseResp = await startAuthentication({
|
||||
optionsJSON: data.options,
|
||||
useBrowserAutofill: true,
|
||||
verifyBrowserAutofillInput: true,
|
||||
});
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await resp.json();
|
||||
} catch {
|
||||
const text = await resp.text().catch(() => "");
|
||||
setStatus(`Challenge request failed (${resp.status}): ${text}`, true);
|
||||
return;
|
||||
}
|
||||
if (!asseResp) return;
|
||||
|
||||
if (!resp.ok) {
|
||||
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", {
|
||||
const verificationResp = await fetch("/api/login/verify", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
inviteCode,
|
||||
response: {
|
||||
...attResp,
|
||||
clientExtensionResults: extensionResults,
|
||||
},
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ response: asseResp }),
|
||||
});
|
||||
|
||||
let verificationJSON;
|
||||
try {
|
||||
verificationJSON = await verificationResp.json();
|
||||
} catch {
|
||||
const text = await verificationResp.text().catch(() => "");
|
||||
setStatus(
|
||||
`Verification failed (${verificationResp.status}): ${text}`,
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const verificationJSON = await verificationResp.json();
|
||||
if (verificationJSON.success) {
|
||||
setStatus("Registration successful! You can now log in.");
|
||||
setTimeout(() => {
|
||||
globalThis.location.href = "/login";
|
||||
}, 2000);
|
||||
} else {
|
||||
setStatus(
|
||||
verificationJSON.error || "Registration verification failed",
|
||||
true,
|
||||
);
|
||||
setStatus("Autofill login successful! Redirecting...");
|
||||
let targetRedirect = "/dashboard";
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const rawRedirect = params.get("redirect");
|
||||
if (
|
||||
rawRedirect &&
|
||||
(rawRedirect.startsWith("/") || rawRedirect.includes(".atyg.org"))
|
||||
) {
|
||||
targetRedirect = rawRedirect;
|
||||
}
|
||||
} catch (_e) {}
|
||||
window.location.replace(targetRedirect);
|
||||
}
|
||||
} 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 || {};
|
||||
}
|
||||
|
||||
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
|
||||
const verificationResp = await fetch("/api/login/verify", {
|
||||
method: "POST",
|
||||
@ -241,7 +146,6 @@ async function startWebAuthnLogin(username) {
|
||||
} catch (_e) {
|
||||
// Fallback to default
|
||||
}
|
||||
setStatus("Login successful! Redirecting...");
|
||||
window.location.replace(targetRedirect);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user