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 = () => {
|
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(() => {});
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
`,
|
`,
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user