diff --git a/dist/index.html b/dist/index.html index 2b7dbe2..67c3d54 100644 --- a/dist/index.html +++ b/dist/index.html @@ -6145,7 +6145,7 @@ var root_2$5 = /* @__PURE__ */ from_html(`
var root_4$5 = /* @__PURE__ */ from_html(`
`); var root_5$5 = /* @__PURE__ */ from_html(``); var root_3$5 = /* @__PURE__ */ from_html(``); -var root_6$4 = /* @__PURE__ */ from_html(``); +var root_6$5 = /* @__PURE__ */ from_html(``); 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(`
TitleUsernameURLNotes
`, 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(`
Username
`); +var root_6$3 = /* @__PURE__ */ from_html(` `, 1); +var root_7$1 = /* @__PURE__ */ from_html(` `, 1); +var root_8$3 = /* @__PURE__ */ from_html(`
Username
`); var root_9$1 = /* @__PURE__ */ from_html(`
Password
`); var root_10 = /* @__PURE__ */ from_html(`
2FA Code (TOTP)
`); var root_11 = /* @__PURE__ */ from_html(`
URL
`); @@ -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)