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:
-1550
File diff suppressed because it is too large
Load Diff
@@ -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 */ }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user