123456789101112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import { clampSyncTimestamp } from "../shared/sync-limits";15import { computeVaultId } from "../shared/sync-identity";161718import { triggerSyncIfReachable } from "./sync-trigger";19import { clearPairedDevices } from "./paired-devices";20import {21 rootKeyToWords,22 wordsToRootKeyMaterial,23 deriveRecoveryKey,24} from "../shared/recovery-words";25import type {26 AccentColorName,27 Canary,28 ManualSummonRecord,29 PasskeyData,30 SavedIdentity,31 StoredVault,32 VaultEntry,33 VaultHistory,34 VaultSnapshot,35} from "../shared/types";36import { decrypt, encrypt, zeroKey } from "./aes";37import { clearAutoLock, touchAutoLock } from "./alarms";38import { setRecoveryConfirmPending } from "./recovery-pending";3940type EntryDraft = Omit<41 VaultEntry,42 "id" | "createdAt" | "modifiedAt" | "deletedAt"43>;444546474849const SLOT_A = "peach_vault_a";50const SLOT_B = "peach_vault_b";51525354const PAD_BLOCK = 1024;5556575859const PASSWORD_HISTORY_MAX = 50;6061626364656667686970const DURESS_SLOT_SIZE = 262_144;7172let derivedKey: Uint8Array | null = null;7374757677let rootKey: Uint8Array | null = null;78let entries: VaultEntry[] = [];79808182let tombstones = new Map<string, number>();83let domainIndex: Map<string, VaultEntry[]> = new Map();84let vaultMode: "real" | "decoy" = "real";85868788let duressConfigured = false;8990919293949596979899let lastUnlockFailureReason: string | null = null;100101export function consumeLastUnlockFailureReason(): string | null {102 const r = lastUnlockFailureReason;103 lastUnlockFailureReason = null;104 return r;105}106107108109110111export type TextSize = "small" | "medium" | "large";112113export interface VaultSettings {114 accentColor?: AccentColorName;115 theme?: "dark" | "light";116 autoLockMinutes?: number;117 clipboardClearSeconds?: number;118 lockOnClose?: boolean;119 breachMonitoring?: boolean;120 passkeyProviderEnabled?: boolean;121 trashRetentionDays?: number;122 manualSummonDomains?: Record<string, ManualSummonRecord>;123 declinedCardFingerprints?: string[];124 125126127128129 textSize?: TextSize;130 131132133134135 reduceMotion?: boolean;136 137 pinEnabled?: boolean;138 139 pinPreferred?: boolean;140 141142143144145146147 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;148}149150interface StoredPinWrap {151 version: 1;152 salt: string;153 nonce: string;154 ciphertext: string;155 tag: string;156}157158const PIN_WRAP_STORAGE = "peach_pin_wrap";159const PIN_ATTEMPTS_STORAGE = "peach_pin_attempts";160const PIN_MIN_LENGTH = 4;161const PIN_MAX_LENGTH = 12;162const PIN_MAX_ATTEMPTS = 3;163164let settings: VaultSettings = {};165let inMemoryPinWrap: StoredPinWrap | null = null;166let inMemoryPinFailedAttempts = 0;167let sessionPinStorageFailed = false;168169export function getSettings(): VaultSettings {170 return settings;171}172173export async function ensureSettingsLoaded(): Promise<VaultSettings> {174 await loadSettings();175 return settings;176}177178export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {179 await loadSettings();180 181 182 183 184 const safe = Object.fromEntries(185 Object.entries(patch).filter(186 ([k]) => !["__proto__", "constructor", "prototype"].includes(k),187 ),188 ) as Partial<VaultSettings>;189 settings = { ...settings, ...safe };190 await persistSettings();191}192193function isValidPin(pin: string): boolean {194 return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);195}196197198export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {199 if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;200 if (!(await verifyMainPassword(currentPassword))) return false;201202 const salt = generateSalt();203 const pinKey = await deriveKey(pin, salt);204 try {205 const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");206 await savePinWrap({ version: 1, salt: hex(salt), ...wrapped });207 await updateSettings({ pinEnabled: true });208 return true;209 } finally {210 zeroKey(pinKey);211 }212}213214async function clearPinUnlockStorage(): Promise<void> {215 inMemoryPinWrap = null;216 inMemoryPinFailedAttempts = 0;217 sessionPinStorageFailed = false;218 if (chrome.storage.session) {219 await chrome.storage.session.remove([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);220 }221 await updateSettings({ pinEnabled: false });222}223224225export async function disablePinUnlock(currentPassword: string): Promise<boolean> {226 if (!derivedKey || vaultMode !== "real") return false;227 if (!(await verifyMainPassword(currentPassword))) return false;228 await clearPinUnlockStorage();229 return true;230}231232233export async function unlockWithPin(pin: string): Promise<boolean> {234 if (!isValidPin(pin)) return false;235 await loadSettings();236 if (!settings.pinEnabled) return false;237238 const { stored, failedAttempts } = await loadPinState();239 if (failedAttempts >= PIN_MAX_ATTEMPTS) return false;240 if (241 !stored ||242 stored.version !== 1 ||243 !stored.salt ||244 !stored.nonce ||245 !stored.ciphertext ||246 !stored.tag247 ) return false;248249 const pinKey = await deriveKey(pin, unhex(stored.salt));250 let unwrappedKey: Uint8Array | null = null;251 try {252 const masterKeyHex = await decrypt(253 pinKey,254 stored.nonce,255 stored.ciphertext,256 stored.tag,257 "pin_wrap_v1",258 );259 if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;260 unwrappedKey = unhex(masterKeyHex);261 const result = await unlockWithKey(masterKeyHex);262 if (!result.ok) return false;263 await saveSessionKey(unwrappedKey, "real");264 await resetPinFailedAttempts();265 return true;266 } catch {267 await recordPinFailure(failedAttempts + 1);268 return false;269 } finally {270 zeroKey(pinKey);271 if (unwrappedKey) zeroKey(unwrappedKey);272 }273}274275async function savePinWrap(stored: StoredPinWrap): Promise<void> {276 inMemoryPinWrap = stored;277 inMemoryPinFailedAttempts = 0;278 sessionPinStorageFailed = !chrome.storage.session;279 if (!chrome.storage.session) return;280 try {281 await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 });282 sessionPinStorageFailed = false;283 } catch {284 285 sessionPinStorageFailed = true;286 }287}288289async function loadPinState(): Promise<{290 stored: StoredPinWrap | undefined;291 failedAttempts: number;292}> {293 if (!chrome.storage.session) {294 return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };295 }296 try {297 const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);298 return {299 stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ??300 (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined),301 failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number"302 ? result[PIN_ATTEMPTS_STORAGE]303 : 0,304 };305 } catch {306 return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };307 }308}309310async function recordPinFailure(failedAttempts: number): Promise<void> {311 inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS);312 if (!chrome.storage.session) return;313 try {314 await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts });315 } catch { }316}317318async function resetPinFailedAttempts(): Promise<void> {319 inMemoryPinFailedAttempts = 0;320 if (!chrome.storage.session) return;321 try {322 await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 });323 } catch { }324}325326export async function getPinUnlockStatus(): Promise<{327 configured: boolean;328 available: boolean;329 attemptsRemaining: number;330}> {331 await loadSettings();332 if (!settings.pinEnabled) {333 return { configured: false, available: false, attemptsRemaining: 0 };334 }335 const { stored, failedAttempts } = await loadPinState();336 const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS;337 return {338 configured: true,339 available,340 attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0,341 };342}343344async function persistSettings(): Promise<void> {345 try {346 await chrome.storage.local.set({ peach_settings: settings });347 } catch (err) {348 throw mapStorageError(err);349 }350}351352function mapStorageError(err: unknown): Error {353 const message = err instanceof Error ? err.message : String(err);354 if (/QUOTA_BYTES|quota/i.test(message)) {355 return new Error("STORAGE_QUOTA_EXCEEDED");356 }357 return err instanceof Error ? err : new Error(message);358}359360async function loadSettings(): Promise<void> {361 const result = await chrome.storage.local.get("peach_settings");362 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};363}364365366367function fireSyncTrigger(reason: "vault_change"): void {368 void Promise.resolve()369 .then(() => triggerSyncIfReachable({ reason }))370 .catch(() => {371 372 });373}374375let lockedSyncRefreshHook: (() => Promise<void>) | null = null;376let lockedSyncUnlockHook: (() => Promise<void>) | null = null;377378export function setLockedSyncHooks(hooks: {379 refresh: () => Promise<void>;380 afterUnlock: () => Promise<void>;381}): void {382 lockedSyncRefreshHook = hooks.refresh;383 lockedSyncUnlockHook = hooks.afterUnlock;384}385386async function refreshLockedSyncState(): Promise<void> {387 if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;388 await lockedSyncRefreshHook().catch((error) => {389 console.warn("[Peach sync] locked spool refresh failed:", error);390 });391}392393394395396397export async function checkBreach(password: string): Promise<number> {398 399 400 const encoder = new TextEncoder();401 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));402 const hashHex = Array.from(new Uint8Array(hashBuffer))403 .map((b) => b.toString(16).padStart(2, "0"))404 .join("")405 .toUpperCase();406407 const prefix = hashHex.slice(0, 5);408 const suffix = hashHex.slice(5);409410 try {411 412 413 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {414 headers: { "Add-Padding": "true" },415 });416 if (!res.ok) return 0;417 const text = await res.text();418 const match = text.split("\r\n").find((l) => l.startsWith(suffix));419 return match ? parseInt(match.split(":")[1], 10) : 0;420 } catch {421 return 0; 422 }423}424425export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {426 const fingerprint = await cardFingerprint(last4, expMonth, expYear);427 await loadSettings();428 settings = {429 ...settings,430 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],431 };432 await persistSettings();433}434435export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {436 await loadSettings();437 const fingerprint = await cardFingerprint(last4, expMonth, expYear);438 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);439}440441async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {442 const encoder = new TextEncoder();443 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));444 return Array.from(new Uint8Array(buf).slice(0, 8))445 .map((b) => b.toString(16).padStart(2, "0"))446 .join("");447}448449function hex(buf: Uint8Array): string {450 return Array.from(buf)451 .map((b) => b.toString(16).padStart(2, "0"))452 .join("");453}454455function unhex(s: string): Uint8Array {456 const out = new Uint8Array(s.length / 2);457 for (let i = 0; i < s.length; i += 2) {458 out[i / 2] = parseInt(s.slice(i, i + 2), 16);459 }460 return out;461}462463464465466467468469470471472473474475476477478479480481482async function generateAndWrapRootKey(): Promise<{483 rawRootKey: string; 484 recoverySalt: string;485 wrappedRootKey: string;486 wrappedNonce: string;487 wrappedTag: string;488}> {489 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));490491 492 const words = await rootKeyToWords(rootKeyBytes);493 const wordMaterial = await wordsToRootKeyMaterial(words);494 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));495 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);496497 498 499 const nonce = crypto.getRandomValues(new Uint8Array(12));500 const encoded = new TextEncoder().encode(hex(rootKeyBytes));501 const raw = await crypto.subtle.encrypt(502 {503 name: "AES-GCM",504 iv: nonce,505 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,506 tagLength: 128,507 },508 recoveryKey,509 encoded510 );511 512 const rawU8 = new Uint8Array(raw);513 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));514 const tag = hex(rawU8.slice(rawU8.length - 16));515516 return {517 rawRootKey: hex(rootKeyBytes),518 recoverySalt: hex(recoverySalt),519 wrappedRootKey: ciphertext,520 wrappedNonce: hex(nonce),521 wrappedTag: tag,522 };523}524525async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{526 recoverySalt: string;527 wrappedRootKey: string;528 wrappedNonce: string;529 wrappedTag: string;530}> {531 if (rootKeyBytes.length !== 32) {532 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");533 }534535 const words = await rootKeyToWords(rootKeyBytes);536 const wordMaterial = await wordsToRootKeyMaterial(words);537 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));538 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);539 const nonce = crypto.getRandomValues(new Uint8Array(12));540 const encoded = new TextEncoder().encode(hex(rootKeyBytes));541 const raw = await crypto.subtle.encrypt(542 {543 name: "AES-GCM",544 iv: nonce,545 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,546 tagLength: 128,547 },548 recoveryKey,549 encoded,550 );551 const rawU8 = new Uint8Array(raw);552 return {553 recoverySalt: hex(recoverySalt),554 wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),555 wrappedNonce: hex(nonce),556 wrappedTag: hex(rawU8.slice(rawU8.length - 16)),557 };558}559560561562563564565export async function unwrapRootKeyForRecovery(566 wrappedRootKey: string,567 wrappedNonce: string,568 wrappedTag: string,569 recoverySaltHex: string,570 words: string[]571): Promise<void> {572 const wordMaterial = await wordsToRootKeyMaterial(words);573 const recoverySalt = unhex(recoverySaltHex);574 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);575576 577 578 579 580 581 const nonceRaw = unhex(wrappedNonce);582 const ciphertextRaw = unhex(wrappedRootKey);583 const tagRaw = unhex(wrappedTag);584 585 586 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;587 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);588 combined.set(ciphertextRaw, 0);589 combined.set(tagRaw, ciphertextRaw.length);590 const raw = await crypto.subtle.decrypt(591 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },592 recoveryKey,593 combined594 );595 const decryptedHex = new TextDecoder().decode(raw);596 rootKey = unhex(decryptedHex);597}598599600601602603604605606607608export async function rewrapRootKeyForPassword(609 newDerivedKey: Uint8Array,610 _newSalt: Uint8Array611): Promise<void> {612 if (!rootKey) throw new Error("root key not set");613 const slotA = await loadSlot(SLOT_A);614 if (!slotA) throw new Error("missing slot A");615 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);616 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });617}618619620621622623624async function reencryptVaultForNewKey(625 newKey: Uint8Array,626 slotA: StoredVault627): Promise<void> {628 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);629 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");630 await writeSlot(SLOT_A, {631 ...slotA,632 nonce,633 ciphertext,634 tag,635 });636}637638639640641642643export async function changePasswordWithNewKey(644 newSalt: Uint8Array,645 newKey: Uint8Array646): Promise<void> {647 const slotA = await loadSlot(SLOT_A);648 if (!slotA) throw new Error("missing slot A");649 650 651 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });652 if (derivedKey) zeroKey(derivedKey);653 derivedKey = newKey;654 655 656 657 void saveSessionKey(newKey, "real");658 659 660 661 662 if (rootKey) {663 const slotAfter = await loadSlot(SLOT_A);664 if (slotAfter) {665 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);666 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });667 }668 }669 if (!duressConfigured) {670 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));671 }672 await clearPinUnlockStorage();673 await stampLocalVaultMutation();674}675676677678679680681export { deriveKey, generateSalt };682683export async function loadSlotA(): Promise<StoredVault | null> {684 return loadSlot(SLOT_A);685}686687688689690691692693694695696async function deriveRootKeyFromDerivedKey(697 derivedKeyBytes: Uint8Array,698 slotA: StoredVault,699): Promise<Uint8Array | null> {700 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {701 return null;702 }703 try {704 const rootHex = await decrypt(705 derivedKeyBytes,706 slotA.pw_root_nonce,707 slotA.pw_root_ciphertext,708 slotA.pw_root_tag,709 "pw_root_v1",710 );711 return unhex(rootHex);712 } catch {713 return null;714 }715}716717718719async function wrapRootKeyForDerivedKey(720 derivedKeyBytes: Uint8Array,721 rootKeyBytes: Uint8Array,722): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {723 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");724 return {725 pw_root_ciphertext: ciphertext,726 pw_root_nonce: nonce,727 pw_root_tag: tag,728 };729}730731732export async function getRecoveryWords(): Promise<string[]> {733 if (!rootKey) {734 throw new Error("recovery not set up");735 }736 return rootKeyToWords(rootKey);737}738739async function loadSlot(key: string): Promise<StoredVault | null> {740 const result = await chrome.storage.local.get(key);741 return (result as Record<string, StoredVault | undefined>)[key] ?? null;742}743744async function writeSlot(key: string, vault: StoredVault): Promise<void> {745 try {746 await chrome.storage.local.set({ [key]: vault });747 } catch (err) {748 throw mapStorageError(err);749 }750}751752753754755756757758759760761export async function rollbackAdoptedVault(): Promise<void> {762 lock();763 try {764 await chrome.storage.local.remove([765 SLOT_A,766 SLOT_B,767 "peach_last_modified_at",768 ]);769 } catch (err) {770 throw mapStorageError(err);771 }772}773774async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {775 const raw = await chrome.storage.local.get("peach_last_modified_at");776 const previous = Number(777 (raw as Record<string, unknown>).peach_last_modified_at,778 );779 const next = Math.max(780 Date.now(),781 Number.isFinite(previous) ? previous + 1 : 0,782 );783 try {784 await chrome.storage.local.set({785 [SLOT_A]: slotA,786 [SLOT_B]: slotB,787 peach_last_modified_at: String(next),788 });789 } catch (err) {790 throw mapStorageError(err);791 }792}793794795796797798799800801let lastEntryTimestamp = 0;802function monotonicEntryTimestamp(currentModifiedAt?: number): number {803 const base = Math.max(804 Date.now(),805 (currentModifiedAt ?? 0) + 1,806 lastEntryTimestamp + 1,807 );808 lastEntryTimestamp = base;809 return base;810}811812async function stampLocalVaultMutation(): Promise<void> {813 const raw = await chrome.storage.local.get("peach_last_modified_at");814 const previous = Number(815 (raw as Record<string, unknown>).peach_last_modified_at,816 );817 const next = Math.max(818 Date.now(),819 Number.isFinite(previous) ? previous + 1 : 0,820 );821 await chrome.storage.local.set({822 peach_last_modified_at: String(next),823 });824}825826827828829830831832833834835836function padPlaintext(json: string, minBytes = 0): string {837 const encoded = new TextEncoder().encode(json);838 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);839 840 const target = Math.max(bucket, minBytes);841 const padLen = target - encoded.length;842 return json + " ".repeat(padLen);843}844845846847848849850851852853854855const CSPRNG_CHUNK = 65_536;856857function fillRandom(buf: Uint8Array): Uint8Array {858 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {859 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);860 const chunk = new Uint8Array(end - offset);861 crypto.getRandomValues(chunk);862 buf.set(chunk, offset);863 }864 return buf;865}866867function generateNoiseBlob(ciphertextBytes: number): StoredVault {868 const salt = crypto.getRandomValues(new Uint8Array(16));869 const nonce = crypto.getRandomValues(new Uint8Array(12));870 const cipher = fillRandom(new Uint8Array(ciphertextBytes));871 const tag = crypto.getRandomValues(new Uint8Array(16));872 return {873 version: 1,874 salt: hex(salt),875 nonce: hex(nonce),876 ciphertext: hex(cipher),877 tag: hex(tag),878 };879}880881882883884885type VaultPayload = {886 entries: VaultEntry[];887 tombstones?: Record<string, number>;888 duressConfigured?: boolean;889};890891type DecryptedPayload = {892 entries: VaultEntry[];893 tombstones: Map<string, number>;894 duressConfigured: boolean;895};896897function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {898 const envelope: VaultPayload = {899 entries,900 tombstones: Object.fromEntries(tombstones),901 duressConfigured: duress,902 };903 return padPlaintext(JSON.stringify(envelope), minBytes);904}905906function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {907 const result = new Map<string, number>();908 if (raw && typeof raw === "object" && !Array.isArray(raw)) {909 for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {910 if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {911 result.set(id, timestamp);912 }913 }914 }915 916 917 for (const entry of allEntries) {918 if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {919 continue;920 }921 if ((result.get(entry.id) ?? 0) < entry.deletedAt) {922 result.set(entry.id, entry.deletedAt);923 }924 }925 return result;926}927928function reconcileTombstones(): void {929 const byId = new Map(entries.map((entry) => [entry.id, entry]));930 for (const [id] of tombstones) {931 if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);932 }933 for (const entry of entries) {934 if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {935 if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {936 tombstones.set(entry.id, entry.deletedAt);937 }938 }939 }940}941942function decodePayload(plain: string): DecryptedPayload {943 const parsed = JSON.parse(plain) as VaultPayload;944 if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {945 throw new Error("invalid vault payload");946 }947 const normalized = parsed.entries.map(normalizeEntry);948 return {949 entries: normalized,950 tombstones: decodeTombstones(parsed.tombstones, normalized),951 duressConfigured: parsed.duressConfigured === true,952 };953}954955956957958959960961962963964965966967968969970971972973974975function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {976 let merged = false;977 const now = Date.now();978979 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);980 const byRpId = new Map<string, VaultEntry[]>();981 for (const e of candidates) {982 const rpId = e.passkey!.rpId;983 const list = byRpId.get(rpId);984 if (list) list.push(e);985 else byRpId.set(rpId, [e]);986 }987988 for (const [rpId, group] of byRpId) {989 if (group.length === 0) continue;990 group.sort((a, b) => {991 const aT =992 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;993 const bT =994 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;995 return bT - aT;996 });997 const winner = group[0]!;998999 1000 1001 1002 const loginTarget = entries.find((e) => {1003 if (e.deletedAt || e.passkey || e.type !== "login") return false;1004 if (e.url && extractDomain(e.url) === rpId) return true;1005 for (const extra of e.additionalUrls ?? []) {1006 if (extractDomain(extra) === rpId) return true;1007 }1008 return false;1009 });10101011 if (loginTarget) {1012 loginTarget.passkey = winner.passkey;1013 1014 1015 1016 if (!loginTarget.totp && winner.totp) {1017 loginTarget.totp = winner.totp;1018 loginTarget.totpAlgorithm = winner.totpAlgorithm;1019 loginTarget.totpDigits = winner.totpDigits;1020 loginTarget.totpPeriod = winner.totpPeriod;1021 }1022 loginTarget.modifiedAt = now;1023 for (const dup of group) {1024 if (!dup.deletedAt) {1025 dup.deletedAt = now;1026 dup.modifiedAt = now;1027 }1028 }1029 merged = true;1030 } else if (group.length > 1) {1031 for (let i = 1; i < group.length; i += 1) {1032 const dup = group[i]!;1033 if (!dup.deletedAt) {1034 dup.deletedAt = now;1035 dup.modifiedAt = now;1036 }1037 }1038 merged = true;1039 }1040 }10411042 return { merged };1043}10441045104610471048104910501051function hasValidVaultSchema(v: unknown): v is StoredVault {1052 if (typeof v !== "object" || v === null) return false;1053 const r = v as Record<string, unknown>;1054 return (1055 r.version === 1 &&1056 typeof r.salt === "string" &&1057 typeof r.nonce === "string" &&1058 typeof r.ciphertext === "string" &&1059 typeof r.tag === "string"1060 );1061}10621063export type TryDecryptResult =1064 | { status: "ok"; payload: DecryptedPayload }1065 | { status: "wrong_key" }1066 | { status: "corrupted"; reason: string };1067106810691070107110721073export async function tryDecryptSlot(1074 vault: StoredVault,1075 key: Uint8Array,1076 aadTag?: string,1077): Promise<DecryptedPayload | null> {1078 const res = await tryDecryptSlotResult(vault, key, aadTag);1079 return res.status === "ok" ? res.payload : null;1080}10811082export async function tryDecryptSlotResult(1083 vault: StoredVault,1084 key: Uint8Array,1085 aadTag?: string,1086): Promise<TryDecryptResult> {1087 if (!hasValidVaultSchema(vault)) {1088 return { status: "corrupted", reason: "invalid_slot_schema" };1089 }1090 let plain: string;1091 try {1092 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1093 } catch {1094 return { status: "wrong_key" };1095 }1096 try {1097 const parsed = JSON.parse(plain) as unknown;1098 if (typeof parsed !== "object" || parsed === null) {1099 return { status: "corrupted", reason: "invalid_payload_shape" };1100 }1101 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1102 return { status: "corrupted", reason: "invalid_payload_shape" };1103 }1104 return { status: "ok", payload: decodePayload(plain) };1105 } catch {1106 return { status: "corrupted", reason: "invalid_payload_json" };1107 }1108}110911101111111211131114function buildDomainIndexSync(all: VaultEntry[]): void {1115 domainIndex = new Map();11161117 for (const entry of all) {1118 if (entry.deletedAt) continue;1119 if (entry.type === "note" || entry.type === "card") continue;11201121 1122 1123 1124 1125 const domains = new Set<string>();1126 const primary = extractDomain(entry.url ?? "");1127 if (primary) domains.add(primary);1128 for (const extra of entry.additionalUrls ?? []) {1129 const d = extractDomain(extra);1130 if (d) domains.add(d);1131 }1132 if (domains.size === 0) continue;11331134 for (const domain of domains) {1135 const bucket = domainIndex.get(domain) ?? [];1136 bucket.push(entry);1137 domainIndex.set(domain, bucket);1138 }1139 }1140}11411142export async function createVault(password: string): Promise<void> {1143 const salt = generateSalt();1144 derivedKey = await deriveKey(password, salt);1145 entries = [];1146 tombstones = new Map();1147 vaultMode = "real";1148 duressConfigured = false;1149 buildDomainIndexSync(entries);11501151 1152 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1153 await generateAndWrapRootKey();1154 rootKey = unhex(rawRootKey);11551156 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1157 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");11581159 1160 1161 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);11621163 const slotA: StoredVault = {1164 version: 1,1165 salt: hex(salt),1166 nonce,1167 ciphertext,1168 tag,1169 recovery_salt: recoverySalt,1170 recovery_wrapped_root_key: wrappedRootKey,1171 recovery_nonce: wrappedNonce,1172 recovery_tag: wrappedTag,1173 ...pwRoot,1174 };1175 await writeSlot(SLOT_A, slotA);11761177 1178 1179 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1180 await stampLocalVaultMutation();11811182 touchAutoLock();1183}1184118511861187118811891190export async function createVaultFromRootKey(1191 password: string,1192 rootKeyHex: string,1193): Promise<{ vaultId: string }> {1194 if (typeof password !== "string" || password.length === 0) {1195 throw new Error("ADOPT_INVALID_PASSWORD");1196 }1197 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1198 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1199 }12001201 const rootKeyBytes = unhex(rootKeyHex);1202 if (rootKeyBytes.length !== 32) {1203 rootKeyBytes.fill(0);1204 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1205 }12061207 let nextDerivedKey: Uint8Array | null = null;1208 let sessionRootKey: Uint8Array | null = null;1209 try {1210 if ((await loadSlot(SLOT_A)) !== null) {1211 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1212 }12131214 const nextEntries: VaultEntry[] = [];1215 const nextDuressConfigured = false;1216 const vaultId = await computeVaultId(rootKeyBytes);1217 const salt = generateSalt();1218 nextDerivedKey = await deriveKey(password, salt);1219 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);12201221 sessionRootKey = new Uint8Array(rootKeyBytes);1222 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1223 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1224 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1225 const slotA: StoredVault = {1226 version: 1,1227 salt: hex(salt),1228 nonce,1229 ciphertext,1230 tag,1231 recovery_salt: recovery.recoverySalt,1232 recovery_wrapped_root_key: recovery.wrappedRootKey,1233 recovery_nonce: recovery.wrappedNonce,1234 recovery_tag: recovery.wrappedTag,1235 ...pwRoot,1236 };1237 const slotB = generateNoiseBlob(ciphertext.length / 2);1238 await commitCreatedVaultSlots(slotA, slotB);12391240 const previousDerivedKey = derivedKey;1241 const previousRootKey = rootKey;1242 derivedKey = nextDerivedKey;1243 rootKey = sessionRootKey;1244 nextDerivedKey = null;1245 sessionRootKey = null;1246 entries = nextEntries;1247 tombstones = new Map();1248 vaultMode = "real";1249 duressConfigured = nextDuressConfigured;1250 buildDomainIndexSync(entries);1251 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1252 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1253 touchAutoLock();12541255 return { vaultId };1256 } finally {1257 rootKeyBytes.fill(0);1258 if (nextDerivedKey) zeroKey(nextDerivedKey);1259 if (sessionRootKey) sessionRootKey.fill(0);1260 }1261}12621263export async function hasVault(): Promise<boolean> {1264 return (await loadSlot(SLOT_A)) !== null;1265}1266126712681269127012711272export async function isDuressEnabled(): Promise<boolean> {1273 return vaultMode === "real" && duressConfigured;1274}12751276export async function setupDuress(duressPassword: string): Promise<void> {1277 if (!derivedKey || vaultMode !== "real") {1278 throw new Error("vault must be unlocked in real mode");1279 }1280 1281 1282 1283 const slotA = await loadSlot(SLOT_A);1284 if (!slotA) throw new Error("missing slot A");12851286 const decoySalt = generateSalt();1287 const decoyKey = await deriveKey(duressPassword, decoySalt);1288 try {1289 1290 1291 1292 1293 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1294 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1295 await writeSlot(SLOT_B, {1296 version: 1,1297 salt: hex(decoySalt),1298 nonce,1299 ciphertext,1300 tag,1301 });1302 } finally {1303 zeroKey(decoyKey);1304 }13051306 1307 1308 duressConfigured = true;1309 await persistSlotA();1310}13111312export async function disableDuress(): Promise<void> {1313 if (!derivedKey || vaultMode !== "real") {1314 throw new Error("vault must be unlocked in real mode");1315 }1316 1317 1318 1319 const slotA = await loadSlot(SLOT_A);1320 if (slotA) {1321 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1322 }13231324 duressConfigured = false;1325 await persistSlotA();1326}13271328132913301331133213331334export async function verifyMainPassword(password: string): Promise<boolean> {1335 const slotA = await loadSlot(SLOT_A);1336 if (!slotA) return false;1337 const key = await deriveKey(password, unhex(slotA.salt));1338 try {1339 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1340 } finally {1341 zeroKey(key);1342 }1343}13441345export async function unlock(password: string): Promise<boolean> {1346 return attemptUnlockWithDerive(password, deriveKey);1347}13481349async function attemptUnlockWithDerive(1350 password: string,1351 derive: typeof deriveKey,1352): Promise<boolean> {1353 1354 1355 1356 1357 1358 1359 const [slotA, slotB] = await Promise.all([1360 loadSlot(SLOT_A),1361 loadSlot(SLOT_B),1362 ]);13631364 1365 const [keyA, keyB] = await Promise.all([1366 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1367 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1368 ]);13691370 1371 1372 1373 const [resultA, resultB] = await Promise.all([1374 keyA && slotA1375 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1376 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1377 keyB && slotB1378 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1379 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1380 ]);1381 const payloadA = resultA.status === "ok" ? resultA.payload : null;1382 const payloadB = resultB.status === "ok" ? resultB.payload : null;13831384 if (payloadA && keyA) {1385 derivedKey = keyA;1386 void saveSessionKey(keyA, "real");1387 vaultMode = "real";1388 entries = payloadA.entries;1389 tombstones = payloadA.tombstones;1390 duressConfigured = payloadA.duressConfigured;1391 buildDomainIndexSync(entries);1392 await loadSettings();1393 touchAutoLock();1394 if (keyB) zeroKey(keyB);13951396 1397 1398 rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1399 if (!rootKey) {1400 lock();1401 return false;1402 }14031404 await lockedSyncUnlockHook?.().catch((error) => {1405 console.warn("[Peach sync] locked inbox drain failed:", error);1406 });1407 await resetPinFailedAttempts();1408 return true;1409 }14101411 if (payloadB && keyB) {1412 derivedKey = keyB;1413 void saveSessionKey(keyB, "decoy");1414 vaultMode = "decoy";1415 entries = payloadB.entries;1416 tombstones = payloadB.tombstones;1417 duressConfigured = false;1418 buildDomainIndexSync(entries);1419 await loadSettings();1420 touchAutoLock();1421 if (keyA) zeroKey(keyA);1422 return true;1423 }14241425 if (keyA) zeroKey(keyA);1426 if (keyB) zeroKey(keyB);1427 1428 1429 if (resultA.status === "corrupted") {1430 lastUnlockFailureReason = resultA.reason;1431 } else if (resultB.status === "corrupted") {1432 lastUnlockFailureReason = resultB.reason;1433 }1434 return false;1435}14361437143814391440144114421443const SESSION_KEY_STORAGE = "peach_session_dk";14441445async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1446 if (!chrome.storage.session) return;1447 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1448}14491450async function clearSessionKey(): Promise<void> {1451 if (!chrome.storage.session) return;1452 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1453}145414551456145714581459146014611462export async function restoreFromSession(): Promise<boolean> {1463 if (derivedKey !== null) return true;1464 if (!chrome.storage.session) return false;1465 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1466 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1467 if (!entry?.keyHex) return false;14681469 const key = unhex(entry.keyHex);1470 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1471 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1472 const slot = await loadSlot(slotKey);1473 if (!slot) { await clearSessionKey(); return false; }14741475 const result = await tryDecryptSlotResult(slot, key, aadTag);1476 if (result.status !== "ok") { await clearSessionKey(); return false; }14771478 derivedKey = key;1479 vaultMode = entry.mode;1480 entries = result.payload.entries;1481 tombstones = result.payload.tombstones;1482 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1483 buildDomainIndexSync(entries);1484 await loadSettings();1485 touchAutoLock();14861487 1488 1489 if (entry.mode === "real") {1490 rootKey = await deriveRootKeyFromDerivedKey(key, slot);1491 if (!rootKey) {1492 lock();1493 return false;1494 }1495 }14961497 await lockedSyncUnlockHook?.().catch((error) => {1498 console.warn("[Peach sync] locked inbox drain failed:", error);1499 });15001501 return true;1502}15031504export function lock(): void {1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 void clearSessionKey();1515 if (derivedKey) {1516 zeroKey(derivedKey);1517 derivedKey = null;1518 }1519 if (rootKey) {1520 rootKey.fill(0);1521 rootKey = null;1522 }15231524 entries = [];1525 tombstones = new Map();1526 domainIndex = new Map();1527 vaultMode = "real";1528 duressConfigured = false;1529 clearAutoLock();1530}15311532export function isUnlocked(): boolean {1533 return derivedKey !== null;1534}15351536153715381539export async function unlockWithKey(1540 masterKeyHex: string,1541): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1542 try {1543 const masterKeyBytes = unhex(masterKeyHex);1544 const stored = await loadSlot(SLOT_A);1545 if (!stored) return { ok: false, error: "invalid_key" };1546 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1547 if (!payload) return { ok: false, error: "invalid_key" };15481549 derivedKey = masterKeyBytes;1550 vaultMode = "real";1551 entries = payload.entries;1552 tombstones = payload.tombstones;1553 duressConfigured = payload.duressConfigured;1554 buildDomainIndexSync(entries);1555 await loadSettings();1556 touchAutoLock();15571558 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1559 if (!rootKey) {1560 lock();1561 return { ok: false, error: "invalid_key" };1562 }1563 return { ok: true, entries };1564 } catch {1565 return { ok: false, error: "invalid_key" };1566 }1567}156815691570157115721573157415751576export async function exportDuressSlotForAdoption(): Promise<string | null> {1577 if (!(await isDuressEnabled())) return null;1578 const slotB = await loadSlot(SLOT_B);1579 if (!slotB) return null;1580 return JSON.stringify(slotB);1581}15821583158415851586158715881589export async function installAdoptedDuressSlot(json: string): Promise<void> {1590 let parsed: StoredVault;1591 try {1592 const raw = JSON.parse(json) as unknown;1593 if (!hasValidVaultSchema(raw)) {1594 throw new Error("invalid_duress_slot_shape");1595 }1596 parsed = raw;1597 } catch (err) {1598 if (err instanceof SyntaxError) {1599 throw new Error("invalid_duress_slot_shape");1600 }1601 throw err;1602 }1603 await writeSlot(SLOT_B, parsed);1604}16051606160716081609161016111612161316141615161616171618161916201621162216231624export async function unlockFromCodex(params: {1625 slotA: string; 1626 slotB: string | null; 1627 timestamp: number;1628 masterPassphrase: string;1629}): Promise<1630 | { ok: true; entryCount: number }1631 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1632> {1633 let parsedSlotA: StoredVault;1634 let parsedSlotB: StoredVault | null = null;1635 try {1636 const rawA = JSON.parse(params.slotA) as unknown;1637 if (!hasValidVaultSchema(rawA)) {1638 return { ok: false, reason: "invalid_slot_format" };1639 }1640 parsedSlotA = rawA;1641 if (params.slotB !== null) {1642 const rawB = JSON.parse(params.slotB) as unknown;1643 if (!hasValidVaultSchema(rawB)) {1644 return { ok: false, reason: "invalid_slot_format" };1645 }1646 parsedSlotB = rawB;1647 }1648 } catch {1649 return { ok: false, reason: "invalid_slot_format" };1650 }16511652 1653 1654 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1655 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1656 if (!trialPayload) {1657 zeroKey(trialKey);1658 return { ok: false, reason: "wrong_passphrase" };1659 }1660 1661 1662 zeroKey(trialKey);16631664 1665 1666 await takeVaultSnapshot("restore");16671668 await writeSlot(SLOT_A, parsedSlotA);1669 if (parsedSlotB) {1670 await writeSlot(SLOT_B, parsedSlotB);1671 } else {1672 1673 1674 1675 await writeSlot(1676 SLOT_B,1677 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1678 );1679 }1680 await chrome.storage.local.set({1681 peach_last_modified_at: String(params.timestamp),1682 });16831684 1685 1686 const ok = await unlock(params.masterPassphrase);1687 if (!ok) {1688 1689 1690 1691 return { ok: false, reason: "wrong_passphrase" };1692 }1693 return { ok: true, entryCount: getEntries().length };1694}16951696169716981699export function getDerivedKeyHex(): string {1700 if (!derivedKey) throw new Error("vault_not_unlocked");1701 return hex(derivedKey);1702}17031704170517061707170817091710171117121713171417151716171717181719export function getRootKeyHex(): string | null {1720 return rootKey ? hex(rootKey) : null;1721}17221723export function getEntries(): VaultEntry[] {1724 if (!derivedKey) throw new Error("vault_locked");1725 return entries.filter((entry) => !entry.deletedAt);1726}17271728const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;17291730export function getIdentities(): SavedIdentity[] {1731 if (!derivedKey) throw new Error("vault_locked");1732 const active = entries.filter((e) => !e.deletedAt && e.type === "login");17331734 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1735 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();17361737 for (const entry of active) {1738 const domain = extractDomain(entry.url ?? "") ?? "";1739 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;17401741 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1742 const existing = map.get(v);1743 if (!existing || modifiedAt > existing.lastUsed) {1744 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1745 } else {1746 map.set(v, { ...existing, count: existing.count + 1 });1747 }1748 }17491750 const uVal = entry.username?.trim();1751 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);17521753 1754 1755 const eVal = entry.email?.trim();1756 if (eVal) tally(emailMap, eVal);1757 }17581759 const toIdentity = (type: "email" | "username") =>1760 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1761 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1762 });17631764 return [1765 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1766 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1767 ];1768}17691770export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1771 if (!derivedKey) throw new Error("vault_locked");17721773 const now = Date.now();1774 const added: VaultEntry[] = [];1775 for (const draft of drafts) {1776 try {1777 const sanitized = sanitizeEntryDraft(draft);1778 const nextEntry: VaultEntry = {1779 ...sanitized,1780 id: crypto.randomUUID(),1781 createdAt: now,1782 modifiedAt: monotonicEntryTimestamp(),1783 };1784 entries.push(nextEntry);1785 added.push(nextEntry);1786 } catch {1787 1788 }1789 }17901791 if (added.length > 0) {1792 await persistEntries();1793 }17941795 return added;1796}179717981799180018011802180318041805export async function restoreEntries(1806 incoming: VaultEntry[],1807 mode: "replace" | "merge"1808): Promise<{ added: number; updated: number; skipped: number }> {1809 if (!derivedKey) throw new Error("vault_locked");18101811 1812 1813 1814 if (mode === "replace" && incoming.length === 0) {1815 throw new Error("refuse to replace with empty entry set");1816 }18171818 let added = 0;1819 let updated = 0;1820 let skipped = 0;18211822 if (mode === "replace") {1823 entries = [];1824 tombstones = new Map();1825 for (const entry of incoming) {1826 const normalized = sanitizeRestoredEntry(entry);1827 if (!normalized) {1828 skipped += 1;1829 continue;1830 }1831 entries.push(normalized);1832 if (normalized.deletedAt) {1833 tombstones.set(normalized.id, normalized.deletedAt);1834 }1835 added += 1;1836 }1837 } else {1838 const byId = new Map<string, VaultEntry>();1839 for (const e of entries) byId.set(e.id, e);18401841 for (const entry of incoming) {1842 const normalized = sanitizeRestoredEntry(entry);1843 if (!normalized) {1844 skipped += 1;1845 continue;1846 }1847 const existing = byId.get(normalized.id);1848 if (!existing) {1849 entries.push(normalized);1850 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1851 else tombstones.delete(normalized.id);1852 added += 1;1853 } else if (normalized.modifiedAt > existing.modifiedAt) {1854 const idx = entries.findIndex((e) => e.id === existing.id);1855 if (idx !== -1) {1856 entries[idx] = normalized;1857 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1858 else tombstones.delete(normalized.id);1859 updated += 1;1860 }1861 } else {1862 skipped += 1;1863 }1864 }1865 }18661867 await persistEntries("import");1868 return { added, updated, skipped };1869}187018711872187318741875187618771878function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1879 if (!entry || typeof entry !== "object") return null;1880 const title = typeof entry.title === "string" ? entry.title.trim() : "";1881 if (!title) return null;1882 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1883 const now = Date.now();1884 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1885 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;18861887 1888 1889 const normalized = normalizeEntry({1890 ...entry,1891 id,1892 title,1893 type: entry.type ?? "login",1894 createdAt,1895 modifiedAt,1896 });18971898 if (normalized.passkey) {1899 normalized.passkey = sanitizePasskey(normalized.passkey);1900 }1901 if (normalized.canary) {1902 normalized.canary = sanitizeCanary(normalized.canary);1903 }1904 1905 normalized.tags = sanitizeTags(normalized.tags);19061907 1908 1909 if (1910 normalized.type === "login" &&1911 !normalized.passkey &&1912 typeof normalized.password !== "string"1913 ) {1914 return null;1915 }1916 if (entry.passkey && !normalized.passkey) {1917 1918 1919 return null;1920 }19211922 return normalized;1923}1924192519261927192819291930function sanitizePasskey(pk: unknown): PasskeyData | undefined {1931 if (!pk || typeof pk !== "object") return undefined;1932 const r = pk as Record<string, unknown>;1933 if (1934 typeof r.rpId !== "string" || !r.rpId ||1935 typeof r.credentialId !== "string" || !r.credentialId ||1936 typeof r.publicKeyCose !== "string" ||1937 typeof r.userHandle !== "string" ||1938 typeof r.userName !== "string"1939 ) {1940 return undefined;1941 }1942 return {1943 rpId: r.rpId,1944 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1945 credentialId: r.credentialId,1946 publicKeyCose: r.publicKeyCose,1947 privateKeyJwk:1948 r.privateKeyJwk && typeof r.privateKeyJwk === "object"1949 ? (r.privateKeyJwk as JsonWebKey)1950 : undefined,1951 userHandle: r.userHandle,1952 userName: r.userName,1953 userDisplayName:1954 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1955 algorithm: r.algorithm === -8 ? -8 : -7,1956 signCount: typeof r.signCount === "number" ? r.signCount : 0,1957 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1958 backupEligible: r.backupEligible === true,1959 backupState: r.backupState === true,1960 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1961 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1962 transports: Array.isArray(r.transports)1963 ? r.transports.filter((t): t is string => typeof t === "string")1964 : undefined,1965 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1966 };1967}19681969197019711972197319741975197619771978function sanitizeCanary(c: unknown): Canary | undefined {1979 if (!c || typeof c !== "object") return undefined;1980 const r = c as Record<string, unknown>;1981 if (typeof r.webhookUrl !== "string") return undefined;1982 let url: URL;1983 try {1984 url = new URL(r.webhookUrl);1985 } catch {1986 return undefined;1987 }1988 if (url.protocol !== "https:") return undefined;1989 return {1990 webhookUrl: url.toString(),1991 label: typeof r.label === "string" ? r.label : undefined,1992 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,1993 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,1994 };1995}19961997199819992000200120022003function sanitizeTags(tags: unknown): string[] | undefined {2004 if (!Array.isArray(tags)) return undefined;2005 const out: string[] = [];2006 for (const t of tags) {2007 if (typeof t !== "string") continue;2008 const trimmed = t.trim();2009 if (!trimmed) continue;2010 out.push(trimmed.slice(0, 256));2011 if (out.length >= 255) break;2012 }2013 return out.length > 0 ? out : undefined;2014}201520162017201820192020export async function clearVaultEntries(2021 confirmation: "REPLACE_ALL"2022): Promise<void> {2023 if (confirmation !== "REPLACE_ALL") {2024 throw new Error("clearVaultEntries requires explicit confirmation");2025 }2026 if (!derivedKey) throw new Error("vault_locked");2027 for (const entry of entries) {2028 if (!entry.deletedAt) {2029 const stamp = monotonicEntryTimestamp(Math.max(2030 entry.modifiedAt,2031 tombstones.get(entry.id) ?? 0,2032 ));2033 entry.deletedAt = stamp;2034 entry.modifiedAt = stamp;2035 tombstones.set(entry.id, stamp);2036 }2037 }2038 await persistEntries();2039}20402041export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2042 if (!derivedKey) throw new Error("vault_locked");20432044 const sanitized = sanitizeEntryDraft(entry);2045 const now = Date.now();2046 const stamp = monotonicEntryTimestamp();2047 const nextEntry: VaultEntry = {2048 ...sanitized,2049 id: crypto.randomUUID(),2050 createdAt: now,2051 modifiedAt: stamp,2052 2053 2054 passwordChangedAt: sanitized.password ? stamp : undefined,2055 };20562057 2058 2059 2060 const rollback = entries;2061 entries = [...entries, nextEntry];2062 try {2063 await persistEntries();2064 } catch (err) {2065 entries = rollback;2066 throw err;2067 }20682069 2070 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2071 checkBreach(nextEntry.password).then(async (count) => {2072 if (count > 0) {2073 const idx = entries.findIndex((e) => e.id === nextEntry.id);2074 if (idx !== -1) {2075 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2076 await persistEntries();2077 }2078 }2079 });2080 }20812082 fireSyncTrigger("vault_change");20832084 return nextEntry;2085}20862087208820892090209120922093export async function consolidatePasskeyEntries(): Promise<void> {2094 if (!derivedKey) return;2095 const { merged } = mergePasskeyOnlyIntoLogins(entries);2096 if (merged) {2097 await persistEntries();2098 }2099}21002101export async function updateEntry(2102 id: string,2103 patch: Partial<VaultEntry>2104): Promise<void> {2105 if (!derivedKey) throw new Error("vault_locked");21062107 const idx = entries.findIndex((entry) => entry.id === id);2108 if (idx === -1 || entries[idx].deletedAt) {2109 throw new Error("entry_not_found");2110 }21112112 const current = entries[idx];2113 const sanitizedPatch = sanitizeEntryPatch(patch);2114 2115 2116 2117 const passwordChanged =2118 "password" in patch &&2119 typeof sanitizedPatch.password === "string" &&2120 sanitizedPatch.password !== current.password;2121 const stamp = monotonicEntryTimestamp(current.modifiedAt);2122 2123 2124 2125 2126 2127 2128 2129 const priorPassword = current.password;2130 const shouldCapture =2131 passwordChanged &&2132 typeof priorPassword === "string" &&2133 priorPassword.length > 0 &&2134 current.passwordHistory?.[0]?.password !== priorPassword;2135 const nextHistory = shouldCapture2136 ? [2137 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2138 ...(current.passwordHistory ?? []),2139 ].slice(0, PASSWORD_HISTORY_MAX)2140 : current.passwordHistory;2141 entries[idx] = {2142 ...current,2143 ...sanitizedPatch,2144 modifiedAt: stamp,2145 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2146 };2147214821492150 2151 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2152 entries[idx] = current;2153 throw new Error("card number is required for card entries");2154 }21552156 2157 2158 2159 2160 try {2161 await persistEntries();2162 } catch (err) {2163 entries[idx] = current;2164 throw err;2165 }21662167 2168 2169 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2170 entries[idx] = { ...entries[idx], breach: undefined };2171 await persistEntries();2172 const password = entries[idx].password;2173 if (password) {2174 checkBreach(password).then(async (count) => {2175 const i = entries.findIndex((e) => e.id === id);2176 if (i !== -1 && count > 0) {2177 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2178 await persistEntries();2179 }2180 });2181 }2182 }21832184 fireSyncTrigger("vault_change");2185}21862187export async function deleteEntry(id: string): Promise<void> {2188 if (!derivedKey) throw new Error("vault_locked");21892190 const entry = entries.find((item) => item.id === id);2191 if (!entry || entry.deletedAt) {2192 throw new Error("entry_not_found");2193 }21942195 2196 2197 2198 const previousModifiedAt = entry.modifiedAt;2199 const previousTombstone = tombstones.get(id);2200 const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2201 entry.deletedAt = stamp;2202 entry.modifiedAt = stamp;2203 tombstones.set(id, stamp);2204 2205 2206 try {2207 await persistEntries();2208 } catch (err) {2209 delete entry.deletedAt;2210 entry.modifiedAt = previousModifiedAt;2211 if (previousTombstone === undefined) tombstones.delete(id);2212 else tombstones.set(id, previousTombstone);2213 throw err;2214 }2215 fireSyncTrigger("vault_change");2216}22172218export function getDeletedEntries(): VaultEntry[] {2219 if (!derivedKey) return [];2220 return entries.filter((e) => !!e.deletedAt);2221}222222232224export function getTombstoneManifest(): Record<string, number> {2225 if (!derivedKey) return {};2226 return Object.fromEntries(tombstones);2227}2228222922302231223222332234export async function getVaultVersion(): Promise<number> {2235 try {2236 const raw = await chrome.storage.local.get("peach_last_modified_at");2237 const v = (raw as Record<string, unknown>).peach_last_modified_at;2238 if (typeof v === "number") return v;2239 if (typeof v === "string" && v !== "") {2240 const n = Number.parseInt(v, 10);2241 return Number.isFinite(n) ? n : 0;2242 }2243 return 0;2244 } catch {2245 return 0;2246 }2247}2248224922502251225222532254225522562257225822592260export async function applyMergedRemoteEntries(2261 remoteEntries: VaultEntry[],2262 remoteTombstones: Record<string, number>,2263 _context: { deviceName?: string } = {},2264): Promise<{ received: number; applied: number; conflicts: number }> {2265 2266 2267 2268 2269 2270 2271 if (!derivedKey) throw new Error("vault_locked");22722273 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2274 let applied = 0;2275 let conflicts = 0;22762277 const byId = new Map<string, VaultEntry>();2278 for (const e of entries) byId.set(e.id, e);22792280 const mergeNow = Date.now();2281 const remoteTombstoneTimes = new Map<string, number>();2282 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2283 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2284 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2285 }2286 }22872288 for (const remote of remoteEntries) {2289 const sanitized = sanitizeRestoredEntry(remote);2290 if (!sanitized) continue;2291 2292 2293 2294 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2295 if (typeof sanitized.deletedAt === "number") {2296 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2297 }2298 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2299 const localTombstoneTs = tombstones.get(sanitized.id);2300 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2301 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2302 continue;2303 }2304 const local = byId.get(sanitized.id);2305 if (!local) {2306 entries.push(sanitized);2307 byId.set(sanitized.id, sanitized);2308 tombstones.delete(sanitized.id);2309 applied += 1;2310 } else if (sanitized.modifiedAt > local.modifiedAt) {2311 const idx = entries.findIndex((e) => e.id === local.id);2312 if (idx !== -1) {2313 entries[idx] = sanitized;2314 byId.set(sanitized.id, sanitized);2315 tombstones.delete(sanitized.id);2316 applied += 1;2317 if (!local.deletedAt) conflicts += 1;2318 }2319 }2320 }23212322 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2323 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2324 2325 const deletedAt = remoteTombstoneTimes.get(id)!;2326 const knownTombstone = tombstones.get(id);2327 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2328 const local = byId.get(id);2329 if (!local) {2330 tombstones.set(id, deletedAt);2331 applied += 1;2332 continue;2333 }2334 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2335 if (deletedAt < local.modifiedAt) continue;2336 const idx = entries.findIndex((e) => e.id === id);2337 if (idx !== -1) {2338 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2339 tombstones.set(id, deletedAt);2340 applied += 1;2341 }2342 }23432344 if (applied > 0) {2345 await persistEntries("sync_received");2346 buildDomainIndexSync(entries);2347 }2348 return { received, applied, conflicts };2349}23502351export async function restoreEntry(id: string): Promise<void> {2352 if (!derivedKey) throw new Error("vault_locked");2353 const entry = entries.find((e) => e.id === id && e.deletedAt);2354 if (!entry) throw new Error("deleted entry not found");2355 const previousDeletedAt = entry.deletedAt;2356 const previousModifiedAt = entry.modifiedAt;2357 const previousTombstone = tombstones.get(id);2358 delete entry.deletedAt;2359 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2360 entry.modifiedAt,2361 previousTombstone ?? 0,2362 ));2363 tombstones.delete(id);2364 try {2365 await persistEntries();2366 } catch (err) {2367 entry.deletedAt = previousDeletedAt;2368 entry.modifiedAt = previousModifiedAt;2369 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2370 throw err;2371 }2372 buildDomainIndexSync(entries);2373}23742375export async function purgeEntry(id: string): Promise<void> {2376 if (!derivedKey) throw new Error("vault_locked");2377 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2378 if (idx === -1) throw new Error("deleted entry not found");2379 const entry = entries[idx]!;2380 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2381 entries.splice(idx, 1);2382 await persistEntries();2383}238423852386export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2387 if (!derivedKey || retentionMs === 0) return 0;2388 const cutoff = Date.now() - retentionMs;2389 const before = entries.length;2390 for (const entry of entries) {2391 if (entry.deletedAt && entry.deletedAt < cutoff) {2392 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2393 }2394 }2395 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2396 const purged = before - entries.length;2397 if (purged > 0) await persistEntries();2398 return purged;2399}24002401export function queryDomain(domain: string): VaultEntry[] {2402 return domainIndex.get(domain) ?? [];2403}24042405export function getEntryById(id: string): VaultEntry | undefined {2406 return entries.find((entry) => entry.id === id && !entry.deletedAt);2407}24082409async function persistSlotA(2410 reason: VaultSnapshot["reason"] = "local_save",2411): Promise<StoredVault> {2412 if (!derivedKey) throw new Error("vault_locked");24132414 const slotA = await loadSlot(SLOT_A);2415 if (!slotA) throw new Error("missing slot A");24162417 2418 2419 await takeVaultSnapshot(reason);24202421 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2422 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");24232424 2425 const nextSlotA: StoredVault = {2426 ...slotA,2427 nonce,2428 ciphertext,2429 tag,2430 2431 };2432 await writeSlot(SLOT_A, nextSlotA);24332434 2435 2436 2437 2438 2439 2440 try {2441 2442 2443 2444 if (!duressConfigured) {2445 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2446 }2447 2448 2449 2450 2451 2452 if (reason !== "sync_received") {2453 await stampLocalVaultMutation();2454 }2455 } catch (err) {2456 2457 2458 await writeSlot(SLOT_A, slotA).catch(() => {});2459 throw err;2460 }24612462 return nextSlotA;2463}24642465async function persistSlotB(): Promise<void> {2466 if (!derivedKey) throw new Error("vault_locked");24672468 const slotB = await loadSlot(SLOT_B);2469 if (!slotB) throw new Error("missing slot B");24702471 2472 2473 2474 2475 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2476 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");24772478 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2479 2480 2481 2482 try {2483 await stampLocalVaultMutation();2484 } catch (err) {2485 await writeSlot(SLOT_B, slotB).catch(() => {});2486 throw err;2487 }2488}24892490async function persistEntries(2491 reason: VaultSnapshot["reason"] = "local_save",2492): Promise<void> {2493 if (!derivedKey) throw new Error("vault_locked");24942495 2496 2497 const previousTombstones = tombstones;2498 tombstones = new Map(tombstones);2499 reconcileTombstones();2500 try {2501 if (vaultMode === "decoy") {2502 await persistSlotB();2503 } else {2504 await persistSlotA(reason);2505 }2506 } catch (err) {2507 tombstones = previousTombstones;2508 throw err;2509 }2510 buildDomainIndexSync(entries);2511 await refreshLockedSyncState();2512}2513251425152516251725182519function allowHttpUrl(url: string | undefined): string {2520 const trimmed = url?.trim() ?? "";2521 if (!trimmed) return "";2522 try {2523 const parsed = new URL(trimmed);2524 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2525 } catch {2526 2527 }2528 return trimmed;2529}25302531function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2532 if (!Array.isArray(values)) return undefined;2533 const seen = new Set<string>();2534 const cleaned: string[] = [];2535 for (const value of values) {2536 const trimmed = typeof value === "string" ? value.trim() : "";2537 if (!trimmed) continue;2538 if (!allowHttpUrl(trimmed)) continue;2539 const key = trimmed.toLowerCase();2540 if (seen.has(key)) continue;2541 seen.add(key);2542 cleaned.push(trimmed);2543 }2544 return cleaned.length > 0 ? cleaned : undefined;2545}25462547function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2548 const title = entry.title.trim();2549 const type: VaultEntry["type"] = entry.type ?? "login";25502551 if (!title) throw new Error("title is required");25522553 if (entry.passkey) {2554 const p = entry.passkey;2555 if (2556 !p.rpId ||2557 !p.credentialId ||2558 !p.publicKeyCose ||2559 !p.userHandle ||2560 !p.userName ||2561 (p.algorithm !== -7 && p.algorithm !== -8)2562 ) {2563 throw new Error("invalid passkey credential");2564 }2565 }2566 if (type === "card") {2567 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2568 throw new Error("cardholder name and card number are required");2569 }2570 }25712572 return {2573 type,2574 title,2575 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2576 email: type === "login" ? entry.email?.trim() || undefined : undefined,2577 password: type === "login" ? entry.password : undefined,2578 url: type === "login" ? allowHttpUrl(entry.url) : undefined,2579 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2580 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2581 2582 2583 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2584 totpDigits: type === "login" ? entry.totpDigits : undefined,2585 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2586 notes: entry.notes?.trim() || undefined,2587 breach: type === "login" ? entry.breach : undefined,2588 2589 2590 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2591 tags: sanitizeTags(entry.tags),2592 passkey: entry.passkey,2593 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2594 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2595 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2596 expYear: type === "card" ? entry.expYear?.trim() : undefined,2597 cvc: type === "card" ? entry.cvc?.trim() : undefined,2598 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2599 network: type === "card" ? entry.network : undefined,2600 last4: type === "card" ? entry.last4?.trim() : undefined,2601 };2602}26032604function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2605 const nextPatch: Partial<VaultEntry> = {};26062607 if ("type" in patch) {2608 nextPatch.type = patch.type;2609 }26102611 if ("title" in patch) {2612 const title = patch.title?.trim() ?? "";2613 if (!title) throw new Error("title is required");2614 nextPatch.title = title;2615 }26162617 if ("url" in patch) {2618 nextPatch.url = allowHttpUrl(patch.url);2619 }26202621 if ("additionalUrls" in patch) {2622 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2623 const seen = new Set<string>();2624 const cleaned: string[] = [];2625 for (const value of incoming) {2626 const trimmed = typeof value === "string" ? value.trim() : "";2627 if (!trimmed || !allowHttpUrl(trimmed)) continue;2628 const key = trimmed.toLowerCase();2629 if (seen.has(key)) continue;2630 seen.add(key);2631 cleaned.push(trimmed);2632 }2633 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2634 }26352636 if ("username" in patch) {2637 nextPatch.username = patch.username?.trim() ?? "";2638 }26392640 if ("email" in patch) {2641 nextPatch.email = patch.email?.trim() || undefined;2642 }26432644 if ("password" in patch) {2645 const password = patch.password ?? "";2646 2647 nextPatch.password = password || undefined;2648 }26492650 if ("totp" in patch) {2651 nextPatch.totp = patch.totp?.trim() || undefined;2652 }26532654 if ("notes" in patch) {2655 nextPatch.notes = patch.notes?.trim() || undefined;2656 }26572658 if ("breach" in patch) {2659 nextPatch.breach = patch.breach;2660 }26612662 if ("canary" in patch) {2663 2664 2665 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2666 }26672668 if ("passkey" in patch) {2669 2670 2671 if (patch.passkey === undefined || patch.passkey === null) {2672 nextPatch.passkey = undefined;2673 } else {2674 const sanitized = sanitizePasskey(patch.passkey);2675 if (!sanitized) throw new Error("invalid passkey credential");2676 nextPatch.passkey = sanitized;2677 }2678 }26792680 if ("tags" in patch) {2681 2682 nextPatch.tags = sanitizeTags(patch.tags);2683 }26842685 if ("cardholderName" in patch) {2686 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2687 }2688 if ("cardNumber" in patch) {2689 const n = patch.cardNumber ?? "";2690 nextPatch.cardNumber = n || undefined;2691 }2692 if ("expMonth" in patch) {2693 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2694 }2695 if ("expYear" in patch) {2696 nextPatch.expYear = patch.expYear?.trim() || undefined;2697 }2698 if ("cvc" in patch) {2699 nextPatch.cvc = patch.cvc?.trim() || undefined;2700 }2701 if ("cardNickname" in patch) {2702 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2703 }2704 if ("network" in patch) {2705 nextPatch.network = patch.network;2706 }2707 if ("last4" in patch) {2708 nextPatch.last4 = patch.last4?.trim() || undefined;2709 }27102711 return nextPatch;2712}27132714function normalizeEntry(entry: VaultEntry): VaultEntry {2715 const base: VaultEntry = {2716 id: entry.id,2717 type: entry.type,2718 title: entry.title,2719 username: entry.username,2720 email: entry.email,2721 password: entry.password,2722 url: entry.url,2723 additionalUrls:2724 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2725 ? entry.additionalUrls2726 : undefined,2727 totp: entry.totp,2728 2729 totpAlgorithm: entry.totpAlgorithm,2730 totpDigits: entry.totpDigits,2731 totpPeriod: entry.totpPeriod,2732 notes: entry.notes,2733 breach: entry.breach,2734 2735 2736 2737 passwordChangedAt:2738 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2739 2740 2741 2742 passwordHistory:2743 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2744 ? entry.passwordHistory2745 : undefined,2746 canary: entry.canary,2747 passkey: entry.passkey,2748 createdAt: entry.createdAt,2749 modifiedAt: entry.modifiedAt,2750 deletedAt: entry.deletedAt,2751 tags: entry.tags,2752 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2753 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2754 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2755 expYear: "expYear" in entry ? entry.expYear : undefined,2756 cvc: "cvc" in entry ? entry.cvc : undefined,2757 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2758 network: "network" in entry ? entry.network : undefined,2759 last4: "last4" in entry ? entry.last4 : undefined,2760 };27612762 return base;2763}2764276527662767276827692770function entryDupeKey(e: VaultEntry): string {2771 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2772 2773 2774 2775 2776 if (e.passkey) {2777 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2778 }2779 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2780}278127822783export function countDuplicates(): number {2784 const best = new Map<string, VaultEntry>();2785 let dupeCount = 0;2786 for (const e of entries.filter((e) => !e.deletedAt)) {2787 const key = entryDupeKey(e);2788 const existing = best.get(key);2789 if (!existing) {2790 best.set(key, e);2791 } else {2792 dupeCount++;2793 2794 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2795 best.set(key, e);2796 }2797 }2798 }2799 return dupeCount;2800}280128022803280428052806export async function deduplicateEntries(): Promise<number> {2807 if (!derivedKey) throw new Error("vault_locked");2808 2809 2810 if (vaultMode === "decoy") {2811 throw new Error("deduplicate disabled in decoy mode");2812 }28132814 const best = new Map<string, VaultEntry>();2815 for (const e of entries.filter((en) => !en.deletedAt)) {2816 const key = entryDupeKey(e);2817 const existing = best.get(key);2818 if (!existing) {2819 best.set(key, e);2820 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2821 best.set(key, e);2822 }2823 }28242825 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2826 let removed = 0;2827 entries = entries.map((e) => {2828 if (!e.deletedAt && !keepIds.has(e.id)) {2829 removed++;2830 const deletedAt = monotonicEntryTimestamp(Math.max(2831 e.modifiedAt,2832 tombstones.get(e.id) ?? 0,2833 ));2834 tombstones.set(e.id, deletedAt);2835 return { ...e, deletedAt };2836 }2837 return e;2838 });28392840 if (removed > 0) await persistEntries();2841 return removed;2842}284328442845284628472848284928502851const HISTORY_KEY = "peach_vault_history";2852const HISTORY_MAX = 5;285328542855285628572858function bucketEntryCount(n: number): number {2859 if (n <= 0) return 0;2860 if (n <= 10) return 10;2861 if (n <= 25) return 25;2862 if (n <= 50) return 50;2863 if (n <= 100) return 100;2864 if (n <= 250) return 250;2865 if (n <= 500) return 500;2866 return Math.ceil(n / 500) * 500;2867}28682869287028712872287328742875export async function takeVaultSnapshot(2876 reason: VaultSnapshot["reason"],2877): Promise<void> {2878 try {2879 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2880 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2881 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];28822883 2884 2885 2886 if (!slotA) return;28872888 const vault_a = JSON.stringify(slotA);2889 const vault_b = JSON.stringify(slotB ?? slotA);28902891 2892 2893 2894 2895 2896 2897 let entryCount: number | null = null;2898 if (derivedKey && vaultMode === "real") {2899 try {2900 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);2901 } catch {2902 entryCount = null;2903 }2904 }29052906 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2907 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2908 ? existingRaw2909 : { snapshots: [] };29102911 2912 2913 2914 const latest = history.snapshots[0];2915 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2916 return;2917 }29182919 const snapshot: VaultSnapshot = {2920 id: crypto.randomUUID(),2921 savedAt: Date.now(),2922 reason,2923 vault_a,2924 vault_b,2925 entryCount,2926 };29272928 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2929 await chrome.storage.local.set({ [HISTORY_KEY]: history });2930 } catch (err) {2931 2932 2933 2934 2935 console.warn('[Peach] takeVaultSnapshot failed:', err);2936 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2937 try {2938 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2939 } catch {2940 2941 }2942 }2943 }2944}294529462947export async function clearVaultHistory(): Promise<void> {2948 try {2949 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2950 } catch {2951 2952 }2953}295429552956export async function getVaultHistory(): Promise<VaultSnapshot[]> {2957 const raw = await chrome.storage.local.get(HISTORY_KEY);2958 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2959 if (!history || !Array.isArray(history.snapshots)) return [];2960 return history.snapshots;2961}29622963296429652966296729682969export async function restoreVaultSnapshot(2970 snapshotId: string,2971): Promise<{ ok: true } | { ok: false; error: string }> {2972 const history = await getVaultHistory();2973 const snapshot = history.find((s) => s.id === snapshotId);2974 if (!snapshot) return { ok: false, error: "snapshot_not_found" };29752976 2977 2978 2979 await takeVaultSnapshot("restore");29802981 let restoredA: StoredVault;2982 let restoredB: StoredVault;2983 try {2984 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2985 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2986 } catch {2987 return { ok: false, error: "snapshot_corrupt" };2988 }29892990 await writeSlot(SLOT_A, restoredA);2991 await writeSlot(SLOT_B, restoredB);2992 await stampLocalVaultMutation();29932994 2995 2996 2997 lock();29982999 return { ok: true };3000}300130023003300430053006300730083009301030113012301330143015export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3016 if (!derivedKey || vaultMode !== "real") {3017 return { ok: false, error: "vault_locked" };3018 }3019 const slotA = await loadSlot(SLOT_A);3020 if (!slotA) return { ok: false, error: "missing_slot_a" };30213022 3023 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3024 await generateAndWrapRootKey();30253026 3027 const newRootKey = unhex(rawRootKey);3028 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);30293030 3031 3032 3033 3034 await takeVaultSnapshot("local_save");3035 const nextSlotA: StoredVault = {3036 ...slotA,3037 recovery_salt: recoverySalt,3038 recovery_wrapped_root_key: wrappedRootKey,3039 recovery_nonce: wrappedNonce,3040 recovery_tag: wrappedTag,3041 ...pwRoot,3042 };3043 await writeSlot(SLOT_A, nextSlotA);30443045 3046 if (rootKey) zeroKey(rootKey);3047 rootKey = newRootKey;30483049 3050 await clearPairedDevices();30513052 3053 await setRecoveryConfirmPending(); 30543055 await stampLocalVaultMutation();3056 return { ok: true };3057}30583059306030613062306330643065export async function changePassword(3066 currentPassword: string,3067 newPassword: string3068): Promise<boolean> {3069 if (!derivedKey || vaultMode !== "real") return false;30703071 3072 const valid = await verifyMainPassword(currentPassword);3073 if (!valid) return false;30743075 const slotA = await loadSlot(SLOT_A);3076 if (!slotA) return false;3077 if (!rootKey) return false;30783079 3080 const newSalt = generateSalt();3081 const newKey = await deriveKey(newPassword, newSalt);30823083 3084 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3085 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");30863087 3088 3089 3090 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);30913092 const finalSlotA: StoredVault = {3093 ...slotA,3094 salt: hex(newSalt),3095 nonce,3096 ciphertext,3097 tag,3098 ...pwRoot,3099 };31003101 3102 3103 3104 await takeVaultSnapshot("local_save");31053106 3107 3108 3109 3110 await writeSlot(SLOT_A, finalSlotA);31113112 3113 3114 3115 zeroKey(derivedKey);3116 derivedKey = newKey;31173118 3119 3120 3121 3122 void saveSessionKey(newKey, "real");31233124 3125 3126 3127 if (!duressConfigured) {3128 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3129 }3130 await clearPinUnlockStorage();3131 await stampLocalVaultMutation();31323133 return true;3134}