05/18 Enhanced codes and functionalities 5
This commit is contained in:
@@ -316,6 +316,44 @@ const Auth = (() => {
|
||||
const notice = document.getElementById("register-notice");
|
||||
if (notice) notice.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// Passkey login button (only present on login.html)
|
||||
const passkeyBtn = document.getElementById("btn-passkey-login");
|
||||
if (passkeyBtn) {
|
||||
passkeyBtn.addEventListener("click", async () => {
|
||||
const errEl = document.getElementById("passkey-error");
|
||||
errEl.classList.add("hidden");
|
||||
passkeyBtn.disabled = true;
|
||||
passkeyBtn.textContent = "Waiting for passkey…";
|
||||
|
||||
const email = (document.getElementById("email")?.value || "").trim().toLowerCase();
|
||||
const result = await PasskeyAuth.loginWithPasskey(email);
|
||||
|
||||
passkeyBtn.disabled = false;
|
||||
passkeyBtn.textContent = "🔑 Sign in with Passkey";
|
||||
|
||||
if (result.error) {
|
||||
errEl.textContent = result.error;
|
||||
errEl.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
// Tokens received — store them, then prompt for master password to unlock vault.
|
||||
sessionStorage.setItem("access_token", result.data.access_token);
|
||||
localStorage.setItem("refresh_token", result.data.refresh_token);
|
||||
sessionStorage.setItem("enc_key_salt", result.data.enc_key_salt);
|
||||
|
||||
// Derive vault key from master password.
|
||||
// Re-use the same master-password input field; if empty, show unlock overlay.
|
||||
const pw = document.getElementById("password")?.value;
|
||||
if (pw) {
|
||||
const vaultKey = await Crypto.deriveVaultKey(pw, result.data.enc_key_salt);
|
||||
VaultSession.setKey(vaultKey);
|
||||
}
|
||||
// Navigate to vault — if vault key wasn't derived, unlock overlay will show.
|
||||
window.location.href = "/vault";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { init };
|
||||
@@ -337,3 +375,208 @@ const VaultSession = (() => {
|
||||
}
|
||||
return { setKey, getKey, clear };
|
||||
})();
|
||||
|
||||
// ── PasskeyAuth — WebAuthn / Passkey login and registration management ────────
|
||||
const PasskeyAuth = (() => {
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Convert ArrayBuffer → base64url string (no padding). */
|
||||
function _bufToB64url(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let bin = '';
|
||||
bytes.forEach((b) => (bin += String.fromCharCode(b)));
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/** Convert base64url string → Uint8Array. */
|
||||
function _b64urlToBuf(str) {
|
||||
str = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (str.length % 4) str += '=';
|
||||
const bin = atob(str);
|
||||
const arr = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a PublicKeyCredentialCreationOptions or RequestOptions object
|
||||
* returned by the server (JSON) into the format expected by navigator.credentials.
|
||||
* The browser API requires ArrayBuffers for challenge and user.id; the server
|
||||
* sends base64url strings.
|
||||
*/
|
||||
function _prepareCreationOptions(opts) {
|
||||
opts.challenge = _b64urlToBuf(opts.challenge);
|
||||
if (opts.user?.id) opts.user.id = _b64urlToBuf(opts.user.id);
|
||||
if (opts.excludeCredentials) {
|
||||
opts.excludeCredentials = opts.excludeCredentials.map((c) => ({
|
||||
...c,
|
||||
id: _b64urlToBuf(c.id),
|
||||
}));
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function _prepareRequestOptions(opts) {
|
||||
opts.challenge = _b64urlToBuf(opts.challenge);
|
||||
if (opts.allowCredentials) {
|
||||
opts.allowCredentials = opts.allowCredentials.map((c) => ({
|
||||
...c,
|
||||
id: _b64urlToBuf(c.id),
|
||||
}));
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise a PublicKeyCredential returned by navigator.credentials.create()
|
||||
* or navigator.credentials.get() into a plain JSON-serialisable object that
|
||||
* the server can accept.
|
||||
*/
|
||||
function _credentialToJson(cred) {
|
||||
const resp = cred.response;
|
||||
const obj = {
|
||||
id: cred.id,
|
||||
rawId: _bufToB64url(cred.rawId),
|
||||
type: cred.type,
|
||||
response: {},
|
||||
};
|
||||
|
||||
if (resp.clientDataJSON !== undefined)
|
||||
obj.response.clientDataJSON = _bufToB64url(resp.clientDataJSON);
|
||||
if (resp.attestationObject !== undefined)
|
||||
obj.response.attestationObject = _bufToB64url(resp.attestationObject);
|
||||
if (resp.authenticatorData !== undefined)
|
||||
obj.response.authenticatorData = _bufToB64url(resp.authenticatorData);
|
||||
if (resp.signature !== undefined)
|
||||
obj.response.signature = _bufToB64url(resp.signature);
|
||||
if (resp.userHandle !== undefined && resp.userHandle !== null)
|
||||
obj.response.userHandle = _bufToB64url(resp.userHandle);
|
||||
|
||||
// Include transport hints if available (registration only).
|
||||
if (typeof resp.getTransports === 'function') {
|
||||
obj.response.transports = resp.getTransports();
|
||||
}
|
||||
if (cred.authenticatorAttachment) {
|
||||
obj.authenticatorAttachment = cred.authenticatorAttachment;
|
||||
}
|
||||
if (cred.clientExtensionResults) {
|
||||
obj.clientExtensionResults = cred.getClientExtensionResults?.() ?? {};
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
// ── Login flow ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initiate a passkey login.
|
||||
* 1. Call /api/webauthn/authenticate/begin (optionally with email hint).
|
||||
* 2. Invoke navigator.credentials.get() — browser shows passkey picker.
|
||||
* 3. Send the assertion to /api/webauthn/authenticate/complete.
|
||||
* 4. On success: store tokens and derive vault key from master password.
|
||||
* The master password is still required to unlock the vault (ZK preserved).
|
||||
*/
|
||||
async function loginWithPasskey(email) {
|
||||
if (!window.PublicKeyCredential) {
|
||||
return { error: 'Passkeys are not supported in this browser.' };
|
||||
}
|
||||
|
||||
// Step 1: get options from server.
|
||||
const beginRes = await fetch('/api/webauthn/authenticate/begin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email || '' }),
|
||||
});
|
||||
if (!beginRes.ok) {
|
||||
const d = await beginRes.json().catch(() => ({}));
|
||||
return { error: d.error || 'Could not start passkey authentication.' };
|
||||
}
|
||||
const options = await beginRes.json();
|
||||
|
||||
// Step 2: browser passkey picker.
|
||||
let assertion;
|
||||
try {
|
||||
assertion = await navigator.credentials.get({
|
||||
publicKey: _prepareRequestOptions(options),
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.name === 'NotAllowedError') return { error: 'Passkey cancelled.' };
|
||||
return { error: e.message || 'Passkey authentication failed.' };
|
||||
}
|
||||
|
||||
// Step 3: send assertion to server.
|
||||
const completeRes = await fetch('/api/webauthn/authenticate/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(_credentialToJson(assertion)),
|
||||
});
|
||||
const completeData = await completeRes.json().catch(() => ({}));
|
||||
if (!completeRes.ok) {
|
||||
return { error: completeData.error || 'Passkey authentication failed.' };
|
||||
}
|
||||
|
||||
return { ok: true, data: completeData };
|
||||
}
|
||||
|
||||
// ── Registration flow (settings page) ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register a new passkey for the currently logged-in user.
|
||||
* Requires an active access_token in sessionStorage (set by vault.js on login).
|
||||
* @param {string} name User-friendly name for the passkey (e.g. "iPhone 15").
|
||||
*/
|
||||
async function registerPasskey(name) {
|
||||
if (!window.PublicKeyCredential) {
|
||||
return { error: 'Passkeys are not supported in this browser.' };
|
||||
}
|
||||
|
||||
const token = sessionStorage.getItem('access_token');
|
||||
if (!token) return { error: 'Not authenticated.' };
|
||||
|
||||
// Step 1: get creation options from server.
|
||||
const beginRes = await fetch('/api/webauthn/register/begin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!beginRes.ok) {
|
||||
const d = await beginRes.json().catch(() => ({}));
|
||||
return { error: d.error || 'Could not start passkey registration.' };
|
||||
}
|
||||
const options = await beginRes.json();
|
||||
|
||||
// Step 2: create credential.
|
||||
let credential;
|
||||
try {
|
||||
credential = await navigator.credentials.create({
|
||||
publicKey: _prepareCreationOptions(options),
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.name === 'NotAllowedError') return { error: 'Passkey registration cancelled.' };
|
||||
return { error: e.message || 'Passkey creation failed.' };
|
||||
}
|
||||
|
||||
// Step 3: send attestation to server.
|
||||
const payload = _credentialToJson(credential);
|
||||
payload.name = name || 'Passkey';
|
||||
|
||||
const completeRes = await fetch('/api/webauthn/register/complete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const completeData = await completeRes.json().catch(() => ({}));
|
||||
if (!completeRes.ok) {
|
||||
return { error: completeData.error || 'Passkey registration failed.' };
|
||||
}
|
||||
|
||||
return { ok: true, credential: completeData.credential };
|
||||
}
|
||||
|
||||
return { loginWithPasskey, registerPasskey };
|
||||
})();
|
||||
|
||||
+132
-4
@@ -1753,7 +1753,9 @@ const Vault = (() => {
|
||||
granteePubKey,
|
||||
);
|
||||
|
||||
// Re-encrypt all decrypted vault items
|
||||
// Re-encrypt all decrypted vault items.
|
||||
// Item name is encrypted with the same ECDH shared key so the server
|
||||
// never sees plaintext names inside enc_vault (zero-knowledge).
|
||||
const encItems = await Promise.all(
|
||||
_items
|
||||
.filter((i) => i.plain)
|
||||
@@ -1762,12 +1764,17 @@ const Vault = (() => {
|
||||
sharedKey,
|
||||
i.plain,
|
||||
);
|
||||
const { enc_name, iv_name } = await SharingCrypto.encryptName(
|
||||
sharedKey,
|
||||
i.name,
|
||||
);
|
||||
return {
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
item_type: i.item_type,
|
||||
enc_data,
|
||||
iv,
|
||||
enc_name,
|
||||
iv_name,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -1889,9 +1896,20 @@ const Vault = (() => {
|
||||
i.enc_data,
|
||||
i.iv,
|
||||
);
|
||||
return { ...i, plain };
|
||||
// Decrypt the item display name if available (new format).
|
||||
// Fall back to item_type label for legacy enc_vault snapshots.
|
||||
let displayName = i.item_type || "password";
|
||||
if (i.enc_name && i.iv_name) {
|
||||
const decName = await SharingCrypto.decryptName(
|
||||
sharedKey,
|
||||
i.enc_name,
|
||||
i.iv_name,
|
||||
);
|
||||
if (decName) displayName = decName;
|
||||
}
|
||||
return { ...i, name: displayName, plain };
|
||||
} catch {
|
||||
return { ...i, plain: null };
|
||||
return { ...i, name: i.item_type || "password", plain: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -2211,6 +2229,7 @@ const Vault = (() => {
|
||||
loadSharingKeysStatus(),
|
||||
loadRecoveryStatus(),
|
||||
loadAuditLog(0, true),
|
||||
loadPasskeys(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -2229,6 +2248,115 @@ const Vault = (() => {
|
||||
let _auditOffset = 0;
|
||||
let _auditTotal = 0;
|
||||
|
||||
// ── Passkey (WebAuthn) management ────────────────────────────────────────────
|
||||
|
||||
async function loadPasskeys() {
|
||||
const listEl = document.getElementById("passkeys-list");
|
||||
const errEl = document.getElementById("passkeys-error");
|
||||
if (!listEl) return;
|
||||
errEl?.classList.add("hidden");
|
||||
|
||||
// Hide the section if WebAuthn is not supported in this browser.
|
||||
if (!window.PublicKeyCredential) {
|
||||
document.getElementById("passkeys-section")?.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiFetch("/api/webauthn/credentials");
|
||||
const creds = await res.json();
|
||||
if (!Array.isArray(creds) || !creds.length) {
|
||||
listEl.innerHTML = '<p class="settings-desc">No passkeys registered yet.</p>';
|
||||
} else {
|
||||
listEl.innerHTML = creds
|
||||
.map((c) => {
|
||||
const created = c.created_at
|
||||
? new Date(c.created_at).toLocaleDateString()
|
||||
: "";
|
||||
const lastUsed = c.last_used_at
|
||||
? `Last used ${new Date(c.last_used_at).toLocaleDateString()}`
|
||||
: "Never used";
|
||||
return `<div class="passkey-item" data-cred-id="${c.id}">
|
||||
<div class="passkey-info">
|
||||
<span class="passkey-name">${escHtml(c.name)}</span>
|
||||
<span class="passkey-meta">${lastUsed} · Added ${escHtml(created)}</span>
|
||||
</div>
|
||||
<div class="passkey-actions">
|
||||
<button class="btn-text btn-sm btn-rename-passkey" data-id="${c.id}" data-name="${escHtml(c.name)}">Rename</button>
|
||||
<button class="btn-danger-text btn-sm btn-delete-passkey" data-id="${c.id}" data-name="${escHtml(c.name)}">Remove</button>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// Wire rename buttons.
|
||||
listEl.querySelectorAll(".btn-rename-passkey").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const newName = prompt("New passkey name:", btn.dataset.name);
|
||||
if (!newName?.trim()) return;
|
||||
try {
|
||||
await apiFetch(`/api/webauthn/credentials/${btn.dataset.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name: newName.trim() }),
|
||||
});
|
||||
await loadPasskeys();
|
||||
} catch (e) {
|
||||
showToast("Failed to rename passkey", "error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Wire delete buttons.
|
||||
listEl.querySelectorAll(".btn-delete-passkey").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!confirm(`Remove passkey "${btn.dataset.name}"?`)) return;
|
||||
try {
|
||||
await apiFetch(`/api/webauthn/credentials/${btn.dataset.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
showToast("Passkey removed");
|
||||
await loadPasskeys();
|
||||
} catch (e) {
|
||||
showToast("Failed to remove passkey", "error");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<p class="settings-desc">Could not load passkeys.</p>';
|
||||
}
|
||||
|
||||
// Wire register button (only bind once).
|
||||
const registerBtn = document.getElementById("btn-register-passkey");
|
||||
if (registerBtn && !registerBtn.dataset.bound) {
|
||||
registerBtn.dataset.bound = "1";
|
||||
registerBtn.addEventListener("click", async () => {
|
||||
const nameInput = document.getElementById("passkey-name-input");
|
||||
const name = (nameInput?.value || "").trim() || "Passkey";
|
||||
const errEl = document.getElementById("passkeys-error");
|
||||
errEl?.classList.add("hidden");
|
||||
registerBtn.disabled = true;
|
||||
registerBtn.textContent = "Waiting…";
|
||||
|
||||
const result = await PasskeyAuth.registerPasskey(name);
|
||||
|
||||
registerBtn.disabled = false;
|
||||
registerBtn.textContent = "+ Add passkey";
|
||||
|
||||
if (result.error) {
|
||||
if (errEl) {
|
||||
errEl.textContent = result.error;
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (nameInput) nameInput.value = "";
|
||||
showToast(`Passkey "${escHtml(result.credential.name)}" registered`);
|
||||
await loadPasskeys();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAuditLog(offset = 0, reset = false) {
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
|
||||
Reference in New Issue
Block a user