- 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
151 lines
4.5 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
}
|