Add TOTP (2FA) support to entries
- New src/lib/crypto/totp.js: native RFC 6238 TOTP using Web Crypto HMAC-SHA1 (no external crypto). base32Decode, extractSecret (bare base32 or otpauth:// URI), generateTotp, totpRemainingSeconds. - Entries gain an optional encryptedTotpSecret, stored AES-GCM-encrypted like passwords. schema.js createEntry/updateEntry/docs updated. - Export/import re-key the TOTP secret alongside passwords when sealing with a separate password, and decrypt/re-encrypt on import so TOTP survives moves. - EntryForm: optional 'TOTP Secret (2FA)' field (base32 or otpauth:// URI). - EntryDetail: live 6-digit TOTP display updating every second with a countdown and urgency indicator, plus copy; guarded cleanup timer on unmount. - Tests: RFC 6238 SHA-1 vectors (6 & 8 digit), base32/extractSecret, remaining seconds, schema round-trip. 157 total pass.
This commit is contained in:
parent
b8e7ce75f7
commit
9758e80a02
452
dist/index.html
vendored
452
dist/index.html
vendored
@ -111,6 +111,14 @@ var STALE_REACTION = new class StaleReactionError extends Error {
|
||||
message = "The reaction that called `getAbortSignal()` was re-run or destroyed";
|
||||
}();
|
||||
var IS_XHTML = !!globalThis.document?.contentType && /* @__PURE__ */ globalThis.document.contentType.includes("xml");
|
||||
/**
|
||||
* `%name%(...)` can only be used during component initialisation
|
||||
* @param {string} name
|
||||
* @returns {never}
|
||||
*/
|
||||
function lifecycle_outside_component(name) {
|
||||
throw new Error(`https://svelte.dev/e/lifecycle_outside_component`);
|
||||
}
|
||||
//#endregion
|
||||
//#region node_modules/svelte/src/internal/client/errors.js
|
||||
/**
|
||||
@ -4767,6 +4775,53 @@ function observe_all(context, props) {
|
||||
props();
|
||||
}
|
||||
if (typeof HTMLElement === "function");
|
||||
/**
|
||||
* `onMount`, like [`$effect`](https://svelte.dev/docs/svelte/$effect), schedules a function to run as soon as the component has been mounted to the DOM.
|
||||
* Unlike `$effect`, the provided function only runs once.
|
||||
*
|
||||
* It must be called during the component's initialisation (but doesn't need to live _inside_ the component;
|
||||
* it can be called from an external module). If a function is returned _synchronously_ from `onMount`,
|
||||
* it will be called when the component is unmounted.
|
||||
*
|
||||
* `onMount` functions do not run during [server-side rendering](https://svelte.dev/docs/svelte/svelte-server#render).
|
||||
*
|
||||
* @template T
|
||||
* @param {() => NotFunction<T> | Promise<NotFunction<T>> | (() => any)} fn
|
||||
* @returns {void}
|
||||
*/
|
||||
function onMount(fn) {
|
||||
if (component_context === null) lifecycle_outside_component("onMount");
|
||||
if (legacy_mode_flag && component_context.l !== null) init_update_callbacks(component_context).m.push(fn);
|
||||
else user_effect(() => {
|
||||
const cleanup = untrack(fn);
|
||||
if (typeof cleanup === "function") return cleanup;
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Schedules a callback to run immediately before the component is unmounted.
|
||||
*
|
||||
* Out of `onMount`, `beforeUpdate`, `afterUpdate` and `onDestroy`, this is the
|
||||
* only one that runs inside a server-side component.
|
||||
*
|
||||
* @param {() => any} fn
|
||||
* @returns {void}
|
||||
*/
|
||||
function onDestroy(fn) {
|
||||
if (component_context === null) lifecycle_outside_component("onDestroy");
|
||||
onMount(() => () => untrack(fn));
|
||||
}
|
||||
/**
|
||||
* Legacy-mode: Init callbacks object for onMount/beforeUpdate/afterUpdate
|
||||
* @param {ComponentContext} context
|
||||
*/
|
||||
function init_update_callbacks(context) {
|
||||
var l = context.l;
|
||||
return l.u ??= {
|
||||
a: [],
|
||||
b: [],
|
||||
m: []
|
||||
};
|
||||
}
|
||||
//#endregion
|
||||
//#region node_modules/svelte/src/internal/disclose-version.js
|
||||
if (typeof window !== "undefined") ((window.__svelte ??= {}).v ??= /* @__PURE__ */ new Set()).add("5");
|
||||
@ -5018,6 +5073,7 @@ function generateId() {
|
||||
* @property {string} title - Display name (e.g. "GitHub", "Gmail")
|
||||
* @property {string} [username] - Login username or email (optional)
|
||||
* @property {string} [encryptedPassword] - AES-GCM encrypted password blob (JSON string); optional
|
||||
* @property {string} [encryptedTotpSecret] - AES-GCM encrypted TOTP base32 secret (JSON string); optional
|
||||
* @property {string} [url] - Website URL
|
||||
* @property {string} [notes] - Free-form notes
|
||||
* @property {string} [groupId] - Reference to a Group id (empty string = no group)
|
||||
@ -5032,6 +5088,7 @@ function generateId() {
|
||||
* @param {string} data.title
|
||||
* @param {string} [data.username]
|
||||
* @param {string} [data.encryptedPassword] - Must already be encrypted (optional; empty string = no password)
|
||||
* @param {string} [data.encryptedTotpSecret] - Must already be encrypted (optional; empty = no TOTP)
|
||||
* @param {string} [data.url]
|
||||
* @param {string} [data.notes]
|
||||
* @param {string} [data.groupId]
|
||||
@ -5045,6 +5102,7 @@ function createEntry(data) {
|
||||
title: data.title.trim(),
|
||||
username: data.username?.trim() || "",
|
||||
encryptedPassword: data.encryptedPassword,
|
||||
encryptedTotpSecret: data.encryptedTotpSecret,
|
||||
url: data.url?.trim() || "",
|
||||
notes: data.notes?.trim() || "",
|
||||
groupId: data.groupId || "",
|
||||
@ -5066,6 +5124,7 @@ function updateEntry$1(existing, data) {
|
||||
title: data.title !== void 0 ? data.title.trim() : existing.title,
|
||||
username: data.username !== void 0 ? data.username?.trim() || "" : existing.username,
|
||||
encryptedPassword: data.encryptedPassword !== void 0 ? data.encryptedPassword : existing.encryptedPassword,
|
||||
encryptedTotpSecret: data.encryptedTotpSecret !== void 0 ? data.encryptedTotpSecret : existing.encryptedTotpSecret,
|
||||
url: data.url !== void 0 ? data.url.trim() : existing.url,
|
||||
notes: data.notes !== void 0 ? data.notes.trim() : existing.notes,
|
||||
groupId: data.groupId !== void 0 ? data.groupId : existing.groupId,
|
||||
@ -5650,8 +5709,8 @@ async function exportSelected(groupIds = null, options = {}) {
|
||||
sealKey = await deriveKey(password, exportSalt);
|
||||
envelopeSalt = uint8ArrayToBase64(exportSalt);
|
||||
for (const entry of payload.entries) {
|
||||
if (!entry.encryptedPassword) continue;
|
||||
entry.encryptedPassword = await encrypt(await decrypt(entry.encryptedPassword, vaultKey), sealKey);
|
||||
if (entry.encryptedPassword) entry.encryptedPassword = await encrypt(await decrypt(entry.encryptedPassword, vaultKey), sealKey);
|
||||
if (entry.encryptedTotpSecret) entry.encryptedTotpSecret = await encrypt(await decrypt(entry.encryptedTotpSecret, vaultKey), sealKey);
|
||||
}
|
||||
payload.meta.salt = envelopeSalt;
|
||||
}
|
||||
@ -5709,11 +5768,16 @@ async function importAll(data, mode = "merge", sourcePassword = "", targetKey =
|
||||
}
|
||||
for (const entry of data.entries) try {
|
||||
let reencryptedEntry = { ...entry };
|
||||
if (sourceKey && targetKey && entry.encryptedPassword) reencryptedEntry.encryptedPassword = await encrypt(await decrypt(entry.encryptedPassword, sourceKey), targetKey);
|
||||
else if (!sourceKey || !targetKey) {
|
||||
console.warn("Skipping entry (missing source password or target key):", entry.title);
|
||||
skipped++;
|
||||
continue;
|
||||
const hasEncrypted = !!(entry.encryptedPassword || entry.encryptedTotpSecret);
|
||||
if (sourceKey && targetKey && hasEncrypted) {
|
||||
if (entry.encryptedPassword) reencryptedEntry.encryptedPassword = await encrypt(await decrypt(entry.encryptedPassword, sourceKey), targetKey);
|
||||
if (entry.encryptedTotpSecret) reencryptedEntry.encryptedTotpSecret = await encrypt(await decrypt(entry.encryptedTotpSecret, sourceKey), targetKey);
|
||||
} else if (!sourceKey || !targetKey) {
|
||||
if (hasEncrypted) {
|
||||
console.warn("Skipping entry (missing source password or target):", entry.title);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await db.put("entries", reencryptedEntry);
|
||||
importedEntries++;
|
||||
@ -6533,6 +6597,104 @@ function EntryList($$anchor, $$props) {
|
||||
}
|
||||
delegate(["click"]);
|
||||
//#endregion
|
||||
//#region src/lib/crypto/totp.js
|
||||
/**
|
||||
* TOTP (Time-based One-Time Password) — RFC 6238 / RFC 4226.
|
||||
*
|
||||
* Implemented with the browser's native Web Crypto API (HMAC-SHA1), so no
|
||||
* external crypto dependency. A base32 secret yields 6-digit codes that change
|
||||
* every 30 seconds, matching common 2FA authenticator apps.
|
||||
*/
|
||||
var DEFAULT_PERIOD = 30;
|
||||
var DEFAULT_DIGITS = 6;
|
||||
var BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
/**
|
||||
* Decode a base32 string (RFC 4648) into bytes. Accepts whitespace (from
|
||||
* authenticator-exported secrets). Throws on invalid characters.
|
||||
*
|
||||
* @param {string} base32
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
function base32Decode(base32) {
|
||||
const clean = String(base32).toUpperCase().replace(/[\s=]/g, "");
|
||||
if (!clean) return new Uint8Array(0);
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const bytes = [];
|
||||
for (const ch of clean) {
|
||||
const idx = BASE32_ALPHABET.indexOf(ch);
|
||||
if (idx === -1) throw new Error(`Invalid base32 character: "${ch}"`);
|
||||
value = value << 5 | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bytes.push(value >>> bits - 8 & 255);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
/**
|
||||
* Extract a raw base32 secret from the user input, accepting either a bare
|
||||
* base32 string or an otpauth:// URI (which embeds `secret=`).
|
||||
*
|
||||
* @param {string} input
|
||||
* @returns {string} Base32 secret (uppercased, no whitespace)
|
||||
*/
|
||||
function extractSecret(input) {
|
||||
const text = String(input || "").trim();
|
||||
if (!text) return "";
|
||||
if (/^otpauth:\/\//i.test(text)) try {
|
||||
const m = text.match(/[?&]secret=([^&]+)/i);
|
||||
if (m) return m[1].toUpperCase().replace(/[^A-Z2-7]/g, "");
|
||||
} catch {}
|
||||
return text.toUpperCase().replace(/[\s-]/g, "");
|
||||
}
|
||||
/**
|
||||
* Generate the 8-byte big-endian counter for a Unix timestamp.
|
||||
* @param {number} counter
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
function counterBytes(counter) {
|
||||
const buf = new Uint8Array(8);
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
buf[i] = counter & 255;
|
||||
counter = Math.floor(counter / 256);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
/**
|
||||
* Compute a TOTP code for a secret at a given Unix timestamp.
|
||||
*
|
||||
* @param {string} secret - Base32 secret (SCHEME uri also accepted via extractSecret)
|
||||
* @param {Object} [opts]
|
||||
* @param {number} [opts.timestamp=Date.now()/1000] - Unix seconds
|
||||
* @param {number} [opts.period=30]
|
||||
* @param {number} [opts.digits=6]
|
||||
* @returns {Promise<string>} Zero-padded code.
|
||||
*/
|
||||
async function generateTotp(secret, { timestamp = Math.floor(Date.now() / 1e3), period = DEFAULT_PERIOD, digits = DEFAULT_DIGITS } = {}) {
|
||||
const keyBytes = base32Decode(extractSecret(secret));
|
||||
if (keyBytes.length === 0) throw new Error("TOTP secret is empty or invalid");
|
||||
const counter = Math.floor(timestamp / period);
|
||||
const key = await crypto.subtle.importKey("raw", keyBytes, {
|
||||
name: "HMAC",
|
||||
hash: "SHA-1"
|
||||
}, false, ["sign"]);
|
||||
const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, counterBytes(counter)));
|
||||
const offset = sig[sig.length - 1] & 15;
|
||||
return (((sig[offset] & 127) << 24 | sig[offset + 1] << 16 | sig[offset + 2] << 8 | sig[offset + 3]) % Math.pow(10, digits)).toString().padStart(digits, "0");
|
||||
}
|
||||
/**
|
||||
* Seconds remaining before the current TOTP code expires.
|
||||
* @param {Object} [opts]
|
||||
* @param {number} [opts.period=30]
|
||||
* @param {number} [opts.timestamp=Date.now()/1000]
|
||||
* @returns {number} 1..period
|
||||
*/
|
||||
function totpRemainingSeconds({ period = DEFAULT_PERIOD, timestamp = Math.floor(Date.now() / 1e3) } = {}) {
|
||||
return period - timestamp % period;
|
||||
}
|
||||
//#endregion
|
||||
//#region src/components/EntryDetail.svelte
|
||||
var root_1$5 = /* @__PURE__ */ from_html(`<div class="toast svelte-dssgjx"> </div>`);
|
||||
var root_2$3 = /* @__PURE__ */ from_html(`<div class="loading svelte-dssgjx">Loading...</div>`);
|
||||
@ -6542,17 +6704,21 @@ var root_6$2 = /* @__PURE__ */ from_html(`<button class="btn btn-primary btn-sm"
|
||||
var root_7$2 = /* @__PURE__ */ from_html(`<button class="btn btn-ghost btn-sm">✏️ Edit</button> <button class="btn btn-danger btn-sm">🗑 Move to Trash</button>`, 1);
|
||||
var root_8$2 = /* @__PURE__ */ from_html(`<div class="detail-field"><span class="field-label svelte-dssgjx">Username</span> <div class="field-value svelte-dssgjx"><span> </span> <button class="btn btn-ghost btn-sm copy-btn svelte-dssgjx" title="Copy username">📋</button></div></div>`);
|
||||
var root_9$1 = /* @__PURE__ */ from_html(`<div class="detail-field"><span class="field-label svelte-dssgjx">Password</span> <div class="field-value svelte-dssgjx"><span> </span> <button class="btn btn-ghost btn-sm" title="Toggle visibility"> </button> <button class="btn btn-ghost btn-sm copy-btn svelte-dssgjx" title="Copy password">📋</button></div></div>`);
|
||||
var root_10 = /* @__PURE__ */ from_html(`<div class="detail-field"><span class="field-label svelte-dssgjx">URL</span> <div class="field-value svelte-dssgjx"><a target="_blank" rel="noopener noreferrer" class="svelte-dssgjx"> </a> <button class="btn btn-ghost btn-sm copy-btn svelte-dssgjx" title="Copy URL">📋</button></div></div>`);
|
||||
var root_11 = /* @__PURE__ */ from_html(`<div class="detail-field"><span class="field-label svelte-dssgjx">Notes</span> <div class="field-value notes svelte-dssgjx"> </div></div>`);
|
||||
var root_12 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-dssgjx" role="presentation"><div class="modal svelte-dssgjx" role="dialog" aria-modal="true" aria-label="Move to trash confirmation" tabindex="-1"><h3 class="svelte-dssgjx">Move to Trash</h3> <p class="svelte-dssgjx">Move "<strong> </strong>" to the trash? You can restore it later.</p> <div class="modal-actions svelte-dssgjx"><button class="btn btn-danger"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
|
||||
var root_13$1 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-dssgjx" role="presentation"><div class="modal svelte-dssgjx" role="dialog" aria-modal="true" aria-label="Permanent delete confirmation" tabindex="-1"><h3 class="svelte-dssgjx">Delete Forever</h3> <p class="svelte-dssgjx">Permanently delete "<strong> </strong>"? This cannot be undone.</p> <div class="modal-actions svelte-dssgjx"><button class="btn btn-danger"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
|
||||
var root_5$3 = /* @__PURE__ */ from_html(`<div class="detail-card svelte-dssgjx"><div class="detail-header svelte-dssgjx"><h2 class="svelte-dssgjx"> </h2> <div class="header-actions svelte-dssgjx"><!></div></div> <div class="detail-fields svelte-dssgjx"><!> <!> <!> <!></div> <div class="detail-meta svelte-dssgjx"><span class="text-xs text-muted"> </span> <span class="text-xs text-muted"> </span></div></div> <!> <!>`, 1);
|
||||
var root_10 = /* @__PURE__ */ from_html(`<div class="detail-field"><span class="field-label svelte-dssgjx">2FA Code (TOTP)</span> <div class="field-value totp-value svelte-dssgjx"><span> </span> <button class="btn btn-ghost btn-sm copy-btn svelte-dssgjx" title="Copy 2FA code">📋</button></div> <div class="totp-remaining svelte-dssgjx" aria-hidden="true"><span></span> <span class="text-xs text-muted"> </span></div></div>`);
|
||||
var root_11 = /* @__PURE__ */ from_html(`<div class="detail-field"><span class="field-label svelte-dssgjx">URL</span> <div class="field-value svelte-dssgjx"><a target="_blank" rel="noopener noreferrer" class="svelte-dssgjx"> </a> <button class="btn btn-ghost btn-sm copy-btn svelte-dssgjx" title="Copy URL">📋</button></div></div>`);
|
||||
var root_12 = /* @__PURE__ */ from_html(`<div class="detail-field"><span class="field-label svelte-dssgjx">Notes</span> <div class="field-value notes svelte-dssgjx"> </div></div>`);
|
||||
var root_13$1 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-dssgjx" role="presentation"><div class="modal svelte-dssgjx" role="dialog" aria-modal="true" aria-label="Move to trash confirmation" tabindex="-1"><h3 class="svelte-dssgjx">Move to Trash</h3> <p class="svelte-dssgjx">Move "<strong> </strong>" to the trash? You can restore it later.</p> <div class="modal-actions svelte-dssgjx"><button class="btn btn-danger"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
|
||||
var root_14 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-dssgjx" role="presentation"><div class="modal svelte-dssgjx" role="dialog" aria-modal="true" aria-label="Permanent delete confirmation" tabindex="-1"><h3 class="svelte-dssgjx">Delete Forever</h3> <p class="svelte-dssgjx">Permanently delete "<strong> </strong>"? This cannot be undone.</p> <div class="modal-actions svelte-dssgjx"><button class="btn btn-danger"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
|
||||
var root_5$3 = /* @__PURE__ */ from_html(`<div class="detail-card svelte-dssgjx"><div class="detail-header svelte-dssgjx"><h2 class="svelte-dssgjx"> </h2> <div class="header-actions svelte-dssgjx"><!></div></div> <div class="detail-fields svelte-dssgjx"><!> <!> <!> <!> <!></div> <div class="detail-meta svelte-dssgjx"><span class="text-xs text-muted"> </span> <span class="text-xs text-muted"> </span></div></div> <!> <!>`, 1);
|
||||
var root$4 = /* @__PURE__ */ from_html(`<div class="entry-detail"><!> <!></div>`);
|
||||
function EntryDetail($$anchor, $$props) {
|
||||
push($$props, true);
|
||||
let entry = /* @__PURE__ */ state(null);
|
||||
let passwordVisible = /* @__PURE__ */ state(false);
|
||||
let decryptedPassword = /* @__PURE__ */ state("");
|
||||
let totpCode = /* @__PURE__ */ state("");
|
||||
let totpRemaining = /* @__PURE__ */ state(30);
|
||||
let totalRemaining = /* @__PURE__ */ state(false);
|
||||
let loading = /* @__PURE__ */ state(true);
|
||||
let error = /* @__PURE__ */ state("");
|
||||
let showDeleteConfirm = /* @__PURE__ */ state(false);
|
||||
@ -6566,13 +6732,46 @@ function EntryDetail($$anchor, $$props) {
|
||||
set(error, "");
|
||||
try {
|
||||
set(entry, await getEntryById($$props.entryId), true);
|
||||
if (get(entry) && app$1.encryptionKey) set(decryptedPassword, get(entry).encryptedPassword ? await decrypt(get(entry).encryptedPassword, app$1.encryptionKey) : "", true);
|
||||
if (get(entry) && app$1.encryptionKey) {
|
||||
set(decryptedPassword, get(entry).encryptedPassword ? await decrypt(get(entry).encryptedPassword, app$1.encryptionKey) : "", true);
|
||||
if (get(entry).encryptedTotpSecret) {
|
||||
const secret = await decrypt(get(entry).encryptedTotpSecret, app$1.encryptionKey);
|
||||
await refreshTotp(secret);
|
||||
startTotpTimer(secret);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
set(error, "Failed to load entry: " + e.message);
|
||||
}
|
||||
set(loading, false);
|
||||
}
|
||||
loadEntry();
|
||||
let totpTimer = null;
|
||||
async function refreshTotp(secret) {
|
||||
try {
|
||||
set(totpCode, await generateTotp(secret), true);
|
||||
set(totpRemaining, totpRemainingSeconds(), true);
|
||||
set(totalRemaining, get(totpRemaining) <= 5);
|
||||
} catch (e) {
|
||||
set(totpCode, "");
|
||||
set(totpRemaining, 0);
|
||||
}
|
||||
}
|
||||
function startTotpTimer(secret) {
|
||||
stopTotpTimer();
|
||||
totpTimer = setInterval(async () => {
|
||||
if (get(totpRemaining) <= 1) await refreshTotp(secret);
|
||||
else set(totpRemaining, get(totpRemaining) - 1);
|
||||
set(totalRemaining, get(totpRemaining) <= 5);
|
||||
}, 1e3);
|
||||
}
|
||||
function stopTotpTimer() {
|
||||
if (totpTimer) {
|
||||
clearInterval(totpTimer);
|
||||
totpTimer = null;
|
||||
}
|
||||
}
|
||||
onDestroy(stopTotpTimer);
|
||||
function showToast(message) {
|
||||
set(toast, message, true);
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
@ -6726,113 +6925,143 @@ function EntryDetail($$anchor, $$props) {
|
||||
var consequent_7 = ($$anchor) => {
|
||||
var div_13 = root_10();
|
||||
var div_14 = sibling(child(div_13), 2);
|
||||
var a = child(div_14);
|
||||
var text_7 = child(a, true);
|
||||
reset(a);
|
||||
var button_7 = sibling(a, 2);
|
||||
var span_2 = child(div_14);
|
||||
let classes;
|
||||
var text_7 = child(span_2, true);
|
||||
reset(span_2);
|
||||
var button_7 = sibling(span_2, 2);
|
||||
reset(div_14);
|
||||
var div_15 = sibling(div_14, 2);
|
||||
var span_3 = child(div_15);
|
||||
let classes_1;
|
||||
var span_4 = sibling(span_3, 2);
|
||||
var text_8 = child(span_4);
|
||||
reset(span_4);
|
||||
reset(div_15);
|
||||
reset(div_13);
|
||||
template_effect(() => {
|
||||
set_attribute(a, "href", get(entry).url);
|
||||
set_text(text_7, get(entry).url);
|
||||
});
|
||||
delegated("click", button_7, () => copyToClipboard(get(entry).url, "URL"));
|
||||
template_effect(($0) => {
|
||||
classes = set_class(span_2, 1, "totp-code svelte-dssgjx", null, classes, { "totp-urgent": get(totalRemaining) });
|
||||
set_text(text_7, $0);
|
||||
classes_1 = set_class(span_3, 1, "totp-dot svelte-dssgjx", null, classes_1, { urgent: get(totalRemaining) });
|
||||
set_text(text_8, `${get(totpRemaining) ?? ""}s`);
|
||||
}, [() => get(totpCode) ? String(get(totpCode)).replace(/^(.{3})/, "$1 ") : ""]);
|
||||
delegated("click", button_7, () => copyToClipboard(get(totpCode), "2FA code"));
|
||||
append($$anchor, div_13);
|
||||
};
|
||||
if_block(node_5, ($$render) => {
|
||||
if (get(entry).url) $$render(consequent_7);
|
||||
if (get(entry).encryptedTotpSecret) $$render(consequent_7);
|
||||
});
|
||||
var node_6 = sibling(node_5, 2);
|
||||
var consequent_8 = ($$anchor) => {
|
||||
var div_15 = root_11();
|
||||
var div_16 = sibling(child(div_15), 2);
|
||||
var text_8 = child(div_16, true);
|
||||
var div_16 = root_11();
|
||||
var div_17 = sibling(child(div_16), 2);
|
||||
var a = child(div_17);
|
||||
var text_9 = child(a, true);
|
||||
reset(a);
|
||||
var button_8 = sibling(a, 2);
|
||||
reset(div_17);
|
||||
reset(div_16);
|
||||
reset(div_15);
|
||||
template_effect(() => set_text(text_8, get(entry).notes));
|
||||
append($$anchor, div_15);
|
||||
template_effect(() => {
|
||||
set_attribute(a, "href", get(entry).url);
|
||||
set_text(text_9, get(entry).url);
|
||||
});
|
||||
delegated("click", button_8, () => copyToClipboard(get(entry).url, "URL"));
|
||||
append($$anchor, div_16);
|
||||
};
|
||||
if_block(node_6, ($$render) => {
|
||||
if (get(entry).notes) $$render(consequent_8);
|
||||
if (get(entry).url) $$render(consequent_8);
|
||||
});
|
||||
reset(div_8);
|
||||
var div_17 = sibling(div_8, 2);
|
||||
var span_2 = child(div_17);
|
||||
var text_9 = child(span_2);
|
||||
reset(span_2);
|
||||
var span_3 = sibling(span_2, 2);
|
||||
var text_10 = child(span_3);
|
||||
reset(span_3);
|
||||
reset(div_17);
|
||||
reset(div_5);
|
||||
var node_7 = sibling(div_5, 2);
|
||||
var node_7 = sibling(node_6, 2);
|
||||
var consequent_9 = ($$anchor) => {
|
||||
var div_18 = root_12();
|
||||
var div_19 = child(div_18);
|
||||
var p = sibling(child(div_19), 2);
|
||||
var strong = sibling(child(p));
|
||||
var text_11 = child(strong, true);
|
||||
reset(strong);
|
||||
next();
|
||||
reset(p);
|
||||
var div_20 = sibling(p, 2);
|
||||
var button_8 = child(div_20);
|
||||
var text_12 = child(button_8, true);
|
||||
reset(button_8);
|
||||
var button_9 = sibling(button_8, 2);
|
||||
reset(div_20);
|
||||
var div_19 = sibling(child(div_18), 2);
|
||||
var text_10 = child(div_19, true);
|
||||
reset(div_19);
|
||||
reset(div_18);
|
||||
template_effect(() => {
|
||||
set_text(text_11, get(entry).title);
|
||||
button_8.disabled = get(deleting);
|
||||
set_text(text_12, get(deleting) ? "Moving..." : "Move to Trash");
|
||||
});
|
||||
delegated("click", div_18, () => set(showDeleteConfirm, false));
|
||||
delegated("click", div_19, (e) => e.stopPropagation());
|
||||
delegated("click", button_8, handleMoveToTrash);
|
||||
delegated("click", button_9, () => set(showDeleteConfirm, false));
|
||||
template_effect(() => set_text(text_10, get(entry).notes));
|
||||
append($$anchor, div_18);
|
||||
};
|
||||
if_block(node_7, ($$render) => {
|
||||
if (get(showDeleteConfirm)) $$render(consequent_9);
|
||||
if (get(entry).notes) $$render(consequent_9);
|
||||
});
|
||||
var node_8 = sibling(node_7, 2);
|
||||
reset(div_8);
|
||||
var div_20 = sibling(div_8, 2);
|
||||
var span_5 = child(div_20);
|
||||
var text_11 = child(span_5);
|
||||
reset(span_5);
|
||||
var span_6 = sibling(span_5, 2);
|
||||
var text_12 = child(span_6);
|
||||
reset(span_6);
|
||||
reset(div_20);
|
||||
reset(div_5);
|
||||
var node_8 = sibling(div_5, 2);
|
||||
var consequent_10 = ($$anchor) => {
|
||||
var div_21 = root_13$1();
|
||||
var div_22 = child(div_21);
|
||||
var p_1 = sibling(child(div_22), 2);
|
||||
var strong_1 = sibling(child(p_1));
|
||||
var text_13 = child(strong_1, true);
|
||||
reset(strong_1);
|
||||
var p = sibling(child(div_22), 2);
|
||||
var strong = sibling(child(p));
|
||||
var text_13 = child(strong, true);
|
||||
reset(strong);
|
||||
next();
|
||||
reset(p_1);
|
||||
var div_23 = sibling(p_1, 2);
|
||||
var button_10 = child(div_23);
|
||||
var text_14 = child(button_10, true);
|
||||
reset(button_10);
|
||||
var button_11 = sibling(button_10, 2);
|
||||
reset(p);
|
||||
var div_23 = sibling(p, 2);
|
||||
var button_9 = child(div_23);
|
||||
var text_14 = child(button_9, true);
|
||||
reset(button_9);
|
||||
var button_10 = sibling(button_9, 2);
|
||||
reset(div_23);
|
||||
reset(div_22);
|
||||
reset(div_21);
|
||||
template_effect(() => {
|
||||
set_text(text_13, get(entry).title);
|
||||
button_10.disabled = get(deleting);
|
||||
set_text(text_14, get(deleting) ? "Deleting..." : "Delete Forever");
|
||||
button_9.disabled = get(deleting);
|
||||
set_text(text_14, get(deleting) ? "Moving..." : "Move to Trash");
|
||||
});
|
||||
delegated("click", div_21, () => set(showPermanentDeleteConfirm, false));
|
||||
delegated("click", div_21, () => set(showDeleteConfirm, false));
|
||||
delegated("click", div_22, (e) => e.stopPropagation());
|
||||
delegated("click", button_10, handlePermanentDelete);
|
||||
delegated("click", button_11, () => set(showPermanentDeleteConfirm, false));
|
||||
delegated("click", button_9, handleMoveToTrash);
|
||||
delegated("click", button_10, () => set(showDeleteConfirm, false));
|
||||
append($$anchor, div_21);
|
||||
};
|
||||
if_block(node_8, ($$render) => {
|
||||
if (get(showPermanentDeleteConfirm)) $$render(consequent_10);
|
||||
if (get(showDeleteConfirm)) $$render(consequent_10);
|
||||
});
|
||||
var node_9 = sibling(node_8, 2);
|
||||
var consequent_11 = ($$anchor) => {
|
||||
var div_24 = root_14();
|
||||
var div_25 = child(div_24);
|
||||
var p_1 = sibling(child(div_25), 2);
|
||||
var strong_1 = sibling(child(p_1));
|
||||
var text_15 = child(strong_1, true);
|
||||
reset(strong_1);
|
||||
next();
|
||||
reset(p_1);
|
||||
var div_26 = sibling(p_1, 2);
|
||||
var button_11 = child(div_26);
|
||||
var text_16 = child(button_11, true);
|
||||
reset(button_11);
|
||||
var button_12 = sibling(button_11, 2);
|
||||
reset(div_26);
|
||||
reset(div_25);
|
||||
reset(div_24);
|
||||
template_effect(() => {
|
||||
set_text(text_15, get(entry).title);
|
||||
button_11.disabled = get(deleting);
|
||||
set_text(text_16, get(deleting) ? "Deleting..." : "Delete Forever");
|
||||
});
|
||||
delegated("click", div_24, () => set(showPermanentDeleteConfirm, false));
|
||||
delegated("click", div_25, (e) => e.stopPropagation());
|
||||
delegated("click", button_11, handlePermanentDelete);
|
||||
delegated("click", button_12, () => set(showPermanentDeleteConfirm, false));
|
||||
append($$anchor, div_24);
|
||||
};
|
||||
if_block(node_9, ($$render) => {
|
||||
if (get(showPermanentDeleteConfirm)) $$render(consequent_11);
|
||||
});
|
||||
template_effect(($0, $1) => {
|
||||
set_text(text_3, get(entry).title);
|
||||
set_text(text_9, `Created: ${$0 ?? ""}`);
|
||||
set_text(text_10, `Updated: ${$1 ?? ""}`);
|
||||
set_text(text_11, `Created: ${$0 ?? ""}`);
|
||||
set_text(text_12, `Updated: ${$1 ?? ""}`);
|
||||
}, [() => new Date(get(entry).createdAt).toLocaleString(), () => new Date(get(entry).updatedAt).toLocaleString()]);
|
||||
append($$anchor, fragment);
|
||||
};
|
||||
@ -6854,7 +7083,7 @@ var root_3$2 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-pafazm
|
||||
var root_5$2 = /* @__PURE__ */ from_html(`<div class="validation-error svelte-pafazm"> </div>`);
|
||||
var root_4$2 = /* @__PURE__ */ from_html(`<div class="validation-errors svelte-pafazm"></div>`);
|
||||
var root_7$1 = /* @__PURE__ */ from_html(`<option> </option>`);
|
||||
var root_2$2 = /* @__PURE__ */ from_html(`<!> <form class="form-card svelte-pafazm"><!> <div class="form-group"><label for="title">Title *</label> <input id="title" type="text" placeholder="e.g. GitHub, Gmail"/></div> <div class="form-group"><label for="username">Username / Email</label> <input id="username" type="text" placeholder="username or email"/></div> <div class="form-group"><label for="password">Password</label> <div class="password-input-group svelte-pafazm"><input id="password" placeholder="Password" class="svelte-pafazm"/> <button type="button" class="btn btn-ghost btn-sm" title="Toggle visibility"> </button> <button type="button" class="btn btn-ghost btn-sm" title="Generate password">🎲</button></div></div> <div class="form-group"><label for="url">URL</label> <input id="url" type="url" placeholder="https://example.com"/></div> <div class="form-group"><label for="group">Group</label> <select id="group"><option>No group</option><!></select></div> <div class="form-group"><label for="notes">Notes</label> <textarea id="notes" placeholder="Any additional notes..."></textarea></div> <div class="form-actions svelte-pafazm"><button type="submit" class="btn btn-primary"> </button> <button type="button" class="btn btn-ghost">Cancel</button></div></form>`, 1);
|
||||
var root_2$2 = /* @__PURE__ */ from_html(`<!> <form class="form-card svelte-pafazm"><!> <div class="form-group"><label for="title">Title *</label> <input id="title" type="text" placeholder="e.g. GitHub, Gmail"/></div> <div class="form-group"><label for="username">Username / Email</label> <input id="username" type="text" placeholder="username or email"/></div> <div class="form-group"><label for="password">Password</label> <div class="password-input-group svelte-pafazm"><input id="password" placeholder="Password" class="svelte-pafazm"/> <button type="button" class="btn btn-ghost btn-sm" title="Toggle visibility"> </button> <button type="button" class="btn btn-ghost btn-sm" title="Generate password">🎲</button></div></div> <div class="form-group"><label for="totp">TOTP Secret (2FA) — optional</label> <input id="totp" type="text" placeholder="Base32 secret or otpauth:// URI (e.g. JBSWY3DPEHPK3PXP)" autocomplete="off" spellcheck="false"/></div> <div class="form-group"><label for="url">URL</label> <input id="url" type="url" placeholder="https://example.com"/></div> <div class="form-group"><label for="group">Group</label> <select id="group"><option>No group</option><!></select></div> <div class="form-group"><label for="notes">Notes</label> <textarea id="notes" placeholder="Any additional notes..."></textarea></div> <div class="form-actions svelte-pafazm"><button type="submit" class="btn btn-primary"> </button> <button type="button" class="btn btn-ghost">Cancel</button></div></form>`, 1);
|
||||
var root$3 = /* @__PURE__ */ from_html(`<div class="entry-form"><!></div>`);
|
||||
function EntryForm($$anchor, $$props) {
|
||||
push($$props, true);
|
||||
@ -6864,6 +7093,7 @@ function EntryForm($$anchor, $$props) {
|
||||
let url = /* @__PURE__ */ state("");
|
||||
let notes = /* @__PURE__ */ state("");
|
||||
let groupId = /* @__PURE__ */ state("");
|
||||
let totpSecret = /* @__PURE__ */ state("");
|
||||
let passwordVisible = /* @__PURE__ */ state(false);
|
||||
let groups = /* @__PURE__ */ state(proxy([]));
|
||||
let loading = /* @__PURE__ */ state(true);
|
||||
@ -6882,6 +7112,7 @@ function EntryForm($$anchor, $$props) {
|
||||
set(title, entry.title, true);
|
||||
set(username, entry.username, true);
|
||||
set(password, entry.encryptedPassword ? await decrypt(entry.encryptedPassword, app$1.encryptionKey) : "", true);
|
||||
set(totpSecret, entry.encryptedTotpSecret ? await decrypt(entry.encryptedTotpSecret, app$1.encryptionKey) : "", true);
|
||||
set(url, entry.url || "", true);
|
||||
set(notes, entry.notes || "", true);
|
||||
set(groupId, entry.groupId || "", true);
|
||||
@ -6912,10 +7143,13 @@ function EntryForm($$anchor, $$props) {
|
||||
return;
|
||||
}
|
||||
const encryptedPassword = get(password) ? await encrypt(get(password), app$1.encryptionKey) : "";
|
||||
const cleanTotp = get(totpSecret).trim();
|
||||
const encryptedTotpSecret = cleanTotp ? await encrypt(cleanTotp, app$1.encryptionKey) : "";
|
||||
if (get(isEdit)) await updateEntry(updateEntry$1(await getEntryById($$props.entryId), {
|
||||
title: get(title),
|
||||
username: get(username),
|
||||
encryptedPassword,
|
||||
encryptedTotpSecret,
|
||||
url: get(url),
|
||||
notes: get(notes),
|
||||
groupId: get(groupId)
|
||||
@ -6924,6 +7158,7 @@ function EntryForm($$anchor, $$props) {
|
||||
title: get(title),
|
||||
username: get(username),
|
||||
encryptedPassword,
|
||||
encryptedTotpSecret,
|
||||
url: get(url),
|
||||
notes: get(notes),
|
||||
groupId: get(groupId)
|
||||
@ -6994,7 +7229,11 @@ function EntryForm($$anchor, $$props) {
|
||||
remove_input_defaults(input_3);
|
||||
reset(div_9);
|
||||
var div_10 = sibling(div_9, 2);
|
||||
var select = sibling(child(div_10), 2);
|
||||
var input_4 = sibling(child(div_10), 2);
|
||||
remove_input_defaults(input_4);
|
||||
reset(div_10);
|
||||
var div_11 = sibling(div_10, 2);
|
||||
var select = sibling(child(div_11), 2);
|
||||
var option = child(select);
|
||||
option.value = option.__value = "";
|
||||
each(sibling(option), 17, () => get(groups), index, ($$anchor, group) => {
|
||||
@ -7018,17 +7257,17 @@ function EntryForm($$anchor, $$props) {
|
||||
append($$anchor, fragment_1);
|
||||
});
|
||||
reset(select);
|
||||
reset(div_10);
|
||||
var div_11 = sibling(div_10, 2);
|
||||
var textarea = sibling(child(div_11), 2);
|
||||
remove_textarea_child(textarea);
|
||||
reset(div_11);
|
||||
var div_12 = sibling(div_11, 2);
|
||||
var button_2 = child(div_12);
|
||||
var textarea = sibling(child(div_12), 2);
|
||||
remove_textarea_child(textarea);
|
||||
reset(div_12);
|
||||
var div_13 = sibling(div_12, 2);
|
||||
var button_2 = child(div_13);
|
||||
var text_4 = child(button_2, true);
|
||||
reset(button_2);
|
||||
var button_3 = sibling(button_2, 2);
|
||||
reset(div_12);
|
||||
reset(div_13);
|
||||
reset(form);
|
||||
template_effect(() => {
|
||||
set_attribute(input_2, "type", get(passwordVisible) ? "text" : "password");
|
||||
@ -7044,7 +7283,8 @@ function EntryForm($$anchor, $$props) {
|
||||
bind_value(input_2, () => get(password), ($$value) => set(password, $$value));
|
||||
delegated("click", button, () => set(passwordVisible, !get(passwordVisible)));
|
||||
delegated("click", button_1, () => set(password, generatePassword({ length: 16 }), true));
|
||||
bind_value(input_3, () => get(url), ($$value) => set(url, $$value));
|
||||
bind_value(input_3, () => get(totpSecret), ($$value) => set(totpSecret, $$value));
|
||||
bind_value(input_4, () => get(url), ($$value) => set(url, $$value));
|
||||
bind_select_value(select, () => get(groupId), ($$value) => set(groupId, $$value));
|
||||
bind_value(textarea, () => get(notes), ($$value) => set(notes, $$value));
|
||||
delegated("click", button_3, function(...$$args) {
|
||||
@ -8470,6 +8710,34 @@ label {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.totp-value.svelte-dssgjx .totp-code:where(.svelte-dssgjx) {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.totp-value.svelte-dssgjx .totp-code.totp-urgent:where(.svelte-dssgjx) {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.totp-remaining.svelte-dssgjx {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.totp-dot.svelte-dssgjx {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-success);
|
||||
display: inline-block;
|
||||
}
|
||||
.totp-dot.urgent.svelte-dssgjx {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.detail-meta.svelte-dssgjx {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
<script>
|
||||
import { onDestroy } from 'svelte'
|
||||
import { getEntryById, moveToTrash, deleteEntry } from '../lib/storage/db.js'
|
||||
import { decrypt } from '../lib/crypto/crypto.js'
|
||||
import { generateTotp, totpRemainingSeconds } from '../lib/crypto/totp.js'
|
||||
import { app } from '../lib/stores/app.svelte.js'
|
||||
import { isTrashGroup } from '../lib/models/schema.js'
|
||||
|
||||
@ -9,6 +11,9 @@
|
||||
let entry = $state(null)
|
||||
let passwordVisible = $state(false)
|
||||
let decryptedPassword = $state('')
|
||||
let totpCode = $state('')
|
||||
let totpRemaining = $state(30)
|
||||
let totalRemaining = $state(false)
|
||||
let loading = $state(true)
|
||||
let error = $state('')
|
||||
let showDeleteConfirm = $state(false)
|
||||
@ -25,6 +30,11 @@
|
||||
entry = await getEntryById(entryId)
|
||||
if (entry && app.encryptionKey) {
|
||||
decryptedPassword = entry.encryptedPassword ? await decrypt(entry.encryptedPassword, app.encryptionKey) : ''
|
||||
if (entry.encryptedTotpSecret) {
|
||||
const secret = await decrypt(entry.encryptedTotpSecret, app.encryptionKey)
|
||||
await refreshTotp(secret)
|
||||
startTotpTimer(secret)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Failed to load entry: ' + e.message
|
||||
@ -34,6 +44,34 @@
|
||||
|
||||
loadEntry()
|
||||
|
||||
let totpTimer = null
|
||||
|
||||
async function refreshTotp(secret) {
|
||||
try {
|
||||
totpCode = await generateTotp(secret)
|
||||
totpRemaining = totpRemainingSeconds()
|
||||
totalRemaining = totpRemaining <= 5
|
||||
} catch (e) {
|
||||
totpCode = ''
|
||||
totpRemaining = 0
|
||||
}
|
||||
}
|
||||
|
||||
function startTotpTimer(secret) {
|
||||
stopTotpTimer()
|
||||
totpTimer = setInterval(async () => {
|
||||
if (totpRemaining <= 1) await refreshTotp(secret)
|
||||
else totpRemaining -= 1
|
||||
totalRemaining = totpRemaining <= 5
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopTotpTimer() {
|
||||
if (totpTimer) { clearInterval(totpTimer); totpTimer = null }
|
||||
}
|
||||
|
||||
onDestroy(stopTotpTimer)
|
||||
|
||||
function showToast(message) {
|
||||
toast = message
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
@ -143,6 +181,22 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if entry.encryptedTotpSecret}
|
||||
<div class="detail-field">
|
||||
<span class="field-label">2FA Code (TOTP)</span>
|
||||
<div class="field-value totp-value">
|
||||
<span class:totp-urgent={totalRemaining} class="totp-code">
|
||||
{totpCode ? String(totpCode).replace(/^(.{3})/, '$1 ') : ''}
|
||||
</span>
|
||||
<button class="btn btn-ghost btn-sm copy-btn" onclick={() => copyToClipboard(totpCode, '2FA code')} title="Copy 2FA code">📋</button>
|
||||
</div>
|
||||
<div class="totp-remaining" aria-hidden="true">
|
||||
<span class="totp-dot" class:urgent={totalRemaining}></span>
|
||||
<span class="text-xs text-muted">{totpRemaining}s</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if entry.url}
|
||||
<div class="detail-field">
|
||||
<span class="field-label">URL</span>
|
||||
@ -311,6 +365,34 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.totp-value .totp-code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.totp-value .totp-code.totp-urgent {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.totp-remaining {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.totp-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-success);
|
||||
display: inline-block;
|
||||
}
|
||||
.totp-dot.urgent {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
let url = $state('')
|
||||
let notes = $state('')
|
||||
let groupId = $state('')
|
||||
let totpSecret = $state('')
|
||||
let passwordVisible = $state(false)
|
||||
let groups = $state([])
|
||||
let loading = $state(true)
|
||||
@ -34,6 +35,7 @@
|
||||
title = entry.title
|
||||
username = entry.username
|
||||
password = entry.encryptedPassword ? await decrypt(entry.encryptedPassword, app.encryptionKey) : ''
|
||||
totpSecret = entry.encryptedTotpSecret ? await decrypt(entry.encryptedTotpSecret, app.encryptionKey) : ''
|
||||
url = entry.url || ''
|
||||
notes = entry.notes || ''
|
||||
groupId = entry.groupId || ''
|
||||
@ -67,6 +69,10 @@
|
||||
}
|
||||
|
||||
const encryptedPassword = password ? await encrypt(password, app.encryptionKey) : ''
|
||||
// TOTP secret is optional; accept raw base32 or an otpauth:// URI and
|
||||
// store it encrypted only when the user actually provided one.
|
||||
const cleanTotp = totpSecret.trim()
|
||||
const encryptedTotpSecret = cleanTotp ? await encrypt(cleanTotp, app.encryptionKey) : ''
|
||||
|
||||
if (isEdit) {
|
||||
const existing = await getEntryById(entryId)
|
||||
@ -74,6 +80,7 @@
|
||||
title,
|
||||
username,
|
||||
encryptedPassword,
|
||||
encryptedTotpSecret,
|
||||
url,
|
||||
notes,
|
||||
groupId,
|
||||
@ -84,6 +91,7 @@
|
||||
title,
|
||||
username,
|
||||
encryptedPassword,
|
||||
encryptedTotpSecret,
|
||||
url,
|
||||
notes,
|
||||
groupId,
|
||||
@ -145,6 +153,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="totp">TOTP Secret (2FA) — optional</label>
|
||||
<input
|
||||
id="totp"
|
||||
type="text"
|
||||
bind:value={totpSecret}
|
||||
placeholder="Base32 secret or otpauth:// URI (e.g. JBSWY3DPEHPK3PXP)"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="url">URL</label>
|
||||
<input id="url" type="url" bind:value={url} placeholder="https://example.com" />
|
||||
|
||||
114
src/lib/crypto/totp.js
Normal file
114
src/lib/crypto/totp.js
Normal file
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* TOTP (Time-based One-Time Password) — RFC 6238 / RFC 4226.
|
||||
*
|
||||
* Implemented with the browser's native Web Crypto API (HMAC-SHA1), so no
|
||||
* external crypto dependency. A base32 secret yields 6-digit codes that change
|
||||
* every 30 seconds, matching common 2FA authenticator apps.
|
||||
*/
|
||||
|
||||
const DEFAULT_PERIOD = 30
|
||||
const DEFAULT_DIGITS = 6
|
||||
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
||||
|
||||
/**
|
||||
* Decode a base32 string (RFC 4648) into bytes. Accepts whitespace (from
|
||||
* authenticator-exported secrets). Throws on invalid characters.
|
||||
*
|
||||
* @param {string} base32
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export function base32Decode(base32) {
|
||||
const clean = String(base32).toUpperCase().replace(/[\s=]/g, '')
|
||||
if (!clean) return new Uint8Array(0)
|
||||
|
||||
let bits = 0
|
||||
let value = 0
|
||||
const bytes = []
|
||||
for (const ch of clean) {
|
||||
const idx = BASE32_ALPHABET.indexOf(ch)
|
||||
if (idx === -1) throw new Error(`Invalid base32 character: "${ch}"`)
|
||||
value = (value << 5) | idx
|
||||
bits += 5
|
||||
if (bits >= 8) {
|
||||
bytes.push((value >>> (bits - 8)) & 0xff)
|
||||
bits -= 8
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a raw base32 secret from the user input, accepting either a bare
|
||||
* base32 string or an otpauth:// URI (which embeds `secret=`).
|
||||
*
|
||||
* @param {string} input
|
||||
* @returns {string} Base32 secret (uppercased, no whitespace)
|
||||
*/
|
||||
export function extractSecret(input) {
|
||||
const text = String(input || '').trim()
|
||||
if (!text) return ''
|
||||
if (/^otpauth:\/\//i.test(text)) {
|
||||
try {
|
||||
const m = text.match(/[?&]secret=([^&]+)/i)
|
||||
if (m) return m[1].toUpperCase().replace(/[^A-Z2-7]/g, '')
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
// Bare base32 (optionally hyphen-grouped as authenticators display it).
|
||||
return text.toUpperCase().replace(/[\s-]/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the 8-byte big-endian counter for a Unix timestamp.
|
||||
* @param {number} counter
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
function counterBytes(counter) {
|
||||
const buf = new Uint8Array(8)
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
buf[i] = counter & 0xff
|
||||
counter = Math.floor(counter / 256)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a TOTP code for a secret at a given Unix timestamp.
|
||||
*
|
||||
* @param {string} secret - Base32 secret (SCHEME uri also accepted via extractSecret)
|
||||
* @param {Object} [opts]
|
||||
* @param {number} [opts.timestamp=Date.now()/1000] - Unix seconds
|
||||
* @param {number} [opts.period=30]
|
||||
* @param {number} [opts.digits=6]
|
||||
* @returns {Promise<string>} Zero-padded code.
|
||||
*/
|
||||
export async function generateTotp(secret, { timestamp = Math.floor(Date.now() / 1000), period = DEFAULT_PERIOD, digits = DEFAULT_DIGITS } = {}) {
|
||||
const keyBytes = base32Decode(extractSecret(secret))
|
||||
if (keyBytes.length === 0) {
|
||||
throw new Error('TOTP secret is empty or invalid')
|
||||
}
|
||||
|
||||
const counter = Math.floor(timestamp / period)
|
||||
|
||||
const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign'])
|
||||
const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBytes(counter)))
|
||||
|
||||
// Dynamic truncation (RFC 4226 section 5.3)
|
||||
const offset = sig[sig.length - 1] & 0x0f
|
||||
const bin = ((sig[offset] & 0x7f) << 24) | (sig[offset + 1] << 16) | (sig[offset + 2] << 8) | sig[offset + 3]
|
||||
const code = bin % Math.pow(10, digits)
|
||||
|
||||
return code.toString().padStart(digits, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds remaining before the current TOTP code expires.
|
||||
* @param {Object} [opts]
|
||||
* @param {number} [opts.period=30]
|
||||
* @param {number} [opts.timestamp=Date.now()/1000]
|
||||
* @returns {number} 1..period
|
||||
*/
|
||||
export function totpRemainingSeconds({ period = DEFAULT_PERIOD, timestamp = Math.floor(Date.now() / 1000) } = {}) {
|
||||
return period - (timestamp % period)
|
||||
}
|
||||
@ -31,6 +31,7 @@ export function generateId() {
|
||||
* @property {string} title - Display name (e.g. "GitHub", "Gmail")
|
||||
* @property {string} [username] - Login username or email (optional)
|
||||
* @property {string} [encryptedPassword] - AES-GCM encrypted password blob (JSON string); optional
|
||||
* @property {string} [encryptedTotpSecret] - AES-GCM encrypted TOTP base32 secret (JSON string); optional
|
||||
* @property {string} [url] - Website URL
|
||||
* @property {string} [notes] - Free-form notes
|
||||
* @property {string} [groupId] - Reference to a Group id (empty string = no group)
|
||||
@ -46,6 +47,7 @@ export function generateId() {
|
||||
* @param {string} data.title
|
||||
* @param {string} [data.username]
|
||||
* @param {string} [data.encryptedPassword] - Must already be encrypted (optional; empty string = no password)
|
||||
* @param {string} [data.encryptedTotpSecret] - Must already be encrypted (optional; empty = no TOTP)
|
||||
* @param {string} [data.url]
|
||||
* @param {string} [data.notes]
|
||||
* @param {string} [data.groupId]
|
||||
@ -59,6 +61,7 @@ export function createEntry(data) {
|
||||
title: data.title.trim(),
|
||||
username: data.username?.trim() || '',
|
||||
encryptedPassword: data.encryptedPassword,
|
||||
encryptedTotpSecret: data.encryptedTotpSecret,
|
||||
url: data.url?.trim() || '',
|
||||
notes: data.notes?.trim() || '',
|
||||
groupId: data.groupId || '',
|
||||
@ -81,6 +84,7 @@ export function updateEntry(existing, data) {
|
||||
title: data.title !== undefined ? data.title.trim() : existing.title,
|
||||
username: data.username !== undefined ? (data.username?.trim() || '') : existing.username,
|
||||
encryptedPassword: data.encryptedPassword !== undefined ? data.encryptedPassword : existing.encryptedPassword,
|
||||
encryptedTotpSecret: data.encryptedTotpSecret !== undefined ? data.encryptedTotpSecret : existing.encryptedTotpSecret,
|
||||
url: data.url !== undefined ? data.url.trim() : existing.url,
|
||||
notes: data.notes !== undefined ? data.notes.trim() : existing.notes,
|
||||
groupId: data.groupId !== undefined ? data.groupId : existing.groupId,
|
||||
|
||||
@ -475,9 +475,14 @@ export async function exportSelected(groupIds = null, options = {}) {
|
||||
envelopeSalt = uint8ArrayToBase64(exportSalt)
|
||||
|
||||
for (const entry of payload.entries) {
|
||||
if (!entry.encryptedPassword) continue
|
||||
const plaintext = await decrypt(entry.encryptedPassword, vaultKey)
|
||||
entry.encryptedPassword = await encrypt(plaintext, sealKey)
|
||||
if (entry.encryptedPassword) {
|
||||
const plaintext = await decrypt(entry.encryptedPassword, vaultKey)
|
||||
entry.encryptedPassword = await encrypt(plaintext, sealKey)
|
||||
}
|
||||
if (entry.encryptedTotpSecret) {
|
||||
const secret = await decrypt(entry.encryptedTotpSecret, vaultKey)
|
||||
entry.encryptedTotpSecret = await encrypt(secret, sealKey)
|
||||
}
|
||||
}
|
||||
// Point meta.salt at the export salt so import reproduces the export key.
|
||||
payload.meta.salt = envelopeSalt
|
||||
@ -568,16 +573,26 @@ export async function importAll(data, mode = 'merge', sourcePassword = '', targe
|
||||
try {
|
||||
let reencryptedEntry = { ...entry }
|
||||
|
||||
if (sourceKey && targetKey && entry.encryptedPassword) {
|
||||
// Decrypt password with source key
|
||||
const plaintext = await decrypt(entry.encryptedPassword, sourceKey)
|
||||
// Re-encrypt under target vault's key
|
||||
reencryptedEntry.encryptedPassword = await encrypt(plaintext, targetKey)
|
||||
// An entry is only skippable if it actually needs re-keying (has an
|
||||
// encrypted password or TOTP secret) but we lack the keys to do so.
|
||||
const hasEncrypted = !!(entry.encryptedPassword || entry.encryptedTotpSecret)
|
||||
|
||||
if (sourceKey && targetKey && hasEncrypted) {
|
||||
if (entry.encryptedPassword) {
|
||||
const plaintext = await decrypt(entry.encryptedPassword, sourceKey)
|
||||
reencryptedEntry.encryptedPassword = await encrypt(plaintext, targetKey)
|
||||
}
|
||||
if (entry.encryptedTotpSecret) {
|
||||
const secret = await decrypt(entry.encryptedTotpSecret, sourceKey)
|
||||
reencryptedEntry.encryptedTotpSecret = await encrypt(secret, targetKey)
|
||||
}
|
||||
} else if (!sourceKey || !targetKey) {
|
||||
// Can't re-encrypt — skip this entry with a warning
|
||||
console.warn('Skipping entry (missing source password or target key):', entry.title)
|
||||
skipped++
|
||||
continue
|
||||
// Can't re-encrypt — require a password/secret, else nothing to do.
|
||||
if (hasEncrypted) {
|
||||
console.warn('Skipping entry (missing source password or target):', entry.title)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
await db.put('entries', reencryptedEntry)
|
||||
|
||||
82
tests/lib/crypto/totp.test.js
Normal file
82
tests/lib/crypto/totp.test.js
Normal file
@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateTotp, totpRemainingSeconds, base32Decode, extractSecret } from '../../../src/lib/crypto/totp.js'
|
||||
|
||||
// RFC 6238 test vectors (Appendix B, SHA-1) use the ASCII secret
|
||||
// "12345678901234567890" whose base32 is GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ.
|
||||
const RFC_SECRET = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'
|
||||
|
||||
describe('base32Decode', () => {
|
||||
it('decodes the RFC secret to the expected ASCII bytes', () => {
|
||||
const bytes = base32Decode(RFC_SECRET)
|
||||
expect(String.fromCharCode(...bytes)).toBe('12345678901234567890')
|
||||
})
|
||||
|
||||
it('ignores whitespace and padding', () => {
|
||||
expect(base32Decode('JBSWY3DP EHPK3PXP').length).toBe(10)
|
||||
expect(base32Decode('JBSWY3DPEHPK3PXP==').length).toBe(10)
|
||||
})
|
||||
|
||||
it('throws on invalid characters', () => {
|
||||
// '0','1','8','9' are not valid base32
|
||||
expect(() => base32Decode('ABC0')).toThrow(/Invalid base32/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractSecret', () => {
|
||||
it('passes through a bare base32 secret (normalized)', () => {
|
||||
expect(extractSecret('jbs-wy3dpehpk3pxp')).toBe('JBSWY3DPEHPK3PXP')
|
||||
})
|
||||
|
||||
it('pulls the secret out of an otpauth:// URI', () => {
|
||||
expect(extractSecret('otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example'))
|
||||
.toBe('JBSWY3DPEHPK3PXP')
|
||||
})
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(extractSecret('')).toBe('')
|
||||
expect(extractSecret(' ')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateTotp (RFC 6238 vectors)', () => {
|
||||
it('matches the RFC 6238 SHA-1 vectors at 6 digits', async () => {
|
||||
const vectors = [
|
||||
[59, '287082'],
|
||||
[1111111109, '081804'],
|
||||
[1111111111, '050471'],
|
||||
[1234567890, '005924'],
|
||||
[2000000000, '279037'],
|
||||
[20000000000, '353130'],
|
||||
]
|
||||
for (const [t, expected] of vectors) {
|
||||
await expect(generateTotp(RFC_SECRET, { timestamp: t })).resolves.toBe(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('supports custom digit counts', async () => {
|
||||
// 8-digit vector at t=59 is 94287082
|
||||
await expect(generateTotp(RFC_SECRET, { timestamp: 59, digits: 8 })).resolves.toBe('94287082')
|
||||
})
|
||||
|
||||
it('rejects an empty/invalid secret', async () => {
|
||||
await expect(generateTotp('')).rejects.toThrow(/empty or invalid/)
|
||||
await expect(generateTotp('!!!!')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('changes over time', async () => {
|
||||
const a = await generateTotp(RFC_SECRET, { timestamp: 30 })
|
||||
const b = await generateTotp(RFC_SECRET, { timestamp: 90 })
|
||||
// 30 and 90 map to counters 1 and 3 — codes differ.
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
})
|
||||
|
||||
describe('totpRemainingSeconds', () => {
|
||||
it('returns period for exact boundary', () => {
|
||||
expect(totpRemainingSeconds({ timestamp: 0, period: 30 })).toBe(30)
|
||||
})
|
||||
it('counts down within a period', () => {
|
||||
expect(totpRemainingSeconds({ timestamp: 5, period: 30 })).toBe(25)
|
||||
expect(totpRemainingSeconds({ timestamp: 29, period: 30 })).toBe(1)
|
||||
})
|
||||
})
|
||||
@ -50,6 +50,20 @@ describe('createEntry', () => {
|
||||
expect(entry.updatedAt).toBe(entry.createdAt)
|
||||
})
|
||||
|
||||
it('should store an encrypted TOTP secret', () => {
|
||||
const entry = createEntry({
|
||||
title: 'GitHub',
|
||||
encryptedPassword: 'encrypted-blob',
|
||||
encryptedTotpSecret: 'encrypted-totp',
|
||||
})
|
||||
expect(entry.encryptedTotpSecret).toBe('encrypted-totp')
|
||||
})
|
||||
|
||||
it('should default encryptedTotpSecret to undefined when not provided', () => {
|
||||
const entry = createEntry({ title: 'GitHub' })
|
||||
expect(entry.encryptedTotpSecret).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should trim title and optional fields', () => {
|
||||
const entry = createEntry({
|
||||
title: ' GitHub ',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user