05/18 Enhanced codes and functionalities 5

This commit is contained in:
2026-05-18 18:27:24 -04:00
parent 09d3bbdc15
commit 46765b9448
11 changed files with 1042 additions and 4 deletions
+243
View File
@@ -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
View File
@@ -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(