`);
var root$6 = /* @__PURE__ */ from_html(``);
function Sidebar($$anchor, $$props) {
push($$props, true);
@@ -6355,7 +6355,7 @@ function Sidebar($$anchor, $$props) {
});
var node_4 = sibling(node_2, 2);
var consequent_3 = ($$anchor) => {
- var div_13 = root_6$4();
+ var div_13 = root_6$5();
var div_14 = child(div_13);
var p = sibling(child(div_14), 2);
var strong = sibling(child(p));
@@ -6401,13 +6401,13 @@ var root_1$6 = /* @__PURE__ */ from_html(`
Lo
var root_2$4 = /* @__PURE__ */ from_html(`
`);
var root_4$4 = /* @__PURE__ */ from_html(`
`);
var root_3$4 = /* @__PURE__ */ from_html(`
`);
-var root_6$3 = /* @__PURE__ */ from_html(`matching "
"`, 1);
-var root_7$3 = /* @__PURE__ */ from_html(`
| `);
+var root_6$4 = /* @__PURE__ */ from_html(`matching "
"`, 1);
+var root_7$2 = /* @__PURE__ */ from_html(`
| `);
var root_9$2 = /* @__PURE__ */ from_html(`
â ŋ`);
var root_10$1 = /* @__PURE__ */ from_html(`
đ
`);
var root_11$1 = /* @__PURE__ */ from_html(`
â`);
var root_12$1 = /* @__PURE__ */ from_html(`
| `);
-var root_8$3 = /* @__PURE__ */ from_html(`
| | | | |
`);
+var root_8$4 = /* @__PURE__ */ from_html(`
| | | | |
`);
var root_5$4 = /* @__PURE__ */ from_html(`
`, 1);
var root$5 = /* @__PURE__ */ from_html(`
`);
function EntryList($$anchor, $$props) {
@@ -6497,7 +6497,7 @@ function EntryList($$anchor, $$props) {
var text_4 = child(span);
var node_2 = sibling(text_4);
var consequent_4 = ($$anchor) => {
- var fragment_1 = root_6$3();
+ var fragment_1 = root_6$4();
var strong = sibling(first_child(fragment_1));
var text_5 = child(strong, true);
reset(strong);
@@ -6515,7 +6515,7 @@ function EntryList($$anchor, $$props) {
var tr = child(thead);
var node_3 = sibling(child(tr), 4);
var consequent_5 = ($$anchor) => {
- append($$anchor, root_7$3());
+ append($$anchor, root_7$2());
};
if_block(node_3, ($$render) => {
if (get(isTrashView)) $$render(consequent_5);
@@ -6524,7 +6524,7 @@ function EntryList($$anchor, $$props) {
reset(thead);
var tbody = sibling(thead);
each(tbody, 21, () => get(entries), (entry) => entry.id, ($$anchor, entry) => {
- var tr_1 = root_8$3();
+ var tr_1 = root_8$4();
var td = child(tr_1);
var node_4 = child(td);
var consequent_6 = ($$anchor) => {
@@ -6675,6 +6675,60 @@ function extractSecret(input) {
return text.toUpperCase().replace(/[\s-]/g, "");
}
/**
+* Validate a TOTP secret entered by the user (bare base32 or otpauth:// URI).
+*
+* The secret is optional â an empty string validates as OK. When provided, it
+* must decode to a valid base32 key of at least `minBytes` bytes so garbage,
+* typos, or wrong-format input is rejected before it is stored.
+*
+* @param {string} input
+* @param {Object} [opts]
+* @param {number} [opts.minBytes=10] - Minimum decoded secret length (RFC 6238
+* recommends >= 80 bits / 10 bytes; most real 2FA secrets exceed this).
+* @returns {{ valid: boolean, error: string }}
+*/
+function validateTotpSecret(input, { minBytes = 10 } = {}) {
+ const text = String(input || "").trim();
+ if (!text) return {
+ valid: true,
+ error: ""
+ };
+ let secret;
+ try {
+ secret = extractSecret(text);
+ } catch {
+ return {
+ valid: false,
+ error: "Could not read the TOTP secret."
+ };
+ }
+ if (!secret) return {
+ valid: false,
+ error: "No TOTP secret found. Paste a base32 code or an otpauth:// link."
+ };
+ let bytes;
+ try {
+ bytes = base32Decode(secret);
+ } catch (e) {
+ return {
+ valid: false,
+ error: `Invalid TOTP secret: ${e.message}`
+ };
+ }
+ if (bytes.length === 0) return {
+ valid: false,
+ error: "The TOTP secret is empty."
+ };
+ if (bytes.length < minBytes) return {
+ valid: false,
+ error: `TOTP secret is too short (needs at least ${minBytes} characters decoded).`
+ };
+ return {
+ valid: true,
+ error: ""
+ };
+}
+/**
* Generate the 8-byte big-endian counter for a Unix timestamp.
* @param {number} counter
* @returns {Uint8Array}
@@ -6725,9 +6779,9 @@ var root_1$5 = /* @__PURE__ */ from_html(`
Loading...
`);
var root_3$3 = /* @__PURE__ */ from_html(`
`);
var root_4$3 = /* @__PURE__ */ from_html(`
Entry not found
`);
-var root_6$2 = /* @__PURE__ */ from_html(`
`, 1);
-var root_7$2 = /* @__PURE__ */ from_html(`
`, 1);
-var root_8$2 = /* @__PURE__ */ from_html(`
`);
+var root_6$3 = /* @__PURE__ */ from_html(`
`, 1);
+var root_7$1 = /* @__PURE__ */ from_html(`
`, 1);
+var root_8$3 = /* @__PURE__ */ from_html(`
`);
var root_9$1 = /* @__PURE__ */ from_html(`
`);
var root_10 = /* @__PURE__ */ from_html(`
`);
var root_11 = /* @__PURE__ */ from_html(`
`);
@@ -6883,7 +6937,7 @@ function EntryDetail($$anchor, $$props) {
var div_7 = sibling(h2, 2);
var node_2 = child(div_7);
var consequent_4 = ($$anchor) => {
- var fragment_1 = root_6$2();
+ var fragment_1 = root_6$3();
var button = first_child(fragment_1);
var button_1 = sibling(button, 2);
delegated("click", button, () => $$props.onEdit(get(entry).id));
@@ -6891,7 +6945,7 @@ function EntryDetail($$anchor, $$props) {
append($$anchor, fragment_1);
};
var alternate = ($$anchor) => {
- var fragment_2 = root_7$2();
+ var fragment_2 = root_7$1();
var button_2 = first_child(fragment_2);
var button_3 = sibling(button_2, 2);
delegated("click", button_2, () => $$props.onEdit(get(entry).id));
@@ -6907,7 +6961,7 @@ function EntryDetail($$anchor, $$props) {
var div_8 = sibling(div_6, 2);
var node_3 = child(div_8);
var consequent_5 = ($$anchor) => {
- var div_9 = root_8$2();
+ var div_9 = root_8$3();
var div_10 = sibling(child(div_9), 2);
var span = child(div_10);
var text_4 = child(span, true);
@@ -7107,8 +7161,9 @@ var root_1$4 = /* @__PURE__ */ from_html(`
Loa
var root_3$2 = /* @__PURE__ */ from_html(`
`);
var root_5$2 = /* @__PURE__ */ from_html(`
`);
var root_4$2 = /* @__PURE__ */ from_html(`
`);
-var root_7$1 = /* @__PURE__ */ from_html(`
`);
-var root_2$2 = /* @__PURE__ */ from_html(`
`, 1);
+var root_6$2 = /* @__PURE__ */ from_html(`
`);
+var root_8$2 = /* @__PURE__ */ from_html(`
`);
+var root_2$2 = /* @__PURE__ */ from_html(`
`, 1);
var root$3 = /* @__PURE__ */ from_html(`
`);
function EntryForm($$anchor, $$props) {
push($$props, true);
@@ -7126,6 +7181,7 @@ function EntryForm($$anchor, $$props) {
let saving = /* @__PURE__ */ state(false);
let isEdit = /* @__PURE__ */ state(false);
let formErrors = /* @__PURE__ */ state(proxy([]));
+ let totpError = /* @__PURE__ */ state("");
async function loadForm() {
set(loading, true);
try {
@@ -7167,8 +7223,16 @@ function EntryForm($$anchor, $$props) {
set(saving, false);
return;
}
- const encryptedPassword = get(password) ? await encrypt(get(password), app$1.encryptionKey) : "";
const cleanTotp = get(totpSecret).trim();
+ if (cleanTotp) {
+ const totpCheck = validateTotpSecret(cleanTotp);
+ if (!totpCheck.valid) {
+ set(totpError, totpCheck.error, true);
+ set(saving, false);
+ return;
+ }
+ }
+ const encryptedPassword = get(password) ? await encrypt(get(password), app$1.encryptionKey) : "";
const encryptedTotpSecret = cleanTotp ? await encrypt(cleanTotp, app$1.encryptionKey) : "";
if (get(isEdit)) await updateEntry(updateEntry$1(await getEntryById($$props.entryId), {
title: get(title),
@@ -7252,6 +7316,18 @@ function EntryForm($$anchor, $$props) {
var div_9 = sibling(div_7, 2);
var input_3 = sibling(child(div_9), 2);
remove_input_defaults(input_3);
+ let classes;
+ var node_3 = sibling(input_3, 2);
+ var consequent_3 = ($$anchor) => {
+ var p = root_6$2();
+ var text_3 = child(p);
+ reset(p);
+ template_effect(() => set_text(text_3, `â ${get(totpError) ?? ""}`));
+ append($$anchor, p);
+ };
+ if_block(node_3, ($$render) => {
+ if (get(totpError)) $$render(consequent_3);
+ });
reset(div_9);
var div_10 = sibling(div_9, 2);
var input_4 = sibling(child(div_10), 2);
@@ -7263,21 +7339,21 @@ function EntryForm($$anchor, $$props) {
option.value = option.__value = "";
each(sibling(option), 17, () => get(groups), index, ($$anchor, group) => {
var fragment_1 = comment();
- var node_4 = first_child(fragment_1);
- var consequent_3 = ($$anchor) => {
- var option_1 = root_7$1();
- var text_3 = child(option_1, true);
+ var node_5 = first_child(fragment_1);
+ var consequent_4 = ($$anchor) => {
+ var option_1 = root_8$2();
+ var text_4 = child(option_1, true);
reset(option_1);
var option_1_value = {};
template_effect(() => {
- set_text(text_3, get(group).name);
+ set_text(text_4, get(group).name);
if (option_1_value !== (option_1_value = get(group).id)) option_1.value = (option_1.__value = get(group).id) ?? "";
});
append($$anchor, option_1);
};
var d = /* @__PURE__ */ user_derived(() => !isTrashGroup(get(group).id));
- if_block(node_4, ($$render) => {
- if (get(d)) $$render(consequent_3);
+ if_block(node_5, ($$render) => {
+ if (get(d)) $$render(consequent_4);
});
append($$anchor, fragment_1);
});
@@ -7289,7 +7365,7 @@ function EntryForm($$anchor, $$props) {
reset(div_12);
var div_13 = sibling(div_12, 2);
var button_2 = child(div_13);
- var text_4 = child(button_2, true);
+ var text_5 = child(button_2, true);
reset(button_2);
var button_3 = sibling(button_2, 2);
reset(div_13);
@@ -7297,8 +7373,9 @@ function EntryForm($$anchor, $$props) {
template_effect(() => {
set_attribute(input_2, "type", get(passwordVisible) ? "text" : "password");
set_text(text_2, get(passwordVisible) ? "đ" : "đ");
+ classes = set_class(input_3, 1, "svelte-pafazm", null, classes, { "input-error": get(totpError) });
button_2.disabled = get(saving);
- set_text(text_4, get(saving) ? "Saving..." : get(isEdit) ? "đž Update" : "â Create");
+ set_text(text_5, get(saving) ? "Saving..." : get(isEdit) ? "đž Update" : "â Create");
});
event("submit", form, (e) => {
e.preventDefault();
@@ -7308,6 +7385,7 @@ 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));
+ delegated("input", input_3, () => set(totpError, ""));
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));
@@ -7325,7 +7403,7 @@ function EntryForm($$anchor, $$props) {
append($$anchor, div);
pop();
}
-delegate(["click"]);
+delegate(["click", "input"]);
//#endregion
//#region src/components/ImportExport.svelte
var root_2$1 = /* @__PURE__ */ from_html(`
`);
@@ -8888,6 +8966,16 @@ label {
color: var(--color-warning);
}
+ .input-error.svelte-pafazm {
+ border-color: var(--color-danger) !important;
+ }
+
+ .field-error.svelte-pafazm {
+ font-size: 0.8rem;
+ color: var(--color-danger);
+ margin-top: 4px;
+ }
+
.password-input-group.svelte-pafazm {
display: flex;
gap: 8px;
diff --git a/src/components/EntryForm.svelte b/src/components/EntryForm.svelte
index c223210..ebc12d9 100644
--- a/src/components/EntryForm.svelte
+++ b/src/components/EntryForm.svelte
@@ -3,6 +3,7 @@
import { encrypt, decrypt } from '../lib/crypto/crypto.js'
import { createEntry, updateEntry as updateEntryModel, validateEntry, isTrashGroup } from '../lib/models/schema.js'
import { generatePassword } from '../lib/crypto/crypto.js'
+ import { validateTotpSecret } from '../lib/crypto/totp.js'
import { app } from '../lib/stores/app.svelte.js'
import { search as searchStore } from '../lib/stores/search.svelte.js'
import { autofocus } from '../lib/autofocus.js'
@@ -23,6 +24,7 @@
let saving = $state(false)
let isEdit = $state(false)
let formErrors = $state([])
+ let totpError = $state('')
async function loadForm() {
loading = true
@@ -68,10 +70,19 @@
return
}
- 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.
+ // TOTP secret is optional, but if provided it must be valid.
const cleanTotp = totpSecret.trim()
+ if (cleanTotp) {
+ const totpCheck = validateTotpSecret(cleanTotp)
+ if (!totpCheck.valid) {
+ totpError = totpCheck.error
+ saving = false
+ return
+ }
+ }
+
+ const encryptedPassword = password ? await encrypt(password, app.encryptionKey) : ''
+ // Accept raw base32 or an otpauth:// URI; store encrypted only when given.
const encryptedTotpSecret = cleanTotp ? await encrypt(cleanTotp, app.encryptionKey) : ''
if (isEdit) {
@@ -159,10 +170,15 @@
id="totp"
type="text"
bind:value={totpSecret}
+ oninput={() => totpError = ''}
+ class:input-error={totpError}
placeholder="Base32 secret or otpauth:// URI (e.g. JBSWY3DPEHPK3PXP)"
autocomplete="off"
spellcheck="false"
/>
+ {#if totpError}
+
â {totpError}
+ {/if}
@@ -235,6 +251,16 @@
color: var(--color-warning);
}
+ .input-error {
+ border-color: var(--color-danger) !important;
+ }
+
+ .field-error {
+ font-size: 0.8rem;
+ color: var(--color-danger);
+ margin-top: 4px;
+ }
+
.password-input-group {
display: flex;
gap: 8px;
diff --git a/src/lib/crypto/totp.js b/src/lib/crypto/totp.js
index 99b56b4..b64ce7c 100644
--- a/src/lib/crypto/totp.js
+++ b/src/lib/crypto/totp.js
@@ -59,6 +59,51 @@ export function extractSecret(input) {
return text.toUpperCase().replace(/[\s-]/g, '')
}
+/**
+ * Validate a TOTP secret entered by the user (bare base32 or otpauth:// URI).
+ *
+ * The secret is optional â an empty string validates as OK. When provided, it
+ * must decode to a valid base32 key of at least `minBytes` bytes so garbage,
+ * typos, or wrong-format input is rejected before it is stored.
+ *
+ * @param {string} input
+ * @param {Object} [opts]
+ * @param {number} [opts.minBytes=10] - Minimum decoded secret length (RFC 6238
+ * recommends >= 80 bits / 10 bytes; most real 2FA secrets exceed this).
+ * @returns {{ valid: boolean, error: string }}
+ */
+export function validateTotpSecret(input, { minBytes = 10 } = {}) {
+ const text = String(input || '').trim()
+ if (!text) return { valid: true, error: '' }
+
+ let secret
+ try {
+ secret = extractSecret(text)
+ } catch {
+ return { valid: false, error: 'Could not read the TOTP secret.' }
+ }
+
+ if (!secret) {
+ return { valid: false, error: 'No TOTP secret found. Paste a base32 code or an otpauth:// link.' }
+ }
+
+ let bytes
+ try {
+ bytes = base32Decode(secret)
+ } catch (e) {
+ return { valid: false, error: `Invalid TOTP secret: ${e.message}` }
+ }
+
+ if (bytes.length === 0) {
+ return { valid: false, error: 'The TOTP secret is empty.' }
+ }
+ if (bytes.length < minBytes) {
+ return { valid: false, error: `TOTP secret is too short (needs at least ${minBytes} characters decoded).` }
+ }
+
+ return { valid: true, error: '' }
+}
+
/**
* Generate the 8-byte big-endian counter for a Unix timestamp.
* @param {number} counter
diff --git a/tests/lib/crypto/totp.test.js b/tests/lib/crypto/totp.test.js
index c4afc39..6873f45 100644
--- a/tests/lib/crypto/totp.test.js
+++ b/tests/lib/crypto/totp.test.js
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
-import { generateTotp, totpRemainingSeconds, base32Decode, extractSecret } from '../../../src/lib/crypto/totp.js'
+import { generateTotp, totpRemainingSeconds, base32Decode, extractSecret, validateTotpSecret } from '../../../src/lib/crypto/totp.js'
// RFC 6238 test vectors (Appendix B, SHA-1) use the ASCII secret
// "12345678901234567890" whose base32 is GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ.
@@ -71,6 +71,41 @@ describe('generateTotp (RFC 6238 vectors)', () => {
})
})
+describe('validateTotpSecret', () => {
+ const GOOD = 'JBSWY3DPEHPK3PXP' // valid 10-byte base32 secret
+
+ it('accepts a blank secret (optional)', () => {
+ expect(validateTotpSecret('').valid).toBe(true)
+ expect(validateTotpSecret(' ').valid).toBe(true)
+ })
+
+ it('accepts a valid bare base32 secret', () => {
+ expect(validateTotpSecret(GOOD).valid).toBe(true)
+ })
+
+ it('accepts hyphen-grouped base32 and otpauth:// URIs', () => {
+ expect(validateTotpSecret('JBSW-Y3DP-EHPK-3PXP').valid).toBe(true)
+ expect(validateTotpSecret(`otpauth://totp/Example:alice?secret=${GOOD}&issuer=Example`).valid).toBe(true)
+ })
+
+ it('rejects invalid base32 characters', () => {
+ const r = validateTotpSecret('ABC9012345678901')
+ expect(r.valid).toBe(false)
+ expect(r.error).toMatch(/Invalid TOTP secret/)
+ })
+
+ it('rejects a secret that is too short', () => {
+ const r = validateTotpSecret('ABCDEF') // 4 decoded bytes < minBytes
+ expect(r.valid).toBe(false)
+ expect(r.error).toMatch(/too short/)
+ })
+
+ it('rejects an otpauth:// URI with no secret parameter', () => {
+ const r = validateTotpSecret('otpauth://totp/x?issuer=y')
+ expect(r.valid).toBe(false)
+ })
+})
+
describe('totpRemainingSeconds', () => {
it('returns period for exact boundary', () => {
expect(totpRemainingSeconds({ timestamp: 0, period: 30 })).toBe(30)