feat(entries): encrypt template at rest, guided tour, import fixes, cleanup

Batched session work sharing app.js / index.html / Entries.pas, so it can't
split cleanly without interactive hunk staging.

- feat: encrypt `template` metadata at rest (template_enc/iv, added to
  ENCRYPTED_META_FIELDS). withEncryptedMeta skips an absent template key so
  partial re-ships (add-tag, move-to-folder) don't wipe it via LHasTemplate.
  Cleartext column kept as migration fallback. +3 unit tests.
- feat: first-run guided tour ("How it works") — spotlight + bubble, no GIFs,
  re-launchable from Settings, seen-flag in DPAPI prefs.
- fix(import): preserve original created_at on restore (was stamped to import
  time); restore entry icons on overwrite (PUT ignores icon_b64).
- fix(settings): correct clipboard-privacy copy (already excluded from Win+V);
  PIN text 4-6 -> 4-12; reorder Set-PIN above unlock-method; move tray/startup
  toggles to General; dedicated backup-password button + warning status; tab icons.
- chore: remove dead legacy monolith (app-legacy.js, index-legacy.html,
  style-legacy.css) + unused passkeyBtn stub.
- docs: full-source review (CODE_AUDIT 6b), template + favorite/pinned notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-11 18:32:37 +01:00
parent 92ed153bc0
commit a42e4b205d
15 changed files with 381 additions and 2173 deletions
-1550
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -414,6 +414,8 @@ function parseEntriesFromJSON(text) {
custom_fields: cf,
attachments: atts,
icon_b64: String(e.icon_b64 || '').trim(),
created_at: String(e.created_at || '').trim(),
updated_at: String(e.updated_at || '').trim(),
});
}
return { entries, skipped, columns: null, folders,
@@ -472,6 +474,10 @@ async function encryptImportEntry(plain) {
custom_fields_iv: cfIv,
icon_b64: plain.icon_b64 || '',
template: plain.template || '',
// Preserve original timestamps on restore — bulk-import falls back
// to now only when these are absent (foreign CSV imports).
created_at: plain.created_at || '',
updated_at: plain.updated_at || '',
});
}
@@ -693,6 +699,10 @@ async function doImport() {
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(enc),
});
// PUT ignores icon_b64 (dedicated endpoint owns it), so
// restore the file's icon separately — else overwriting
// an entry whose icon was cleared never brings it back.
if (src.icon_b64) await saveEntryIcon(local.id, src.icon_b64);
overwritten++;
} catch (_) { /* skip the single row on failure */ }
}
+14 -2
View File
@@ -1493,7 +1493,7 @@ async function decryptEntryMeta(list) {
// (blanked on write) + ciphertext `f_enc`/`f_iv`. Search/sort/render all run
// client-side on the decrypted in-memory value, so encrypting these is
// transparent. `folder` stays cleartext (server folder-reassign query).
const ENCRYPTED_META_FIELDS = ['username', 'site', 'title', 'tags'];
const ENCRYPTED_META_FIELDS = ['username', 'site', 'title', 'tags', 'template'];
// Choke point for the write path: take an entry body object whose metadata
// fields hold PLAINTEXT, encrypt each into <f>_enc/<f>_iv, and blank the
@@ -1503,6 +1503,12 @@ const ENCRYPTED_META_FIELDS = ['username', 'site', 'title', 'tags'];
async function withEncryptedMeta(obj) {
if (!obj) return obj;
for (const f of ENCRYPTED_META_FIELDS) {
// Partial re-ships (add-tag, move-to-folder, batch ops) omit `template`
// on purpose — the server preserves it when the key is absent
// (LHasTemplate). Synthesising an empty one here would blank the key
// and wipe the stored template. The 4 core fields are always present,
// so this only ever skips `template`.
if (!(f in obj)) continue;
const plain = obj[f] || '';
if (plain) {
const c = await encryptPwd(plain);
@@ -8061,6 +8067,8 @@ async function enterApp() {
// Fire-and-forget periodic backup. Defer a few seconds so the unlock
// path isn't blocked by file I/O + AES-GCM over the full vault.
setTimeout(() => { runAutoBackupIfDue(); }, 5000);
// First-run guided tour (spotlights the headline features once).
maybeStartTour();
}
async function autoPurgeTrashIfNeeded() {
@@ -9123,8 +9131,12 @@ async function init() {
// Autofill picker modal close button
$$('#autofillPickerModal [data-close]').forEach(b =>
b.addEventListener('click', closeAutofillPicker));
$('#startTourBtn').addEventListener('click', () => {
closeSettings();
setTimeout(startTour, 250); // let the panel slide out first
});
$('#openClipboardSettings').addEventListener('click', () => {
toast('Open Windows Settings → System → Clipboard → turn off "Clipboard history"', 'warning');
toast('Copies use the ExcludeClipboardContentFromMonitorProcessing flag, so Windows skips them in Win+V history and cloud sync.');
});
$('#exportBtn').addEventListener('click', doExport);
$('#exportCsvBtn').addEventListener('click', doExportCSV);
+113
View File
@@ -348,6 +348,119 @@ function closeCheatsheet() {
document.getElementById('cheatsheetModal').classList.add('is-hidden');
}
// ============================================================
// Guided tour ("How it works") — spotlights real UI elements with a
// bubble, no GIFs. Cheaper than baking videos into assets.res and never
// goes stale when the UI changes. Auto-runs once, re-launchable from Settings.
// ============================================================
const TOUR_STEPS = [
{ sel: '#searchInput', title: 'Search',
body: 'Find any entry instantly (Ctrl+K). Anywhere in Windows, press Ctrl+Shift+Q for quick search — copy or autofill without opening the app.' },
{ sel: '#newEntryBtn', title: 'Add entries',
body: 'Create a login, secure note, card, SSH key and more. The ▾ caret picks the type.' },
{ sel: '.view-toggle', title: 'Views',
body: 'Switch between cards, list and table. Your choice is remembered.' },
{ sel: '.sidebar-section[data-section="tools"]', title: 'Tools',
body: 'Authenticator (2FA codes), Vault health score and the password generator live here.' },
{ sel: '#settingsBtn', title: 'Settings',
body: 'WebDAV sync, encrypted auto-backup, autofill hotkeys (Ctrl+Shift+L) and security options.' },
{ sel: '#cheatsheetBtn', title: 'Shortcuts',
body: 'Every keyboard shortcut, anytime — or just press ?.' },
];
let tourIdx = -1;
function tourSeen() {
return (Bridge.active ? null : localStorage.getItem('tourSeen')) === '1';
}
function markTourSeen() {
if (Bridge.active) Bridge.setPref('tourSeen', '1');
localStorage.setItem('tourSeen', '1'); // fast path + fallback
}
// Auto-launch on first unlock. Bridge pref is the source of truth (survives
// the port-rotation localStorage wipe); fall back to localStorage when no Bridge.
async function maybeStartTour() {
let seen = localStorage.getItem('tourSeen') === '1';
if (Bridge.active) {
try { seen = (await Bridge.getPref('tourSeen')) === '1'; } catch (_) {}
}
if (!seen) setTimeout(startTour, 600); // let the app shell settle first
}
function startTour() {
tourIdx = 0;
let bd = document.getElementById('tourBackdrop');
if (!bd) {
bd = el('div', { id: 'tourBackdrop', class: 'tour-backdrop' });
const spot = el('div', { id: 'tourSpot', class: 'tour-spot' });
const bubble = el('div', { id: 'tourBubble', class: 'tour-bubble' });
document.body.append(bd, spot, bubble);
}
window.addEventListener('resize', showTourStep);
showTourStep();
}
function showTourStep() {
// Skip any step whose target isn't in the DOM (feature hidden/disabled).
while (tourIdx < TOUR_STEPS.length &&
!document.querySelector(TOUR_STEPS[tourIdx].sel)) tourIdx++;
if (tourIdx >= TOUR_STEPS.length) return endTour();
const step = TOUR_STEPS[tourIdx];
const target = document.querySelector(step.sel);
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
// Reposition after any scroll settles so the spotlight lands on the rect.
setTimeout(() => positionTour(target, step), 120);
}
function positionTour(target, step) {
const spot = document.getElementById('tourSpot');
const bubble = document.getElementById('tourBubble');
if (!spot || !bubble) return;
const r = target.getBoundingClientRect();
const pad = 6;
spot.style.top = (r.top - pad) + 'px';
spot.style.left = (r.left - pad) + 'px';
spot.style.width = (r.width + pad * 2) + 'px';
spot.style.height = (r.height + pad * 2) + 'px';
const last = tourIdx === TOUR_STEPS.length - 1;
bubble.innerHTML =
'<div class="tour-bubble-title">' + step.title + '</div>' +
'<div class="tour-bubble-body">' + step.body + '</div>' +
'<div class="tour-bubble-foot">' +
'<span class="tour-bubble-count">' + (tourIdx + 1) + ' / ' + TOUR_STEPS.length + '</span>' +
'<span class="tour-bubble-btns">' +
'<button class="btn btn-ghost btn-sm" id="tourSkip">Skip</button>' +
'<button class="btn btn-primary btn-sm" id="tourNext">' +
(last ? 'Done' : 'Next') + '</button>' +
'</span></div>';
bubble.querySelector('#tourSkip').onclick = endTour;
bubble.querySelector('#tourNext').onclick = () => { tourIdx++; showTourStep(); };
// Place the bubble below the target if there's room, else above.
bubble.style.visibility = 'hidden';
bubble.style.top = '0px'; bubble.style.left = '0px';
const bh = bubble.offsetHeight, bw = bubble.offsetWidth;
const gap = 12;
let top = r.bottom + gap;
if (top + bh > window.innerHeight - 8) top = Math.max(8, r.top - gap - bh);
let left = r.left;
if (left + bw > window.innerWidth - 8) left = window.innerWidth - 8 - bw;
bubble.style.top = Math.max(8, top) + 'px';
bubble.style.left = Math.max(8, left) + 'px';
bubble.style.visibility = '';
}
function endTour() {
tourIdx = -1;
window.removeEventListener('resize', showTourStep);
['tourBackdrop', 'tourSpot', 'tourBubble'].forEach(id => {
const n = document.getElementById(id);
if (n) n.remove();
});
markTourSeen();
}
function openQuickSearchModal(hideAfter, forFill) {
const modal = document.getElementById('quickSearchModal');
const input = document.getElementById('quickSearchInput');
+39
View File
@@ -224,3 +224,42 @@ test('decryptPwd: wrong key returns "[ERROR]" (not garbage plaintext)', async ()
T.state.cryptoKey = k2.cryptoKey;
assert.equal(await T.decryptPwd(encrypted, iv), '[ERROR]');
});
// --- Metadata-at-rest (§1.3): template encrypted like username/site/... ---
test('withEncryptedMeta: template is encrypted at rest + round-trips', async () => {
assert.ok(T.ENCRYPTED_META_FIELDS.includes('template'),
'template must be an encrypted meta field');
const { cryptoKey } = await T.deriveKeyAndVerifier('m', 's', 100000, T.HASH_ALGO_V2);
T.state.cryptoKey = cryptoKey;
const body = await T.withEncryptedMeta({
username: 'u', site: 's', title: 't', tags: '', template: 'credit-card',
});
assert.equal(body.template, '', 'cleartext template blanked on write');
assert.ok(body.template_enc && body.template_iv, 'ciphertext written');
assert.notEqual(body.template_enc, 'credit-card', 'not stored in cleartext');
// GET returns the ciphertext (cleartext column blanked) → decrypt restores it.
const row = { template: '', template_enc: body.template_enc, template_iv: body.template_iv };
await T.decryptEntryMeta([row]);
assert.equal(row.template, 'credit-card');
});
test('withEncryptedMeta: omitting template preserves it (partial re-ship guard)', async () => {
const { cryptoKey } = await T.deriveKeyAndVerifier('m', 's', 100000, T.HASH_ALGO_V2);
T.state.cryptoKey = cryptoKey;
// add-tag / move-to-folder bodies carry no `template` key. The choke point
// must NOT synthesise template_enc='' — that would make the server wipe the
// stored template (LHasTemplate fires on the present-but-empty key).
const body = await T.withEncryptedMeta({ site: 's', title: 't', username: 'u', tags: 'x' });
assert.ok(!('template' in body), 'no cleartext template key added');
assert.ok(!('template_enc' in body), 'no ciphertext template key added');
});
test('decryptEntryMeta: un-migrated row keeps its cleartext template', async () => {
// Row predates encryption: cleartext `template` present, no template_enc.
const row = { template: 'ssh-key' };
await T.decryptEntryMeta([row]);
assert.equal(row.template, 'ssh-key', 'cleartext preserved until migration');
});
+2
View File
@@ -152,6 +152,8 @@ function loadApp(overrides = {}) {
computeStrength: (typeof computeStrength !== 'undefined' ? computeStrength : undefined),
// merge (async, coupled — tests stub the io seams below)
applyRemoteSnapshot, buildSyncSnapshot,
// metadata-at-rest
withEncryptedMeta, decryptEntryMeta, ENCRYPTED_META_FIELDS,
};
`;