Validate TOTP secret in the edit dialog

- totp.js: add validateTotpSecret(input, { minBytes = 10 }). Secret is
  optional (blank ok); when provided it must decode to a base32 key of at
  least 80 bits, rejecting bad characters, typos, wrong format, and
  too-short secrets.
- EntryForm: validate the TOTP secret on submit; if invalid, show an inline
  error (⚠) and a red input border and block saving. Error clears as the
  user types.
- Tests: blank ok, valid bare/grouped/otpauth URIs, invalid chars, too-short,
  URI without a secret. 163 total pass.
This commit is contained in:
hermes-explorigin 2026-08-27 01:29:33 +00:00
parent 982567acf9
commit ae23b47a92
4 changed files with 225 additions and 31 deletions

142
dist/index.html vendored
View File

@ -6145,7 +6145,7 @@ var root_2$5 = /* @__PURE__ */ from_html(`<div class="group-row svelte-181dlmc">
var root_4$5 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-181dlmc"> </div>`);
var root_5$5 = /* @__PURE__ */ from_html(`<button></button>`);
var root_3$5 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-181dlmc" role="presentation"><div class="modal svelte-181dlmc" role="dialog" aria-modal="true" aria-label="Group settings" tabindex="-1"><h3 class="svelte-181dlmc"> </h3> <!> <div class="form-group svelte-181dlmc"><label for="group-name" class="svelte-181dlmc">Group Name</label> <input id="group-name" type="text" placeholder="e.g. Work, Personal" class="svelte-181dlmc"/></div> <div class="form-group svelte-181dlmc"><span class="field-label svelte-181dlmc">Color</span> <div class="color-picker svelte-181dlmc"></div></div> <div class="modal-actions svelte-181dlmc"><button class="btn btn-primary svelte-181dlmc"> </button> <button class="btn btn-ghost svelte-181dlmc">Cancel</button></div></div></div>`);
var root_6$4 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-181dlmc" role="presentation"><div class="modal svelte-181dlmc" role="dialog" aria-modal="true" aria-label="Delete group confirmation" tabindex="-1"><h3 class="svelte-181dlmc">Delete Group</h3> <p class="svelte-181dlmc">Delete "<strong class="svelte-181dlmc"> </strong>"? Entries in this group will become ungrouped.</p> <div class="modal-actions svelte-181dlmc"><button class="btn btn-danger svelte-181dlmc">Yes, delete</button> <button class="btn btn-ghost svelte-181dlmc">Cancel</button></div></div></div>`);
var root_6$5 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-181dlmc" role="presentation"><div class="modal svelte-181dlmc" role="dialog" aria-modal="true" aria-label="Delete group confirmation" tabindex="-1"><h3 class="svelte-181dlmc">Delete Group</h3> <p class="svelte-181dlmc">Delete "<strong class="svelte-181dlmc"> </strong>"? Entries in this group will become ungrouped.</p> <div class="modal-actions svelte-181dlmc"><button class="btn btn-danger svelte-181dlmc">Yes, delete</button> <button class="btn btn-ghost svelte-181dlmc">Cancel</button></div></div></div>`);
var root$6 = /* @__PURE__ */ from_html(`<div class="sidebar-content svelte-181dlmc"><div class="sidebar-header svelte-181dlmc"><h2 class="svelte-181dlmc">🔐 Vault</h2></div> <div class="search-box svelte-181dlmc"><input type="text" placeholder="Search entries..." class="svelte-181dlmc"/></div> <nav class="groups-nav svelte-181dlmc"><button><span class="group-icon svelte-181dlmc">📋</span> <span class="group-name svelte-181dlmc">All Entries</span></button> <!></nav> <div class="trash-section svelte-181dlmc"><button><span class="group-color svelte-181dlmc"></span> <span class="group-name svelte-181dlmc"> </span></button></div> <div class="sidebar-footer svelte-181dlmc"><button class="btn btn-ghost btn-sm w-full svelte-181dlmc">+ New Group</button></div> <!> <!></div>`);
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(`<div class="loading svelte-13s7gu4">Lo
var root_2$4 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-13s7gu4"> </div>`);
var root_4$4 = /* @__PURE__ */ from_html(`<button class="btn btn-primary mt-3">+ New Entry</button>`);
var root_3$4 = /* @__PURE__ */ from_html(`<div class="empty-state svelte-13s7gu4"><p class="empty-icon svelte-13s7gu4"> </p> <p class="empty-text svelte-13s7gu4"> </p> <p class="empty-hint svelte-13s7gu4"> </p> <!></div>`);
var root_6$3 = /* @__PURE__ */ from_html(`matching "<strong> </strong>"`, 1);
var root_7$3 = /* @__PURE__ */ from_html(`<th style="width: 60px" class="svelte-13s7gu4"></th>`);
var root_6$4 = /* @__PURE__ */ from_html(`matching "<strong> </strong>"`, 1);
var root_7$2 = /* @__PURE__ */ from_html(`<th style="width: 60px" class="svelte-13s7gu4"></th>`);
var root_9$2 = /* @__PURE__ */ from_html(`<span class="drag-handle svelte-13s7gu4" aria-hidden="true"></span>`);
var root_10$1 = /* @__PURE__ */ from_html(`<div class="notes-tooltip svelte-13s7gu4"><span class="notes-icon svelte-13s7gu4">🔍</span> <div class="tooltip-popup svelte-13s7gu4"> </div></div>`);
var root_11$1 = /* @__PURE__ */ from_html(`<span></span>`);
var root_12$1 = /* @__PURE__ */ from_html(`<td class="svelte-13s7gu4"><button class="btn btn-ghost btn-sm restore-btn svelte-13s7gu4" title="Restore entry">↩️</button></td>`);
var root_8$3 = /* @__PURE__ */ from_html(`<tr><td class="svelte-13s7gu4"><!> <span class="entry-title svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-username svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-url truncate svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><!></td><!></tr>`);
var root_8$4 = /* @__PURE__ */ from_html(`<tr><td class="svelte-13s7gu4"><!> <span class="entry-title svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-username svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-url truncate svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><!></td><!></tr>`);
var root_5$4 = /* @__PURE__ */ from_html(`<div class="results-info svelte-13s7gu4"><span class="text-sm text-muted"> <!></span></div> <table class="entries-table svelte-13s7gu4"><thead><tr><th class="svelte-13s7gu4">Title</th><th class="svelte-13s7gu4">Username</th><th class="svelte-13s7gu4">URL</th><th class="svelte-13s7gu4">Notes</th><!></tr></thead><tbody></tbody></table>`, 1);
var root$5 = /* @__PURE__ */ from_html(`<div class="entry-list"><!></div>`);
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(`<div class="toast svelte-dssgjx"> </di
var root_2$3 = /* @__PURE__ */ from_html(`<div class="loading svelte-dssgjx">Loading...</div>`);
var root_3$3 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-dssgjx"> </div>`);
var root_4$3 = /* @__PURE__ */ from_html(`<div class="empty-state svelte-dssgjx">Entry not found</div>`);
var root_6$2 = /* @__PURE__ */ from_html(`<button class="btn btn-primary btn-sm">↩️ Restore</button> <button class="btn btn-danger btn-sm">🗑 Delete Forever</button>`, 1);
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_6$3 = /* @__PURE__ */ from_html(`<button class="btn btn-primary btn-sm">↩️ Restore</button> <button class="btn btn-danger btn-sm">🗑 Delete Forever</button>`, 1);
var root_7$1 = /* @__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$3 = /* @__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">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>`);
@ -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(`<div class="loading svelte-pafazm">Loa
var root_3$2 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-pafazm"> </div>`);
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="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_6$2 = /* @__PURE__ */ from_html(`<p class="field-error svelte-pafazm"> </p>`);
var root_8$2 = /* @__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="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);
@ -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(`<label class="checkbox-label group-checkbox svelte-17di1i9"><input type="checkbox" class="svelte-17di1i9"/> <span class="group-color-dot svelte-17di1i9"></span> <span class="group-name"> </span></label>`);
@ -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;

View File

@ -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}
<p class="field-error">{totpError}</p>
{/if}
</div>
<div class="form-group">
@ -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;

View File

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

View File

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