Compare commits
No commits in common. "9e2cd81e4d6d82f97becdcc58330d5874bd23d27" and "f5935a49a7f0fb66c090c56aa98df87ee75cc2cf" have entirely different histories.
9e2cd81e4d
...
f5935a49a7
@ -1,6 +1,4 @@
|
||||
import { AuthenticatedLayout } from "./AuthenticatedLayout.tsx";
|
||||
import { PasskeyTable } from "./auth/PasskeyTable.tsx";
|
||||
import { WebAuthnScript } from "./auth/WebAuthnScript.tsx";
|
||||
|
||||
export const PasskeysPage = ({
|
||||
passkeys,
|
||||
@ -86,7 +84,145 @@ export const PasskeysPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PasskeyTable passkeys={passkeys} />
|
||||
{/* Desktop Table View (≥ 768px) */}
|
||||
<div class="card desktop-only" style="display: none;">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Credential ID</th>
|
||||
<th>Sign Counter</th>
|
||||
<th>AAGUID Vendor</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{passkeys.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={4}
|
||||
style="text-align: center; padding: 2rem; color: var(--text-muted);"
|
||||
>
|
||||
No passkeys registered.
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: (
|
||||
passkeys.map((passkey) => (
|
||||
<tr key={passkey.id}>
|
||||
<td>
|
||||
<code style="background: var(--surface-muted); padding: 0.2rem 0.5rem; border-radius: var(--radius-sm); font-family: monospace; font-size: 0.85rem;">
|
||||
{passkey.credential_id
|
||||
? `${passkey.credential_id.substring(0, 16)}...`
|
||||
: passkey.id}
|
||||
</code>
|
||||
</td>
|
||||
<td style="color: var(--text-secondary);">
|
||||
{passkey.counter}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-info">
|
||||
{passkey.aaguid
|
||||
? passkey.aaguid.substring(0, 8)
|
||||
: "FIDO2 Key"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger revoke-passkey-btn"
|
||||
data-passkey-id={passkey.id}
|
||||
disabled={passkeys.length <= 1}
|
||||
style={passkeys.length <= 1
|
||||
? "opacity: 0.5; cursor: not-allowed;"
|
||||
: ""}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Adaptive Cards View (< 768px) */}
|
||||
<div
|
||||
class="mobile-only"
|
||||
style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem;"
|
||||
>
|
||||
{passkeys.length === 0
|
||||
? (
|
||||
<div class="card" style="text-align: center; padding: 2rem;">
|
||||
<p style="color: var(--text-muted); margin: 0;">
|
||||
No passkeys registered.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
passkeys.map((passkey) => (
|
||||
<div class="card" key={passkey.id} style="margin-bottom: 0;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.65rem;">
|
||||
<div style="display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; background: var(--primary-light); color: var(--primary); border-radius: var(--radius-md);">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<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>
|
||||
<div>
|
||||
<div style="font-weight: 700; color: var(--text-primary);">
|
||||
Passkey Credential
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
||||
{passkey.credential_id
|
||||
? `${passkey.credential_id.substring(0, 14)}...`
|
||||
: passkey.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="badge badge-info">
|
||||
{passkey.aaguid ? "Hardware/OS" : "FIDO2"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 1rem;">
|
||||
<div>
|
||||
<strong>Counter:</strong> {passkey.counter} authentications
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger revoke-passkey-btn"
|
||||
data-passkey-id={passkey.id}
|
||||
disabled={passkeys.length <= 1}
|
||||
style={`width: 100%; min-height: 44px; ${
|
||||
passkeys.length <= 1
|
||||
? "opacity: 0.5; cursor: not-allowed;"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{passkeys.length <= 1
|
||||
? "Cannot Remove Last Passkey"
|
||||
: "Remove Passkey"}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Emergency Recovery Backup Section */}
|
||||
<div class="card" style="border-left: 4px solid var(--warning);">
|
||||
@ -182,7 +318,137 @@ export const PasskeysPage = ({
|
||||
`}
|
||||
</style>
|
||||
|
||||
<WebAuthnScript isPasskeys />
|
||||
<script
|
||||
type="module"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
import { entropyToMnemonic } from '/public/ui/utils/bip39.ts';
|
||||
|
||||
// Revoke Passkey Logic
|
||||
document.querySelectorAll('.revoke-passkey-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
if (e.currentTarget.disabled) return;
|
||||
if (!confirm('Are you sure you want to remove this passkey?')) return;
|
||||
|
||||
const passkeyId = e.currentTarget.getAttribute('data-passkey-id');
|
||||
const originalText = e.currentTarget.textContent;
|
||||
e.currentTarget.textContent = 'Removing...';
|
||||
e.currentTarget.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(\`/api/passkeys/\${passkeyId}\`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.error || 'Failed to remove passkey');
|
||||
e.currentTarget.textContent = originalText;
|
||||
e.currentTarget.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
alert('An error occurred');
|
||||
e.currentTarget.textContent = originalText;
|
||||
e.currentTarget.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add Passkey Logic
|
||||
const addContainer = document.getElementById('addPasskeyContainer');
|
||||
const addBtn = document.getElementById('addPasskeyBtn');
|
||||
const cancelBtn = document.getElementById('cancelAddPasskeyBtn');
|
||||
const confirmBtn = document.getElementById('confirmAddPasskeyBtn');
|
||||
const statusDiv = document.getElementById('addPasskeyStatus');
|
||||
|
||||
addBtn?.addEventListener('click', () => {
|
||||
addContainer.style.display = 'block';
|
||||
addBtn.style.display = 'none';
|
||||
});
|
||||
|
||||
cancelBtn?.addEventListener('click', () => {
|
||||
addContainer.style.display = 'none';
|
||||
addBtn.style.display = 'inline-flex';
|
||||
statusDiv.textContent = '';
|
||||
});
|
||||
|
||||
confirmBtn?.addEventListener('click', async () => {
|
||||
confirmBtn.disabled = true;
|
||||
statusDiv.textContent = 'Follow prompt on device...';
|
||||
statusDiv.style.color = 'var(--primary)';
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/passkeys/register/challenge", { method: "POST" });
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
statusDiv.textContent = data.error || "Failed to get registration challenge";
|
||||
statusDiv.style.color = 'var(--danger)';
|
||||
confirmBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const { startRegistration } = SimpleWebAuthnBrowser;
|
||||
const attResp = await startRegistration({ optionsJSON: data.options });
|
||||
|
||||
const verificationResp = await fetch("/api/passkeys/register/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ response: attResp }),
|
||||
});
|
||||
|
||||
const verificationJSON = await verificationResp.json();
|
||||
if (verificationJSON.success) {
|
||||
statusDiv.textContent = "Passkey registered successfully! Reloading...";
|
||||
statusDiv.style.color = 'var(--success-text)';
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
statusDiv.textContent = verificationJSON.error || "Registration verification failed";
|
||||
statusDiv.style.color = 'var(--danger)';
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
statusDiv.textContent = err.message || 'An error occurred.';
|
||||
statusDiv.style.color = 'var(--danger)';
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Re-generate Recovery Voucher
|
||||
let backupMnemonic = "";
|
||||
document.getElementById('regenVoucherBtn')?.addEventListener('click', async () => {
|
||||
const entropy = new Uint8Array(16);
|
||||
crypto.getRandomValues(entropy);
|
||||
backupMnemonic = await entropyToMnemonic(entropy);
|
||||
|
||||
const grid = document.getElementById('generatedWordGrid');
|
||||
grid.innerHTML = backupMnemonic.split(' ').map((w, idx) => \`
|
||||
<div class="word-cell">
|
||||
<span style="color: var(--text-muted); font-size: 0.75rem; width: 18px;">\${idx + 1}.</span>
|
||||
<span style="color: var(--text-primary); font-weight: 600;">\${w}</span>
|
||||
</div>
|
||||
\`).join('');
|
||||
|
||||
document.getElementById('voucherModal').style.display = 'block';
|
||||
});
|
||||
|
||||
document.getElementById('copyVoucherBtn')?.addEventListener('click', async () => {
|
||||
if (!backupMnemonic) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(backupMnemonic);
|
||||
const btn = document.getElementById('copyVoucherBtn');
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.innerHTML = '<span>✓ Copied 12 Words to Clipboard!</span>';
|
||||
setTimeout(() => { btn.innerHTML = origHtml; }, 2500);
|
||||
} catch (_e) {
|
||||
alert(backupMnemonic);
|
||||
}
|
||||
});
|
||||
`,
|
||||
}}
|
||||
>
|
||||
</script>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@ -92,7 +92,7 @@ export const RecoveryPage = () => {
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
import init, { Share, reconstruct_secret } from '/public/wasm/sss_recovery_bg.wasm.js';
|
||||
import { mnemonicToEntropy } from '/public/utils/bip39.ts';
|
||||
import { mnemonicToEntropy } from '/public/ui/utils/bip39.ts';
|
||||
|
||||
const methodSelect = document.getElementById('recovery-method');
|
||||
const voucherSection = document.getElementById('voucher-section');
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { Layout } from "./Layout.tsx";
|
||||
import { WebAuthnScript } from "./auth/WebAuthnScript.tsx";
|
||||
|
||||
export const RegisterPage = (
|
||||
{ initialCode = "" }: { initialCode?: string },
|
||||
@ -213,7 +212,93 @@ export const RegisterPage = (
|
||||
`}
|
||||
</style>
|
||||
|
||||
<WebAuthnScript isRegister />
|
||||
<script src="/public/auth-client.js?v=6"></script>
|
||||
<script
|
||||
type="module"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
import { entropyToMnemonic } from '/public/ui/utils/bip39.ts';
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const codeParam = urlParams.get('code');
|
||||
if (codeParam) {
|
||||
const input = document.getElementById('inviteCode');
|
||||
if (input) {
|
||||
input.value = codeParam;
|
||||
document.getElementById('username').focus();
|
||||
}
|
||||
}
|
||||
|
||||
let generatedMnemonic = "";
|
||||
|
||||
document.getElementById('registerBtn').addEventListener('click', async () => {
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const inviteCode = document.getElementById('inviteCode').value.trim();
|
||||
|
||||
if (!username || !inviteCode) {
|
||||
setStatus("Username and Invite Code are required.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = document.getElementById('loadingIndicator');
|
||||
const btn = document.getElementById('registerBtn');
|
||||
loader.style.display = 'block';
|
||||
btn.disabled = true;
|
||||
setStatus('');
|
||||
|
||||
try {
|
||||
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 {
|
||||
loader.style.display = 'none';
|
||||
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);
|
||||
}
|
||||
});
|
||||
`,
|
||||
}}
|
||||
>
|
||||
</script>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,145 +0,0 @@
|
||||
export const PasskeyTable = ({ passkeys }: { passkeys: any[] }) => {
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Table View (≥ 768px) */}
|
||||
<div class="card desktop-only" style="display: none;">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Credential ID</th>
|
||||
<th>Sign Counter</th>
|
||||
<th>AAGUID Vendor</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{passkeys.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={4}
|
||||
style="text-align: center; padding: 2rem; color: var(--text-muted);"
|
||||
>
|
||||
No passkeys registered.
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: (
|
||||
passkeys.map((passkey) => (
|
||||
<tr key={passkey.id}>
|
||||
<td>
|
||||
<code style="background: var(--surface-muted); padding: 0.2rem 0.5rem; border-radius: var(--radius-sm); font-family: monospace; font-size: 0.85rem;">
|
||||
{passkey.credential_id
|
||||
? `${passkey.credential_id.substring(0, 16)}...`
|
||||
: passkey.id}
|
||||
</code>
|
||||
</td>
|
||||
<td style="color: var(--text-secondary);">
|
||||
{passkey.counter}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-info">
|
||||
{passkey.aaguid
|
||||
? passkey.aaguid.substring(0, 8)
|
||||
: "FIDO2 Key"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger revoke-passkey-btn"
|
||||
data-passkey-id={passkey.id}
|
||||
disabled={passkeys.length <= 1}
|
||||
style={passkeys.length <= 1
|
||||
? "opacity: 0.5; cursor: not-allowed;"
|
||||
: ""}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Adaptive Cards View (< 768px) */}
|
||||
<div
|
||||
class="mobile-only"
|
||||
style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem;"
|
||||
>
|
||||
{passkeys.length === 0
|
||||
? (
|
||||
<div class="card" style="text-align: center; padding: 2rem;">
|
||||
<p style="color: var(--text-muted); margin: 0;">
|
||||
No passkeys registered.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
passkeys.map((passkey) => (
|
||||
<div class="card" key={passkey.id} style="margin-bottom: 0;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.65rem;">
|
||||
<div style="display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; background: var(--primary-light); color: var(--primary); border-radius: var(--radius-md);">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<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>
|
||||
<div>
|
||||
<div style="font-weight: 700; color: var(--text-primary);">
|
||||
Passkey Credential
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
||||
{passkey.credential_id
|
||||
? `${passkey.credential_id.substring(0, 14)}...`
|
||||
: passkey.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="badge badge-info">
|
||||
{passkey.aaguid ? "Hardware/OS" : "FIDO2"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 1rem;">
|
||||
<div>
|
||||
<strong>Counter:</strong> {passkey.counter} authentications
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger revoke-passkey-btn"
|
||||
data-passkey-id={passkey.id}
|
||||
disabled={passkeys.length <= 1}
|
||||
style={`width: 100%; min-height: 44px; ${
|
||||
passkeys.length <= 1
|
||||
? "opacity: 0.5; cursor: not-allowed;"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{passkeys.length <= 1
|
||||
? "Cannot Remove Last Passkey"
|
||||
: "Remove Passkey"}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,235 +0,0 @@
|
||||
export const WebAuthnScript = ({
|
||||
isRegister = false,
|
||||
isPasskeys = false,
|
||||
}: {
|
||||
isRegister?: boolean;
|
||||
isPasskeys?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<script src="/public/auth-client.js?v=6"></script>
|
||||
{isRegister && (
|
||||
<script
|
||||
type="module"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
import { entropyToMnemonic } from '/public/utils/bip39.ts';
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const codeParam = urlParams.get('code');
|
||||
if (codeParam) {
|
||||
const input = document.getElementById('inviteCode');
|
||||
if (input) {
|
||||
input.value = codeParam;
|
||||
document.getElementById('username').focus();
|
||||
}
|
||||
}
|
||||
|
||||
let generatedMnemonic = "";
|
||||
|
||||
document.getElementById('registerBtn').addEventListener('click', async () => {
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const inviteCode = document.getElementById('inviteCode').value.trim();
|
||||
|
||||
if (!username || !inviteCode) {
|
||||
setStatus("Username and Invite Code are required.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = document.getElementById('loadingIndicator');
|
||||
const btn = document.getElementById('registerBtn');
|
||||
loader.style.display = 'block';
|
||||
btn.disabled = true;
|
||||
setStatus('');
|
||||
|
||||
try {
|
||||
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 {
|
||||
loader.style.display = 'none';
|
||||
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);
|
||||
}
|
||||
});
|
||||
`,
|
||||
}}
|
||||
>
|
||||
</script>
|
||||
)}
|
||||
|
||||
{isPasskeys && (
|
||||
<script
|
||||
type="module"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
import { entropyToMnemonic } from '/public/utils/bip39.ts';
|
||||
|
||||
// Revoke Passkey Logic
|
||||
document.querySelectorAll('.revoke-passkey-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
if (e.currentTarget.disabled) return;
|
||||
if (!confirm('Are you sure you want to remove this passkey?')) return;
|
||||
|
||||
const passkeyId = e.currentTarget.getAttribute('data-passkey-id');
|
||||
const originalText = e.currentTarget.textContent;
|
||||
e.currentTarget.textContent = 'Removing...';
|
||||
e.currentTarget.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(\`/api/passkeys/\${passkeyId}\`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.error || 'Failed to remove passkey');
|
||||
e.currentTarget.textContent = originalText;
|
||||
e.currentTarget.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
alert('An error occurred');
|
||||
e.currentTarget.textContent = originalText;
|
||||
e.currentTarget.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add Passkey Logic
|
||||
const addContainer = document.getElementById('addPasskeyContainer');
|
||||
const addBtn = document.getElementById('addPasskeyBtn');
|
||||
const cancelBtn = document.getElementById('cancelAddPasskeyBtn');
|
||||
const confirmBtn = document.getElementById('confirmAddPasskeyBtn');
|
||||
const statusDiv = document.getElementById('addPasskeyStatus');
|
||||
|
||||
addBtn?.addEventListener('click', () => {
|
||||
addContainer.style.display = 'block';
|
||||
addBtn.style.display = 'none';
|
||||
});
|
||||
|
||||
cancelBtn?.addEventListener('click', () => {
|
||||
addContainer.style.display = 'none';
|
||||
addBtn.style.display = 'inline-flex';
|
||||
statusDiv.textContent = '';
|
||||
});
|
||||
|
||||
confirmBtn?.addEventListener('click', async () => {
|
||||
confirmBtn.disabled = true;
|
||||
statusDiv.textContent = 'Follow prompt on device...';
|
||||
statusDiv.style.color = 'var(--primary)';
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/passkeys/register/challenge", { method: "POST" });
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
statusDiv.textContent = data.error || "Failed to get registration challenge";
|
||||
statusDiv.style.color = 'var(--danger)';
|
||||
confirmBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const { startRegistration } = SimpleWebAuthnBrowser;
|
||||
const attResp = await startRegistration({ optionsJSON: data.options });
|
||||
|
||||
const verificationResp = await fetch("/api/passkeys/register/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ response: attResp }),
|
||||
});
|
||||
|
||||
const verificationJSON = await verificationResp.json();
|
||||
if (verificationJSON.success) {
|
||||
statusDiv.textContent = "Passkey registered successfully! Reloading...";
|
||||
statusDiv.style.color = 'var(--success-text)';
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
statusDiv.textContent = verificationJSON.error || "Registration verification failed";
|
||||
statusDiv.style.color = 'var(--danger)';
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
statusDiv.textContent = err.message || 'An error occurred.';
|
||||
statusDiv.style.color = 'var(--danger)';
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Re-generate Recovery Voucher
|
||||
let backupMnemonic = "";
|
||||
document.getElementById('regenVoucherBtn')?.addEventListener('click', async () => {
|
||||
const entropy = new Uint8Array(16);
|
||||
crypto.getRandomValues(entropy);
|
||||
backupMnemonic = await entropyToMnemonic(entropy);
|
||||
|
||||
const grid = document.getElementById('generatedWordGrid');
|
||||
grid.innerHTML = backupMnemonic.split(' ').map((w, idx) => \`
|
||||
<div class="word-cell">
|
||||
<span style="color: var(--text-muted); font-size: 0.75rem; width: 18px;">\${idx + 1}.</span>
|
||||
<span style="color: var(--text-primary); font-weight: 600;">\${w}</span>
|
||||
</div>
|
||||
\`).join('');
|
||||
|
||||
document.getElementById('voucherModal').style.display = 'block';
|
||||
});
|
||||
|
||||
document.getElementById('copyVoucherBtn')?.addEventListener('click', async () => {
|
||||
if (!backupMnemonic) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(backupMnemonic);
|
||||
const btn = document.getElementById('copyVoucherBtn');
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.innerHTML = '<span>✓ Copied 12 Words to Clipboard!</span>';
|
||||
setTimeout(() => { btn.innerHTML = origHtml; }, 2500);
|
||||
} catch (_e) {
|
||||
alert(backupMnemonic);
|
||||
}
|
||||
});
|
||||
`,
|
||||
}}
|
||||
>
|
||||
</script>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
71
ui/public/ui/utils/bip39.ts
Normal file
71
ui/public/ui/utils/bip39.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { WORDLIST } from "./bip39_wordlist.ts";
|
||||
|
||||
export async function entropyToMnemonic(entropy: Uint8Array): Promise<string> {
|
||||
if (entropy.length < 16 || entropy.length > 32 || entropy.length % 4 !== 0) {
|
||||
throw new Error("Invalid entropy length");
|
||||
}
|
||||
|
||||
const entropyBits = Array.from(entropy)
|
||||
.map((b) => b.toString(2).padStart(8, "0"))
|
||||
.join("");
|
||||
|
||||
const entropyBuffer = new Uint8Array(entropy.length);
|
||||
entropyBuffer.set(entropy);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", entropyBuffer);
|
||||
const hashBits = Array.from(new Uint8Array(hashBuffer))
|
||||
.map((b) => b.toString(2).padStart(8, "0"))
|
||||
.join("");
|
||||
|
||||
const checksumLength = entropy.length / 4;
|
||||
const checksum = hashBits.slice(0, checksumLength);
|
||||
|
||||
const bits = entropyBits + checksum;
|
||||
const chunks = bits.match(/(.{1,11})/g) || [];
|
||||
|
||||
const mnemonic = chunks.map((binaryStr) => {
|
||||
const index = parseInt(binaryStr, 2);
|
||||
return WORDLIST[index];
|
||||
});
|
||||
|
||||
return mnemonic.join(" ");
|
||||
}
|
||||
|
||||
export async function mnemonicToEntropy(mnemonic: string): Promise<Uint8Array> {
|
||||
const words = mnemonic.normalize("NFKD").trim().split(/\s+/);
|
||||
if (words.length % 3 !== 0) {
|
||||
throw new Error("Invalid mnemonic length");
|
||||
}
|
||||
|
||||
const bits = words
|
||||
.map((word) => {
|
||||
const index = WORDLIST.indexOf(word);
|
||||
if (index === -1) {
|
||||
throw new Error(`Invalid word in mnemonic: ${word}`);
|
||||
}
|
||||
return index.toString(2).padStart(11, "0");
|
||||
})
|
||||
.join("");
|
||||
|
||||
const dividerIndex = Math.floor(bits.length / 33) * 32;
|
||||
const entropyBits = bits.slice(0, dividerIndex);
|
||||
const checksumBits = bits.slice(dividerIndex);
|
||||
|
||||
const entropy = new Uint8Array(entropyBits.length / 8);
|
||||
for (let i = 0; i < entropy.length; i++) {
|
||||
entropy[i] = parseInt(entropyBits.slice(i * 8, (i + 1) * 8), 2);
|
||||
}
|
||||
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", entropy);
|
||||
const hashBits = Array.from(new Uint8Array(hashBuffer))
|
||||
.map((b) => b.toString(2).padStart(8, "0"))
|
||||
.join("");
|
||||
const expectedChecksum = hashBits.slice(0, checksumBits.length);
|
||||
|
||||
if (expectedChecksum !== checksumBits) {
|
||||
// Explicitly zeroize on failure
|
||||
entropy.fill(0);
|
||||
throw new Error("Invalid mnemonic checksum");
|
||||
}
|
||||
|
||||
return entropy;
|
||||
}
|
||||
2050
ui/public/ui/utils/bip39_wordlist.ts
Normal file
2050
ui/public/ui/utils/bip39_wordlist.ts
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user