auth-yes/public/webauthn-recovery.js
Tyler Gillispie 8b30b17dbe
feat(arch): implement Phase 2 base vertical slices and shared UI (#57)
- Scaffold shared UI fragments and styles in src/shared/ui/
- Implement full Auth vertical slice in src/features/auth/ with WebAuthn ceremonies and recovery endpoints
- Implement full Admin vertical slice in src/features/admin/ with responsive tables and mobile decks
- Add public client-side JS utilities and pure JS BIP-39 module
- Mount routes in src/main.ts and keep legacy server/ and ui/ quarantined
- Add pure JSX and API tests for Auth and Admin slices
2026-08-27 19:29:08 -07:00

151 lines
4.5 KiB
JavaScript

import init, {
reconstruct_secret,
Share,
} from "/public/wasm/sss_recovery_bg.wasm.js";
import { mnemonicToEntropy } from "/public/utils/bip39.js";
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);
}
});
}