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:
+14
-2
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user