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
3 files src/background/messages.ts+5 −1src/background/vault.ts+32 −1src/shared/types.ts+19 −0
src/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) {

view file

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,

view file

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;

view file