223 lines
8.1 KiB
TypeScript
223 lines
8.1 KiB
TypeScript
import { Layout } from "./Layout.tsx";
|
|
|
|
export const RecoveryPage = () => {
|
|
return (
|
|
<Layout title="Account Recovery">
|
|
<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 class="subtitle">
|
|
Reconstruct your master secret and enroll a new replacement passkey.
|
|
</p>
|
|
</div>
|
|
|
|
<form id="recovery-form" style="text-align: left;">
|
|
<input type="hidden" id="recovery-code" name="code" />
|
|
|
|
<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
|
|
/>
|
|
</div>
|
|
|
|
<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">
|
|
<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: 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}
|
|
placeholder="abandon ability able about above..."
|
|
style="font-family: monospace; font-size: 0.9rem;"
|
|
>
|
|
</textarea>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
id="reconstructBtn"
|
|
class="btn-primary"
|
|
style="width: 100%; min-height: 50px; font-size: 1rem;"
|
|
>
|
|
Reconstruct & Bind New Passkey
|
|
</button>
|
|
</form>
|
|
|
|
<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">
|
|
</script>
|
|
<script
|
|
type="module"
|
|
dangerouslySetInnerHTML={{
|
|
__html: `
|
|
import init, { Share, reconstruct_secret } from '/public/wasm/sss_recovery_bg.wasm.js';
|
|
import { mnemonicToEntropy } from '/public/ui/utils/bip39.ts';
|
|
|
|
const methodSelect = document.getElementById('recovery-method');
|
|
const voucherSection = document.getElementById('voucher-section');
|
|
methodSelect.addEventListener('change', (e) => {
|
|
if (e.target.value === 'voucher') {
|
|
voucherSection.style.display = 'block';
|
|
} else {
|
|
voucherSection.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const code = urlParams.get('code');
|
|
if (!code) {
|
|
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 {
|
|
document.getElementById('recovery-code').value = code;
|
|
}
|
|
|
|
async function getDeviceShare() {
|
|
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 = document.getElementById('reconstructBtn');
|
|
const errorDiv = document.getElementById('error-message');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Reconstructing Secret...';
|
|
errorDiv.style.display = 'none';
|
|
|
|
let share1Data, share2Data;
|
|
let share1X = 1, share2X = 2;
|
|
|
|
try {
|
|
await init('/public/wasm/sss_recovery_bg.wasm');
|
|
|
|
const pin = document.getElementById('recovery-pin').value;
|
|
const method = methodSelect.value;
|
|
|
|
if (method === 'device') {
|
|
share1Data = await getDeviceShare();
|
|
share1X = 1;
|
|
} else {
|
|
const mnemonic = document.getElementById('recovery-voucher').value;
|
|
share1Data = await mnemonicToEntropy(mnemonic);
|
|
share1X = 3;
|
|
}
|
|
|
|
const challengeRes = await fetch('/api/recovery/challenge', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ code, pin })
|
|
});
|
|
|
|
if (!challengeRes.ok) {
|
|
const data = await challengeRes.json();
|
|
throw new Error(data.error || 'Failed to get server share');
|
|
}
|
|
|
|
const challengeData = await challengeRes.json();
|
|
const { options, serverShareHex } = challengeData;
|
|
|
|
share2Data = new Uint8Array(serverShareHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
|
|
share2X = 2;
|
|
|
|
const s1 = new Share(share1X, share1Data);
|
|
const s2 = new Share(share2X, share2Data);
|
|
|
|
const masterSecret = reconstruct_secret(s1, s2);
|
|
|
|
const cryptoKey = await crypto.subtle.importKey(
|
|
"raw",
|
|
masterSecret,
|
|
{ name: "HMAC", hash: "SHA-256" },
|
|
false,
|
|
["sign"]
|
|
);
|
|
|
|
const enc = new TextEncoder();
|
|
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('');
|
|
|
|
masterSecret.fill(0);
|
|
share1Data.fill(0);
|
|
share2Data.fill(0);
|
|
|
|
const { startRegistration } = SimpleWebAuthnBrowser;
|
|
const attResp = await startRegistration({ optionsJSON: options });
|
|
|
|
const verifyRes = await fetch('/api/recovery/verify', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ code, response: attResp, signature: signatureHex })
|
|
});
|
|
|
|
if (!verifyRes.ok) {
|
|
const data = await verifyRes.json();
|
|
throw new Error(data.error || 'Failed to verify passkey');
|
|
}
|
|
|
|
document.getElementById('recovery-form').style.display = 'none';
|
|
document.getElementById('success-message').style.display = 'block';
|
|
|
|
setTimeout(() => {
|
|
window.location.href = '/login';
|
|
}, 2000);
|
|
|
|
} catch (err) {
|
|
errorDiv.textContent = err.message || 'An error occurred during recovery.';
|
|
errorDiv.style.display = 'block';
|
|
btn.disabled = false;
|
|
btn.textContent = 'Reconstruct & Bind New Passkey';
|
|
|
|
if (share1Data && share1Data.fill) share1Data.fill(0);
|
|
if (share2Data && share2Data.fill) share2Data.fill(0);
|
|
}
|
|
});
|
|
`,
|
|
}}
|
|
>
|
|
</script>
|
|
</Layout>
|
|
);
|
|
};
|