feat(crypto): encrypt username at rest (CODE_AUDIT §1.3)

username is no longer stored cleartext. New columns username_enc/username_iv
(AES-GCM under the vault key, same as encrypted_password). Search/sort/render
stay client-side, so the field is decrypted at loadEntries into e.username in
memory — everything downstream is unchanged. Full-strength random-IV AES-GCM
(no searchable/deterministic encryption) precisely because search is
client-side.

Server (PM.Handler.Entries / .Auth / PM.Database):
- Schema: vault_entries.username_enc, username_iv.
- GET returns them; POST/PUT/bulk-import read + persist them; master-pw
  rotation re-encrypts them under the new key (UPDATE + loop).
- ?q= server search drops `username LIKE` (ciphertext won't match; frontend
  searches client-side anyway).

Client (app.js / app.import.js):
- loadEntries/loadTrash decrypt username_enc → e.username (fallback to
  cleartext for un-migrated rows).
- withEncryptedUsername(obj): write choke point — encrypts obj.username into
  username_enc/username_iv and blanks the cleartext. Wraps every POST/PUT
  body: saveEntry, soSave, duplicateEntry, moveEntryToFolder, addTagToEntry,
  batchMove/AddTag, encryptImportEntry (import + sync-apply).
- doChangeMasterPassword re-encrypts username under the new key.
- migrateUsernamesAtRest(): one-time sweep at enterApp, PUT-re-ships rows that
  still carry cleartext username so the DB gets scrubbed (bumps updated_at
  once; plaintext unchanged so devices converge).

site/title/tags stay cleartext (same pattern later — see memory note). +1
merge test (username encrypted on import). 65/65.

NOT compiled/tested at runtime (Delphi) — large multi-handler change; rebuild
BuildAssets + PMServer and test create/edit/rotate/import/sync + verify the
DB shows no cleartext username.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-08 22:04:48 +01:00
parent 2578ac0d06
commit 69fb2b10dd
8 changed files with 213 additions and 37 deletions
+104 -17
View File
@@ -1468,10 +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) {
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;
}
}
}
// 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 = '';
}
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() {
if (!state.cryptoKey) return;
const todo = state.entries.filter(e => e && !e.username_enc && (e.username || '') !== '');
if (todo.length === 0) return;
let migrated = 0;
for (const e of todo) {
try {
await api('/entries/' + e.id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(await withEncryptedUsername({
site: e.site,
title: e.title || '',
username: e.username,
encrypted_password: e.encrypted_password,
iv: e.iv,
folder: e.folder,
tags: e.tags || '',
kind: e.kind || 'login',
totp_secret: e.totp_secret || '',
totp_iv: e.totp_iv || '',
custom_fields: e.custom_fields || '',
custom_fields_iv: e.custom_fields_iv || '',
template: e.template || '',
})),
});
migrated++;
} catch (_) { /* retry next unlock */ }
}
if (migrated > 0) await loadEntries();
}
async function loadEntries() {
try {
const r = await api('/entries', { headers: authHeaders() });
state.entries = Array.isArray(r) ? r : [];
await decryptEntryUsernames(state.entries);
} catch (e) {
if (e.message === 'Invalid session' || e.message === 'Session expired') {
return doLogout();
@@ -1484,6 +1556,7 @@ async function loadTrash() {
try {
const r = await api('/entries?deleted=1', { headers: authHeaders() });
state.trashed = Array.isArray(r) ? r : [];
await decryptEntryUsernames(state.trashed);
state.trashedCount = state.trashed.length;
} catch (e) { state.trashed = []; }
}
@@ -2148,7 +2221,7 @@ async function addTagToEntry(id, tag) {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
body: JSON.stringify(await withEncryptedUsername({
site: e.site,
title: e.title || '',
username: e.username,
@@ -2161,7 +2234,7 @@ async function addTagToEntry(id, tag) {
totp_iv: e.totp_iv || '',
custom_fields: e.custom_fields || '',
custom_fields_iv: e.custom_fields_iv || '',
}),
})),
});
e.tags = tags.join(',');
render();
@@ -3423,11 +3496,11 @@ async function batchMoveToFolder(folder) {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
body: JSON.stringify(await withEncryptedUsername({
// Re-ship the full payload — partial PUT would wipe
// TOTP / custom_fields / kind / template (see also
// moveEntryToFolder and the "places à toucher" list
// in CLAUDE.md).
// TOTP / custom_fields / kind / template / username_enc
// (see also moveEntryToFolder and the "places à toucher"
// list in CLAUDE.md).
site: e.site,
title: e.title || '',
username: e.username,
@@ -3440,7 +3513,7 @@ async function batchMoveToFolder(folder) {
totp_iv: e.totp_iv || '',
custom_fields: e.custom_fields || '',
custom_fields_iv: e.custom_fields_iv || '',
}),
})),
});
e.folder = folder;
} catch (err) { /* ignore individual failures */ }
@@ -3464,7 +3537,7 @@ async function batchAddTag(tag) {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
body: JSON.stringify(await withEncryptedUsername({
site: e.site,
title: e.title || '',
username: e.username,
@@ -3477,7 +3550,7 @@ async function batchAddTag(tag) {
totp_iv: e.totp_iv || '',
custom_fields: e.custom_fields || '',
custom_fields_iv: e.custom_fields_iv || '',
}),
})),
});
e.tags = tags.join(',');
} catch (err) {}
@@ -4665,7 +4738,7 @@ async function soSave() {
cfIv = e.iv;
}
const body = JSON.stringify({
const body = JSON.stringify(await withEncryptedUsername({
site, title: title.trim(), username: user,
encrypted_password: enc.encrypted, iv: enc.iv,
totp_secret: totpEnc, totp_iv: totpIv,
@@ -4673,7 +4746,7 @@ async function soSave() {
folder: fold, tags: soState.tags.join(','),
kind,
template: soState.template || '',
});
}));
try {
let targetId = soState.id;
@@ -4993,10 +5066,10 @@ async function saveEntry(e) {
if (!site || !pwd) return toast('Site and password required', 'error');
const enc = await encryptPwd(pwd);
const body = JSON.stringify({
const body = JSON.stringify(await withEncryptedUsername({
site, title, username: user, encrypted_password: enc.encrypted, iv: enc.iv,
folder: fold, tags,
});
}));
try {
let savedId = id ? parseInt(id) : null;
if (id) {
@@ -5089,7 +5162,7 @@ async function duplicateEntry(entry) {
const r = await api('/entries', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
body: JSON.stringify(await withEncryptedUsername({
site: entry.site || '',
title: entryDisplayName(entry) + ' (copy)',
username: entry.username || '',
@@ -5114,7 +5187,7 @@ async function duplicateEntry(entry) {
// Carry the template identifier so the copy keeps the
// same card / table label as the source.
template: entry.template || '',
}),
})),
});
// Copy attachments. The source's encrypted blobs are keyed to the
@@ -5253,7 +5326,7 @@ async function moveEntryToFolder(id, folder) {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
body: JSON.stringify(await withEncryptedUsername({
site: e.site,
title: e.title || '',
username: e.username,
@@ -5266,7 +5339,7 @@ async function moveEntryToFolder(id, folder) {
totp_iv: e.totp_iv || '',
custom_fields: e.custom_fields || '',
custom_fields_iv: e.custom_fields_iv || '',
}),
})),
});
e.folder = folder;
render();
@@ -7096,6 +7169,14 @@ 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.push({
id: e.id,
encrypted_password: re.encrypted,
@@ -7104,6 +7185,8 @@ async function doChangeMasterPassword() {
totp_iv: totpIv,
custom_fields: cfEnc,
custom_fields_iv: cfIv,
username_enc: uEnc,
username_iv: uIv,
});
} finally {
state.cryptoKey = oldKey; // restore until server confirms
@@ -7877,6 +7960,10 @@ async function enterApp() {
await loadEntryCounts();
render();
resetAutoLock();
// 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();
// Fire-and-forget HIBP scan if the user opted in. Runs in background,
// re-renders when done to show badges.
if (state.hibpEnabled) hibpCheckAllEntries();