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
+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,
};
`;