feat(crypto): encrypt site/title/tags at rest too (CODE_AUDIT §1.3)

Extends the username-at-rest scheme to site, title and tags — the last
searchable metadata still stored cleartext. Same design: dedicated
<f>_enc/<f>_iv columns (AES-GCM under the vault key), decrypted at load into
e.<f>, so client-side search/sort/render/favicon/autofill-match are unchanged.
Full-strength random-IV AES-GCM (no searchable encryption) because search is
client-side.

Generalized the helpers over ENCRYPTED_META_FIELDS = [username, site, title,
tags]:
- withEncryptedUsername → withEncryptedMeta (encrypts all four, blanks
  cleartext) — wraps every POST/PUT body.
- decryptEntryUsernames → decryptEntryMeta (decrypts all four at load).
- migrateUsernamesAtRest → migrateMetadataAtRest (sweeps any field still
  cleartext, live + trash).
- doChangeMasterPassword re-encrypts all four under the new key.

Server (Entries + Auth + Database):
- Columns site_enc/iv, title_enc/iv, tags_enc/iv; GET emits them (new
  AddNullableField helper); POST/PUT/bulk read+persist (BindNullable helper);
  rotation UPDATE re-encrypts them.
- Removed the server "Site required" validation (site='' when encrypted — the
  client enforces it) at POST/PUT/bulk.
- ?q= server search neutralized (site+username ciphertext → LIKE useless; the
  frontend never sends ?search=).

Tests: merge assertions updated to decrypt site (encrypted on import). 65/65.

username was runtime-validated earlier; site/title/tags NOT yet compiled/
runtime-tested (Delphi) — large multi-handler change. Rebuild BuildAssets +
PMServer, then create/edit/dup/move/tag/import/rotate and verify the DB shows
no cleartext site/title/tags (and the app still renders/searches).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-09 11:25:13 +01:00
parent 263799adcd
commit 6556ce8dea
8 changed files with 278 additions and 136 deletions
+14 -2
View File
@@ -100,6 +100,15 @@ const remoteEntry = (o) => Object.assign({
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
}, o);
// Metadata (site/title/username/tags) is encrypted at rest on the import path,
// so a stored row carries <f>_enc + a blank cleartext <f>. Decrypt to check
// the value; seeded rows (db.seedEntry) keep cleartext, so fall back to it.
async function decField(T, row, field) {
const enc = row[field + '_enc'], iv = row[field + '_iv'];
if (enc && iv) return await T.decryptPwd(enc, iv);
return row[field];
}
test('merge: remote-only entry is added locally, keeping its uuid', async () => {
const { T, db } = await freshMerge();
const res = await T.applyRemoteSnapshot({
@@ -116,7 +125,10 @@ test('merge: remote-only entry is added locally, keeping its uuid', async () =>
assert.ok(db.entries[0].username_enc, 'username_enc must be present');
assert.ok(db.entries[0].username_iv, 'username_iv must be present');
assert.notEqual(db.entries[0].username_enc, 'u', 'must not store plaintext');
assert.equal(db.entries[0].site, 'https://new.example');
// site is encrypted too: blank cleartext + ciphertext that decrypts back.
assert.equal(db.entries[0].site, '', 'cleartext site must be blanked');
assert.ok(db.entries[0].site_enc, 'site_enc must be present');
assert.equal(await decField(T, db.entries[0], 'site'), 'https://new.example');
});
test('merge: remote entry newer than local → PUT updates it', async () => {
@@ -128,7 +140,7 @@ test('merge: remote entry newer than local → PUT updates it', async () => {
});
assert.equal(res.updated, 1);
assert.equal(res.added, 0);
assert.equal(db.entries[0].site, 'https://newer');
assert.equal(await decField(T, db.entries[0], 'site'), 'https://newer');
});
test('merge: remote entry OLDER than local → skipped (last-write-wins keeps local)', async () => {