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
+1 -1
View File
@@ -456,7 +456,7 @@ async function encryptImportEntry(plain) {
: (plain.tags || '');
// Encrypt the username at rest (username_enc/username_iv, cleartext blanked)
// via the shared choke point in app.js — same as the interactive save path.
return await withEncryptedUsername({
return await withEncryptedMeta({
uuid: plain.uuid || '',
site: plain.site,
title: plain.title || '',
+80 -54
View File
@@ -1468,61 +1468,82 @@ function folderMetaFor(name) {
return { color: (f && f.color) || '', icon: (f && f.icon) || '' };
}
// Metadata-at-rest: username is stored encrypted (username_enc/username_iv).
// Decrypt it into e.username in place so all downstream code (render, search,
// autofill-match, sort) works on the plaintext transparently — exactly as
// when username was a cleartext column. Rows not yet migrated have no
// username_enc → their cleartext e.username is kept as-is (fallback).
async function decryptEntryUsernames(list) {
// Metadata-at-rest: username/site/title/tags are stored encrypted
// (<f>_enc/<f>_iv). Decrypt each into e.<f> in place so all downstream code
// (render, search, autofill-match, sort, favicon) works on the plaintext
// transparently — exactly as when they were cleartext columns. Rows not yet
// migrated have no <f>_enc → their cleartext e.<f> is kept (fallback).
// (ENCRYPTED_META_FIELDS is declared just below, resolved at call time.)
async function decryptEntryMeta(list) {
for (const e of (list || [])) {
if (e && e.username_enc && e.username_iv) {
const u = await decryptPwd(e.username_enc, e.username_iv);
if (u !== '[ERROR]') e.username = u;
if (!e) continue;
for (const f of ENCRYPTED_META_FIELDS) {
const enc = e[f + '_enc'], iv = e[f + '_iv'];
if (enc && iv) {
const v = await decryptPwd(enc, iv);
if (v !== '[ERROR]') e[f] = v;
}
}
}
}
// Choke point for the write path: take an entry body object whose `username`
// holds PLAINTEXT, encrypt it into username_enc/username_iv, and blank the
// cleartext field so nothing readable is persisted. Wrap every POST/PUT
// /entries body in this. Empty username → all cleared (server stores NULL
// ciphertext + '' username). Mutates + returns the object for convenience.
async function withEncryptedUsername(obj) {
const plain = (obj && obj.username) || '';
if (plain) {
const c = await encryptPwd(plain);
obj.username_enc = c.encrypted;
obj.username_iv = c.iv;
} else {
obj.username_enc = '';
obj.username_iv = '';
// Fields encrypted at rest as metadata (§1.3). Each `f` has cleartext `f`
// (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'];
// 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
// cleartext so nothing readable is persisted. Wrap every POST/PUT /entries
// body in this. Empty field → cleared (server stores NULL ciphertext + '').
// Mutates + returns the object for convenience.
async function withEncryptedMeta(obj) {
if (!obj) return obj;
for (const f of ENCRYPTED_META_FIELDS) {
const plain = obj[f] || '';
if (plain) {
const c = await encryptPwd(plain);
obj[f + '_enc'] = c.encrypted;
obj[f + '_iv'] = c.iv;
} else {
obj[f + '_enc'] = '';
obj[f + '_iv'] = '';
}
obj[f] = '';
}
obj.username = '';
return obj;
}
// One-time sweep: re-save (full re-ship PUT) every entry that still carries a
// cleartext username with no ciphertext yet, so the cleartext is wiped from
// the DB. Runs at enterApp; no-op once every row is migrated. Best-effort —
// individual failures are skipped and retried on the next unlock. The PUT
// bumps updated_at (accepted one-time sync churn; the plaintext is unchanged
// so other devices converge to the same value).
async function migrateUsernamesAtRest() {
// True when a row still holds cleartext in a metadata field that hasn't been
// encrypted yet (cleartext present but no matching <f>_enc).
function entryNeedsMetaMigration(e) {
if (!e) return false;
return ENCRYPTED_META_FIELDS.some(f => (e[f] || '') !== '' && !e[f + '_enc']);
}
// One-time sweep: re-save (full re-ship PUT) every entry that still carries
// cleartext metadata (username/site/title/tags) with no ciphertext yet, so the
// cleartext is wiped from the DB. Runs at enterApp; no-op once every row is
// migrated. Best-effort — individual failures are skipped and retried on the
// next unlock. The PUT bumps updated_at (accepted one-time sync churn; the
// plaintext is unchanged so other devices converge to the same value).
async function migrateMetadataAtRest() {
if (!state.cryptoKey) return;
let pool = (state.entries || []).slice();
// Trashed rows live in state.trashed (loaded on demand), not state.entries,
// so the live-only sweep would leave a soft-deleted entry's username in
// so the live-only sweep would leave a soft-deleted entry's metadata in
// cleartext until purge. Fetch + decrypt the trash so it's covered too —
// the PUT updates the row's fields without touching `deleted`, so it stays
// in the trash. Trashed rows aren't in the sync snapshot, so no churn.
try {
const trash = await api('/entries?deleted=1', { headers: authHeaders() });
if (Array.isArray(trash)) {
await decryptEntryUsernames(trash);
await decryptEntryMeta(trash);
pool = pool.concat(trash);
}
} catch (_) {}
const todo = pool.filter(e => e && !e.username_enc && (e.username || '') !== '');
const todo = pool.filter(entryNeedsMetaMigration);
if (todo.length === 0) return;
let migrated = 0;
for (const e of todo) {
@@ -1530,7 +1551,7 @@ async function migrateUsernamesAtRest() {
await api('/entries/' + e.id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(await withEncryptedUsername({
body: JSON.stringify(await withEncryptedMeta({
site: e.site,
title: e.title || '',
username: e.username,
@@ -1556,7 +1577,7 @@ async function loadEntries() {
try {
const r = await api('/entries', { headers: authHeaders() });
state.entries = Array.isArray(r) ? r : [];
await decryptEntryUsernames(state.entries);
await decryptEntryMeta(state.entries);
} catch (e) {
if (e.message === 'Invalid session' || e.message === 'Session expired') {
return doLogout();
@@ -1569,7 +1590,7 @@ async function loadTrash() {
try {
const r = await api('/entries?deleted=1', { headers: authHeaders() });
state.trashed = Array.isArray(r) ? r : [];
await decryptEntryUsernames(state.trashed);
await decryptEntryMeta(state.trashed);
state.trashedCount = state.trashed.length;
} catch (e) { state.trashed = []; }
}
@@ -2234,7 +2255,7 @@ async function addTagToEntry(id, tag) {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(await withEncryptedUsername({
body: JSON.stringify(await withEncryptedMeta({
site: e.site,
title: e.title || '',
username: e.username,
@@ -3509,7 +3530,7 @@ async function batchMoveToFolder(folder) {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(await withEncryptedUsername({
body: JSON.stringify(await withEncryptedMeta({
// Re-ship the full payload — partial PUT would wipe
// TOTP / custom_fields / kind / template / username_enc
// (see also moveEntryToFolder and the "places à toucher"
@@ -3550,7 +3571,7 @@ async function batchAddTag(tag) {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(await withEncryptedUsername({
body: JSON.stringify(await withEncryptedMeta({
site: e.site,
title: e.title || '',
username: e.username,
@@ -4751,7 +4772,7 @@ async function soSave() {
cfIv = e.iv;
}
const body = JSON.stringify(await withEncryptedUsername({
const body = JSON.stringify(await withEncryptedMeta({
site, title: title.trim(), username: user,
encrypted_password: enc.encrypted, iv: enc.iv,
totp_secret: totpEnc, totp_iv: totpIv,
@@ -5079,7 +5100,7 @@ async function saveEntry(e) {
if (!site || !pwd) return toast('Site and password required', 'error');
const enc = await encryptPwd(pwd);
const body = JSON.stringify(await withEncryptedUsername({
const body = JSON.stringify(await withEncryptedMeta({
site, title, username: user, encrypted_password: enc.encrypted, iv: enc.iv,
folder: fold, tags,
}));
@@ -5175,7 +5196,7 @@ async function duplicateEntry(entry) {
const r = await api('/entries', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(await withEncryptedUsername({
body: JSON.stringify(await withEncryptedMeta({
site: entry.site || '',
title: entryDisplayName(entry) + ' (copy)',
username: entry.username || '',
@@ -5339,7 +5360,7 @@ async function moveEntryToFolder(id, folder) {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(await withEncryptedUsername({
body: JSON.stringify(await withEncryptedMeta({
site: e.site,
title: e.title || '',
username: e.username,
@@ -7182,13 +7203,19 @@ async function doChangeMasterPassword() {
cfIv = c.iv;
}
}
// Username is encrypted at rest too — re-encrypt the plaintext
// (e.username was decrypted at load) under the NEW key.
let uEnc = '', uIv = '';
if (e.username) {
state.cryptoKey = newKey;
const u = await encryptPwd(e.username);
uEnc = u.encrypted; uIv = u.iv;
// Encrypted metadata (username/site/title/tags) — re-encrypt
// each plaintext (decrypted at load) under the NEW key.
state.cryptoKey = newKey;
const meta = {};
for (const f of ENCRYPTED_META_FIELDS) {
if (e[f]) {
const c = await encryptPwd(e[f]);
meta[f + '_enc'] = c.encrypted;
meta[f + '_iv'] = c.iv;
} else {
meta[f + '_enc'] = '';
meta[f + '_iv'] = '';
}
}
encrypted.push({
id: e.id,
@@ -7198,8 +7225,7 @@ async function doChangeMasterPassword() {
totp_iv: totpIv,
custom_fields: cfEnc,
custom_fields_iv: cfIv,
username_enc: uEnc,
username_iv: uIv,
...meta,
});
} finally {
state.cryptoKey = oldKey; // restore until server confirms
@@ -7976,7 +8002,7 @@ async function enterApp() {
// One-time metadata-at-rest migration: encrypt the cleartext username of
// any row that predates the encrypted column. Fire-and-forget so it never
// blocks the UI; each pass shrinks the backlog until nothing's left.
migrateUsernamesAtRest();
migrateMetadataAtRest();
// Fire-and-forget HIBP scan if the user opted in. Runs in background,
// re-renders when done to show badges.
if (state.hibpEnabled) hibpCheckAllEntries();
+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 () => {