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:
hermes-explorigin 2026-08-27 01:11:48 +00:00
parent b8e7ce75f7
commit 9758e80a02
8 changed files with 703 additions and 104 deletions

452
dist/index.html vendored
View File

@ -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;

View File

@ -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;

View File

@ -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
View 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)
}

View File

@ -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,

View File

@ -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)

View 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)
})
})

View File

@ -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 ',