7e401ad4a9df
add passwordHistory model, capture old password on change in updateEntry, preserve across unlock, strip from passive fill
- time
- 2026-06-06 19:25 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- password history
- parent
- 761e69236230
↓ peach-browser-7e401ad4.tar.gzatlas at this save
files changed3 files
src/background/messages.ts+5 −1src/background/vault.ts+32 −1src/shared/types.ts+19 −0src/background/messages.ts+5 −1
@@ -633,7 +633,11 @@633633 // compromised content script mint valid codes indefinitely (SEV-3-C).634634 // Generate the *current* 6-digit code here in the background and send635635 // only that, plus the seconds left so the UI can render a countdown.636- const { passkey: _strippedPasskey, totp: seed, ...safeEntry } = entry;636+ // Also drop passwordHistory: filling needs only the *current* password.637+ // Handing every past password to a content script during passive638+ // autofill on an arbitrary page would widen the blast radius of a639+ // compromised page far beyond the one credential being filled.640+ const { passkey: _strippedPasskey, totp: seed, passwordHistory: _ph, ...safeEntry } = entry;637641 let totpCode: string | undefined;638642 let totpRemaining: number | undefined;639643 if (seed) {src/background/vault.ts+32 −1
@@ -50,6 +50,11 @@5050 // This coarsens ciphertext-length leakage into 1 KiB buckets.5151 const PAD_BLOCK = 1024;5252 53+// How many past passwords to retain per entry. Generous enough to be useful as54+// a recovery aid while bounding the secret material we hold (and the ciphertext55+// growth) for an entry whose password is rotated frequently.56+const PASSWORD_HISTORY_MAX = 50;57+5358 // When duress is configured, BOTH slots are padded to this fixed ceiling so5459 // their ciphertext lengths are identical and cannot reveal that a hidden decoy5560 // vault exists (audit MEDIUM — plausible-deniability leak). 256 KiB comfortably@@ -1668,11 +1673,30 @@16681673 typeof sanitizedPatch.password === "string" &&16691674 sanitizedPatch.password !== current.password;16701675 const stamp = monotonicEntryTimestamp(current.modifiedAt);1676+ // When the password actually changes, push the outgoing one onto the history1677+ // (newest first) so the user can reveal/copy/restore it later. We record the1678+ // age it carried (passwordChangedAt, falling back to createdAt) rather than1679+ // `now`, so the list reflects when each password was last in effect. Only1680+ // capture a non-empty prior value — clearing a password leaves no secret to1681+ // keep, and we skip an immediate exact duplicate (e.g. restore-then-restore)1682+ // to avoid noise.1683+ const priorPassword = current.password;1684+ const shouldCapture =1685+ passwordChanged &&1686+ typeof priorPassword === "string" &&1687+ priorPassword.length > 0 &&1688+ current.passwordHistory?.[0]?.password !== priorPassword;1689+ const nextHistory = shouldCapture1690+ ? [1691+ { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },1692+ ...(current.passwordHistory ?? []),1693+ ].slice(0, PASSWORD_HISTORY_MAX)1694+ : current.passwordHistory;16711695 entries[idx] = {16721696 ...current,16731697 ...sanitizedPatch,16741698 modifiedAt: stamp,1675- ...(passwordChanged ? { passwordChangedAt: stamp } : {}),1699+ ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),16761700 };16771701 16781702 @@ -2198,6 +2222,13 @@21982222 // signal silently resets to createdAt on each unlock (SEV-2-A).21992223 passwordChangedAt:22002224 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2225+ // Preserve past-password history across lock/unlock. normalizeEntry is a2226+ // field whitelist, so an unlisted field is silently dropped on every decode2227+ // (same class as the passwordChangedAt SEV-2-A above).2228+ passwordHistory:2229+ "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2230+ ? entry.passwordHistory2231+ : undefined,22012232 canary: "canary" in entry ? entry.canary : undefined,22022233 passkey: "passkey" in entry ? entry.passkey : undefined,22032234 createdAt,src/shared/types.ts+19 −0
@@ -56,6 +56,17 @@5656 createdAt: number;5757 }5858 59+/** One past password kept in an entry's {@link VaultEntry.passwordHistory}. */60+export interface PasswordHistoryItem {61+ /** The old password value, replaced by a newer one. */62+ password: string;63+ /**64+ * When this password stopped being current — i.e. the `passwordChangedAt`65+ * (or `createdAt`) it carried at the moment it was swapped out.66+ */67+ changedAt: number;68+}69+5970 export interface VaultEntry {6071 id: string; // crypto.randomUUID()6172 /**@@ -85,6 +96,14 @@8596 * legacy entries are read by the advanced health analyser.8697 */8798 passwordChangedAt?: number;99+ /**100+ * Previously-used passwords for this entry, newest first. A snapshot of the101+ * old password is pushed here automatically whenever the password changes102+ * (see `updateEntry` in vault.ts). Capped at PASSWORD_HISTORY_MAX. Lets the103+ * user reveal, copy, or restore a password they used before — handy when a104+ * site rejects a rotation or an old credential resurfaces.105+ */106+ passwordHistory?: PasswordHistoryItem[];88107 canary?: Canary;89108 createdAt: number; // Date.now()90109 modifiedAt: number;