auth-yes/public/webauthn-recovery.js

133 lines
4.4 KiB
JavaScript

import init, { Share, reconstruct_secret } from '/public/wasm/sss_recovery_bg.wasm.js';
import { mnemonicToEntropy } from '/public/utils/bip39.ts';
const methodSelect = document.getElementById('recovery-method');
const voucherSection = document.getElementById('voucher-section');
if (methodSelect && voucherSection) {
methodSelect.addEventListener('change', (e) => {
if (e.target.value === 'voucher') {
voucherSection.style.display = 'block';
} else {
voucherSection.style.display = 'none';
}
});
}
const urlParams = new URLSearchParams(globalThis.location.search);
const code = urlParams.get('code');
if (!code) {
const errMsg = document.getElementById('error-message');
if (errMsg) {
errMsg.textContent = 'No recovery code found in URL. Please use the emergency recovery link provided by an admin.';
errMsg.style.display = 'block';
}
const form = document.getElementById('recovery-form');
if (form) form.style.display = 'none';
} else {
const rcInput = document.getElementById('recovery-code');
if (rcInput) rcInput.value = code;
}
async function getDeviceShare() {
throw new Error("Device Share PRF not available on this browser. Please use the 12-Word Voucher.");
}
const form = document.getElementById('recovery-form');
if (form) {
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(() => {
globalThis.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);
}
});
}