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 = naturalizeRestoredId(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 = naturalizeRestoredId(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}187018711872187318741875187618771878187918801881188218831884188518861887const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;18881889function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {1890 if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;1891 return { ...entry, id: crypto.randomUUID() };1892}189318941895189618971898189919001901function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1902 if (!entry || typeof entry !== "object") return null;1903 const title = typeof entry.title === "string" ? entry.title.trim() : "";1904 if (!title) return null;1905 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1906 const now = Date.now();1907 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1908 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;19091910 1911 1912 const normalized = normalizeEntry({1913 ...entry,1914 id,1915 title,1916 type: entry.type ?? "login",1917 createdAt,1918 modifiedAt,1919 });19201921 if (normalized.passkey) {1922 normalized.passkey = sanitizePasskey(normalized.passkey);1923 }1924 if (normalized.canary) {1925 normalized.canary = sanitizeCanary(normalized.canary);1926 }1927 1928 normalized.tags = sanitizeTags(normalized.tags);19291930 1931 1932 if (1933 normalized.type === "login" &&1934 !normalized.passkey &&1935 typeof normalized.password !== "string"1936 ) {1937 return null;1938 }1939 if (entry.passkey && !normalized.passkey) {1940 1941 1942 return null;1943 }19441945 return normalized;1946}1947194819491950195119521953function sanitizePasskey(pk: unknown): PasskeyData | undefined {1954 if (!pk || typeof pk !== "object") return undefined;1955 const r = pk as Record<string, unknown>;1956 if (1957 typeof r.rpId !== "string" || !r.rpId ||1958 typeof r.credentialId !== "string" || !r.credentialId ||1959 typeof r.publicKeyCose !== "string" ||1960 typeof r.userHandle !== "string" ||1961 typeof r.userName !== "string"1962 ) {1963 return undefined;1964 }1965 return {1966 rpId: r.rpId,1967 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1968 credentialId: r.credentialId,1969 publicKeyCose: r.publicKeyCose,1970 privateKeyJwk:1971 r.privateKeyJwk && typeof r.privateKeyJwk === "object"1972 ? (r.privateKeyJwk as JsonWebKey)1973 : undefined,1974 userHandle: r.userHandle,1975 userName: r.userName,1976 userDisplayName:1977 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1978 algorithm: r.algorithm === -8 ? -8 : -7,1979 signCount: typeof r.signCount === "number" ? r.signCount : 0,1980 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1981 backupEligible: r.backupEligible === true,1982 backupState: r.backupState === true,1983 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1984 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1985 transports: Array.isArray(r.transports)1986 ? r.transports.filter((t): t is string => typeof t === "string")1987 : undefined,1988 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1989 };1990}19911992199319941995199619971998199920002001function sanitizeCanary(c: unknown): Canary | undefined {2002 if (!c || typeof c !== "object") return undefined;2003 const r = c as Record<string, unknown>;2004 if (typeof r.webhookUrl !== "string") return undefined;2005 let url: URL;2006 try {2007 url = new URL(r.webhookUrl);2008 } catch {2009 return undefined;2010 }2011 if (url.protocol !== "https:") return undefined;2012 return {2013 webhookUrl: url.toString(),2014 label: typeof r.label === "string" ? r.label : undefined,2015 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2016 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2017 };2018}20192020202120222023202420252026function sanitizeTags(tags: unknown): string[] | undefined {2027 if (!Array.isArray(tags)) return undefined;2028 const out: string[] = [];2029 for (const t of tags) {2030 if (typeof t !== "string") continue;2031 const trimmed = t.trim();2032 if (!trimmed) continue;2033 out.push(trimmed.slice(0, 256));2034 if (out.length >= 255) break;2035 }2036 return out.length > 0 ? out : undefined;2037}203820392040204120422043export async function clearVaultEntries(2044 confirmation: "REPLACE_ALL"2045): Promise<void> {2046 if (confirmation !== "REPLACE_ALL") {2047 throw new Error("clearVaultEntries requires explicit confirmation");2048 }2049 if (!derivedKey) throw new Error("vault_locked");2050 for (const entry of entries) {2051 if (!entry.deletedAt) {2052 const stamp = monotonicEntryTimestamp(Math.max(2053 entry.modifiedAt,2054 tombstones.get(entry.id) ?? 0,2055 ));2056 entry.deletedAt = stamp;2057 entry.modifiedAt = stamp;2058 tombstones.set(entry.id, stamp);2059 }2060 }2061 await persistEntries();2062}20632064export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2065 if (!derivedKey) throw new Error("vault_locked");20662067 const sanitized = sanitizeEntryDraft(entry);2068 const now = Date.now();2069 const stamp = monotonicEntryTimestamp();2070 const nextEntry: VaultEntry = {2071 ...sanitized,2072 id: crypto.randomUUID(),2073 createdAt: now,2074 modifiedAt: stamp,2075 2076 2077 passwordChangedAt: sanitized.password ? stamp : undefined,2078 };20792080 2081 2082 2083 const rollback = entries;2084 entries = [...entries, nextEntry];2085 try {2086 await persistEntries();2087 } catch (err) {2088 entries = rollback;2089 throw err;2090 }20912092 2093 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2094 checkBreach(nextEntry.password).then(async (count) => {2095 if (count > 0) {2096 const idx = entries.findIndex((e) => e.id === nextEntry.id);2097 if (idx !== -1) {2098 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2099 await persistEntries();2100 }2101 }2102 });2103 }21042105 fireSyncTrigger("vault_change");21062107 return nextEntry;2108}21092110211121122113211421152116export async function consolidatePasskeyEntries(): Promise<void> {2117 if (!derivedKey) return;2118 const { merged } = mergePasskeyOnlyIntoLogins(entries);2119 if (merged) {2120 await persistEntries();2121 }2122}21232124export async function updateEntry(2125 id: string,2126 patch: Partial<VaultEntry>2127): Promise<void> {2128 if (!derivedKey) throw new Error("vault_locked");21292130 const idx = entries.findIndex((entry) => entry.id === id);2131 if (idx === -1 || entries[idx].deletedAt) {2132 throw new Error("entry_not_found");2133 }21342135 const current = entries[idx];2136 const sanitizedPatch = sanitizeEntryPatch(patch);2137 2138 2139 2140 const passwordChanged =2141 "password" in patch &&2142 typeof sanitizedPatch.password === "string" &&2143 sanitizedPatch.password !== current.password;2144 const stamp = monotonicEntryTimestamp(current.modifiedAt);2145 2146 2147 2148 2149 2150 2151 2152 const priorPassword = current.password;2153 const shouldCapture =2154 passwordChanged &&2155 typeof priorPassword === "string" &&2156 priorPassword.length > 0 &&2157 current.passwordHistory?.[0]?.password !== priorPassword;2158 const nextHistory = shouldCapture2159 ? [2160 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2161 ...(current.passwordHistory ?? []),2162 ].slice(0, PASSWORD_HISTORY_MAX)2163 : current.passwordHistory;2164 entries[idx] = {2165 ...current,2166 ...sanitizedPatch,2167 modifiedAt: stamp,2168 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2169 };2170217121722173 2174 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2175 entries[idx] = current;2176 throw new Error("card number is required for card entries");2177 }21782179 2180 2181 2182 2183 try {2184 await persistEntries();2185 } catch (err) {2186 entries[idx] = current;2187 throw err;2188 }21892190 2191 2192 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2193 entries[idx] = { ...entries[idx], breach: undefined };2194 await persistEntries();2195 const password = entries[idx].password;2196 if (password) {2197 checkBreach(password).then(async (count) => {2198 const i = entries.findIndex((e) => e.id === id);2199 if (i !== -1 && count > 0) {2200 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2201 await persistEntries();2202 }2203 });2204 }2205 }22062207 fireSyncTrigger("vault_change");2208}22092210export async function deleteEntry(id: string): Promise<void> {2211 if (!derivedKey) throw new Error("vault_locked");22122213 const entry = entries.find((item) => item.id === id);2214 if (!entry || entry.deletedAt) {2215 throw new Error("entry_not_found");2216 }22172218 2219 2220 2221 const previousModifiedAt = entry.modifiedAt;2222 const previousTombstone = tombstones.get(id);2223 const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2224 entry.deletedAt = stamp;2225 entry.modifiedAt = stamp;2226 tombstones.set(id, stamp);2227 2228 2229 try {2230 await persistEntries();2231 } catch (err) {2232 delete entry.deletedAt;2233 entry.modifiedAt = previousModifiedAt;2234 if (previousTombstone === undefined) tombstones.delete(id);2235 else tombstones.set(id, previousTombstone);2236 throw err;2237 }2238 fireSyncTrigger("vault_change");2239}22402241export function getDeletedEntries(): VaultEntry[] {2242 if (!derivedKey) return [];2243 return entries.filter((e) => !!e.deletedAt);2244}224522462247export function getTombstoneManifest(): Record<string, number> {2248 if (!derivedKey) return {};2249 return Object.fromEntries(tombstones);2250}2251225222532254225522562257export async function getVaultVersion(): Promise<number> {2258 try {2259 const raw = await chrome.storage.local.get("peach_last_modified_at");2260 const v = (raw as Record<string, unknown>).peach_last_modified_at;2261 if (typeof v === "number") return v;2262 if (typeof v === "string" && v !== "") {2263 const n = Number.parseInt(v, 10);2264 return Number.isFinite(n) ? n : 0;2265 }2266 return 0;2267 } catch {2268 return 0;2269 }2270}2271227222732274227522762277227822792280228122822283export async function applyMergedRemoteEntries(2284 remoteEntries: VaultEntry[],2285 remoteTombstones: Record<string, number>,2286 _context: { deviceName?: string } = {},2287): Promise<{ received: number; applied: number; conflicts: number }> {2288 2289 2290 2291 2292 2293 2294 if (!derivedKey) throw new Error("vault_locked");22952296 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2297 let applied = 0;2298 let conflicts = 0;22992300 const byId = new Map<string, VaultEntry>();2301 for (const e of entries) byId.set(e.id, e);23022303 const mergeNow = Date.now();2304 const remoteTombstoneTimes = new Map<string, number>();2305 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2306 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2307 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2308 }2309 }23102311 for (const remote of remoteEntries) {2312 const sanitized = sanitizeRestoredEntry(remote);2313 if (!sanitized) continue;2314 2315 2316 2317 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2318 if (typeof sanitized.deletedAt === "number") {2319 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2320 }2321 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2322 const localTombstoneTs = tombstones.get(sanitized.id);2323 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2324 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2325 continue;2326 }2327 const local = byId.get(sanitized.id);2328 if (!local) {2329 entries.push(sanitized);2330 byId.set(sanitized.id, sanitized);2331 tombstones.delete(sanitized.id);2332 applied += 1;2333 } else if (sanitized.modifiedAt > local.modifiedAt) {2334 const idx = entries.findIndex((e) => e.id === local.id);2335 if (idx !== -1) {2336 entries[idx] = sanitized;2337 byId.set(sanitized.id, sanitized);2338 tombstones.delete(sanitized.id);2339 applied += 1;2340 if (!local.deletedAt) conflicts += 1;2341 }2342 }2343 }23442345 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2346 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2347 2348 const deletedAt = remoteTombstoneTimes.get(id)!;2349 const knownTombstone = tombstones.get(id);2350 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2351 const local = byId.get(id);2352 if (!local) {2353 tombstones.set(id, deletedAt);2354 applied += 1;2355 continue;2356 }2357 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2358 if (deletedAt < local.modifiedAt) continue;2359 const idx = entries.findIndex((e) => e.id === id);2360 if (idx !== -1) {2361 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2362 tombstones.set(id, deletedAt);2363 applied += 1;2364 }2365 }23662367 if (applied > 0) {2368 await persistEntries("sync_received");2369 buildDomainIndexSync(entries);2370 }2371 return { received, applied, conflicts };2372}23732374export async function restoreEntry(id: string): Promise<void> {2375 if (!derivedKey) throw new Error("vault_locked");2376 const entry = entries.find((e) => e.id === id && e.deletedAt);2377 if (!entry) throw new Error("deleted entry not found");2378 const previousDeletedAt = entry.deletedAt;2379 const previousModifiedAt = entry.modifiedAt;2380 const previousTombstone = tombstones.get(id);2381 delete entry.deletedAt;2382 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2383 entry.modifiedAt,2384 previousTombstone ?? 0,2385 ));2386 tombstones.delete(id);2387 try {2388 await persistEntries();2389 } catch (err) {2390 entry.deletedAt = previousDeletedAt;2391 entry.modifiedAt = previousModifiedAt;2392 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2393 throw err;2394 }2395 buildDomainIndexSync(entries);2396}23972398export async function purgeEntry(id: string): Promise<void> {2399 if (!derivedKey) throw new Error("vault_locked");2400 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2401 if (idx === -1) throw new Error("deleted entry not found");2402 const entry = entries[idx]!;2403 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2404 entries.splice(idx, 1);2405 await persistEntries();2406}240724082409export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2410 if (!derivedKey || retentionMs === 0) return 0;2411 const cutoff = Date.now() - retentionMs;2412 const before = entries.length;2413 for (const entry of entries) {2414 if (entry.deletedAt && entry.deletedAt < cutoff) {2415 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2416 }2417 }2418 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2419 const purged = before - entries.length;2420 if (purged > 0) await persistEntries();2421 return purged;2422}24232424export function queryDomain(domain: string): VaultEntry[] {2425 return domainIndex.get(domain) ?? [];2426}24272428export function getEntryById(id: string): VaultEntry | undefined {2429 return entries.find((entry) => entry.id === id && !entry.deletedAt);2430}24312432async function persistSlotA(2433 reason: VaultSnapshot["reason"] = "local_save",2434): Promise<StoredVault> {2435 if (!derivedKey) throw new Error("vault_locked");24362437 const slotA = await loadSlot(SLOT_A);2438 if (!slotA) throw new Error("missing slot A");24392440 2441 2442 await takeVaultSnapshot(reason);24432444 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2445 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");24462447 2448 const nextSlotA: StoredVault = {2449 ...slotA,2450 nonce,2451 ciphertext,2452 tag,2453 2454 };2455 await writeSlot(SLOT_A, nextSlotA);24562457 2458 2459 2460 2461 2462 2463 try {2464 2465 2466 2467 if (!duressConfigured) {2468 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2469 }2470 2471 2472 2473 2474 2475 if (reason !== "sync_received") {2476 await stampLocalVaultMutation();2477 }2478 } catch (err) {2479 2480 2481 await writeSlot(SLOT_A, slotA).catch(() => {});2482 throw err;2483 }24842485 return nextSlotA;2486}24872488async function persistSlotB(): Promise<void> {2489 if (!derivedKey) throw new Error("vault_locked");24902491 const slotB = await loadSlot(SLOT_B);2492 if (!slotB) throw new Error("missing slot B");24932494 2495 2496 2497 2498 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2499 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");25002501 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2502 2503 2504 2505 try {2506 await stampLocalVaultMutation();2507 } catch (err) {2508 await writeSlot(SLOT_B, slotB).catch(() => {});2509 throw err;2510 }2511}25122513async function persistEntries(2514 reason: VaultSnapshot["reason"] = "local_save",2515): Promise<void> {2516 if (!derivedKey) throw new Error("vault_locked");25172518 2519 2520 const previousTombstones = tombstones;2521 tombstones = new Map(tombstones);2522 reconcileTombstones();2523 try {2524 if (vaultMode === "decoy") {2525 await persistSlotB();2526 } else {2527 await persistSlotA(reason);2528 }2529 } catch (err) {2530 tombstones = previousTombstones;2531 throw err;2532 }2533 buildDomainIndexSync(entries);2534 await refreshLockedSyncState();2535}2536253725382539254025412542function allowHttpUrl(url: string | undefined): string {2543 const trimmed = url?.trim() ?? "";2544 if (!trimmed) return "";2545 try {2546 const parsed = new URL(trimmed);2547 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2548 } catch {2549 2550 }2551 return trimmed;2552}25532554function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2555 if (!Array.isArray(values)) return undefined;2556 const seen = new Set<string>();2557 const cleaned: string[] = [];2558 for (const value of values) {2559 const trimmed = typeof value === "string" ? value.trim() : "";2560 if (!trimmed) continue;2561 if (!allowHttpUrl(trimmed)) continue;2562 const key = trimmed.toLowerCase();2563 if (seen.has(key)) continue;2564 seen.add(key);2565 cleaned.push(trimmed);2566 }2567 return cleaned.length > 0 ? cleaned : undefined;2568}25692570function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2571 const title = entry.title.trim();2572 const type: VaultEntry["type"] = entry.type ?? "login";25732574 if (!title) throw new Error("title is required");25752576 if (entry.passkey) {2577 const p = entry.passkey;2578 if (2579 !p.rpId ||2580 !p.credentialId ||2581 !p.publicKeyCose ||2582 !p.userHandle ||2583 !p.userName ||2584 (p.algorithm !== -7 && p.algorithm !== -8)2585 ) {2586 throw new Error("invalid passkey credential");2587 }2588 }2589 if (type === "card") {2590 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2591 throw new Error("cardholder name and card number are required");2592 }2593 }25942595 return {2596 type,2597 title,2598 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2599 email: type === "login" ? entry.email?.trim() || undefined : undefined,2600 password: type === "login" ? entry.password : undefined,2601 url: type === "login" ? allowHttpUrl(entry.url) : undefined,2602 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2603 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2604 2605 2606 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2607 totpDigits: type === "login" ? entry.totpDigits : undefined,2608 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2609 notes: entry.notes?.trim() || undefined,2610 breach: type === "login" ? entry.breach : undefined,2611 2612 2613 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2614 tags: sanitizeTags(entry.tags),2615 passkey: entry.passkey,2616 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2617 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2618 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2619 expYear: type === "card" ? entry.expYear?.trim() : undefined,2620 cvc: type === "card" ? entry.cvc?.trim() : undefined,2621 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2622 network: type === "card" ? entry.network : undefined,2623 last4: type === "card" ? entry.last4?.trim() : undefined,2624 };2625}26262627function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2628 const nextPatch: Partial<VaultEntry> = {};26292630 if ("type" in patch) {2631 nextPatch.type = patch.type;2632 }26332634 if ("title" in patch) {2635 const title = patch.title?.trim() ?? "";2636 if (!title) throw new Error("title is required");2637 nextPatch.title = title;2638 }26392640 if ("url" in patch) {2641 nextPatch.url = allowHttpUrl(patch.url);2642 }26432644 if ("additionalUrls" in patch) {2645 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2646 const seen = new Set<string>();2647 const cleaned: string[] = [];2648 for (const value of incoming) {2649 const trimmed = typeof value === "string" ? value.trim() : "";2650 if (!trimmed || !allowHttpUrl(trimmed)) continue;2651 const key = trimmed.toLowerCase();2652 if (seen.has(key)) continue;2653 seen.add(key);2654 cleaned.push(trimmed);2655 }2656 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2657 }26582659 if ("username" in patch) {2660 nextPatch.username = patch.username?.trim() ?? "";2661 }26622663 if ("email" in patch) {2664 nextPatch.email = patch.email?.trim() || undefined;2665 }26662667 if ("password" in patch) {2668 const password = patch.password ?? "";2669 2670 nextPatch.password = password || undefined;2671 }26722673 if ("totp" in patch) {2674 nextPatch.totp = patch.totp?.trim() || undefined;2675 }26762677 if ("notes" in patch) {2678 nextPatch.notes = patch.notes?.trim() || undefined;2679 }26802681 if ("breach" in patch) {2682 nextPatch.breach = patch.breach;2683 }26842685 if ("canary" in patch) {2686 2687 2688 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2689 }26902691 if ("passkey" in patch) {2692 2693 2694 if (patch.passkey === undefined || patch.passkey === null) {2695 nextPatch.passkey = undefined;2696 } else {2697 const sanitized = sanitizePasskey(patch.passkey);2698 if (!sanitized) throw new Error("invalid passkey credential");2699 nextPatch.passkey = sanitized;2700 }2701 }27022703 if ("tags" in patch) {2704 2705 nextPatch.tags = sanitizeTags(patch.tags);2706 }27072708 if ("cardholderName" in patch) {2709 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2710 }2711 if ("cardNumber" in patch) {2712 const n = patch.cardNumber ?? "";2713 nextPatch.cardNumber = n || undefined;2714 }2715 if ("expMonth" in patch) {2716 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2717 }2718 if ("expYear" in patch) {2719 nextPatch.expYear = patch.expYear?.trim() || undefined;2720 }2721 if ("cvc" in patch) {2722 nextPatch.cvc = patch.cvc?.trim() || undefined;2723 }2724 if ("cardNickname" in patch) {2725 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2726 }2727 if ("network" in patch) {2728 nextPatch.network = patch.network;2729 }2730 if ("last4" in patch) {2731 nextPatch.last4 = patch.last4?.trim() || undefined;2732 }27332734 return nextPatch;2735}27362737function normalizeEntry(entry: VaultEntry): VaultEntry {2738 const base: VaultEntry = {2739 id: entry.id,2740 type: entry.type,2741 title: entry.title,2742 username: entry.username,2743 email: entry.email,2744 password: entry.password,2745 url: entry.url,2746 additionalUrls:2747 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2748 ? entry.additionalUrls2749 : undefined,2750 totp: entry.totp,2751 2752 totpAlgorithm: entry.totpAlgorithm,2753 totpDigits: entry.totpDigits,2754 totpPeriod: entry.totpPeriod,2755 notes: entry.notes,2756 breach: entry.breach,2757 2758 2759 2760 passwordChangedAt:2761 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2762 2763 2764 2765 passwordHistory:2766 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2767 ? entry.passwordHistory2768 : undefined,2769 canary: entry.canary,2770 passkey: entry.passkey,2771 createdAt: entry.createdAt,2772 modifiedAt: entry.modifiedAt,2773 deletedAt: entry.deletedAt,2774 tags: entry.tags,2775 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2776 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2777 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2778 expYear: "expYear" in entry ? entry.expYear : undefined,2779 cvc: "cvc" in entry ? entry.cvc : undefined,2780 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2781 network: "network" in entry ? entry.network : undefined,2782 last4: "last4" in entry ? entry.last4 : undefined,2783 };27842785 return base;2786}2787278827892790279127922793function entryDupeKey(e: VaultEntry): string {2794 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2795 2796 2797 2798 2799 if (e.passkey) {2800 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2801 }2802 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2803}280428052806export function countDuplicates(): number {2807 const best = new Map<string, VaultEntry>();2808 let dupeCount = 0;2809 for (const e of entries.filter((e) => !e.deletedAt)) {2810 const key = entryDupeKey(e);2811 const existing = best.get(key);2812 if (!existing) {2813 best.set(key, e);2814 } else {2815 dupeCount++;2816 2817 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2818 best.set(key, e);2819 }2820 }2821 }2822 return dupeCount;2823}282428252826282728282829export async function deduplicateEntries(): Promise<number> {2830 if (!derivedKey) throw new Error("vault_locked");2831 2832 2833 if (vaultMode === "decoy") {2834 throw new Error("deduplicate disabled in decoy mode");2835 }28362837 const best = new Map<string, VaultEntry>();2838 for (const e of entries.filter((en) => !en.deletedAt)) {2839 const key = entryDupeKey(e);2840 const existing = best.get(key);2841 if (!existing) {2842 best.set(key, e);2843 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2844 best.set(key, e);2845 }2846 }28472848 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2849 let removed = 0;2850 entries = entries.map((e) => {2851 if (!e.deletedAt && !keepIds.has(e.id)) {2852 removed++;2853 const deletedAt = monotonicEntryTimestamp(Math.max(2854 e.modifiedAt,2855 tombstones.get(e.id) ?? 0,2856 ));2857 tombstones.set(e.id, deletedAt);2858 return { ...e, deletedAt };2859 }2860 return e;2861 });28622863 if (removed > 0) await persistEntries();2864 return removed;2865}286628672868286928702871287228732874const HISTORY_KEY = "peach_vault_history";2875const HISTORY_MAX = 5;287628772878287928802881function bucketEntryCount(n: number): number {2882 if (n <= 0) return 0;2883 if (n <= 10) return 10;2884 if (n <= 25) return 25;2885 if (n <= 50) return 50;2886 if (n <= 100) return 100;2887 if (n <= 250) return 250;2888 if (n <= 500) return 500;2889 return Math.ceil(n / 500) * 500;2890}28912892289328942895289628972898export async function takeVaultSnapshot(2899 reason: VaultSnapshot["reason"],2900): Promise<void> {2901 try {2902 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2903 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2904 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];29052906 2907 2908 2909 if (!slotA) return;29102911 const vault_a = JSON.stringify(slotA);2912 const vault_b = JSON.stringify(slotB ?? slotA);29132914 2915 2916 2917 2918 2919 2920 let entryCount: number | null = null;2921 if (derivedKey && vaultMode === "real") {2922 try {2923 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);2924 } catch {2925 entryCount = null;2926 }2927 }29282929 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2930 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2931 ? existingRaw2932 : { snapshots: [] };29332934 2935 2936 2937 const latest = history.snapshots[0];2938 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2939 return;2940 }29412942 const snapshot: VaultSnapshot = {2943 id: crypto.randomUUID(),2944 savedAt: Date.now(),2945 reason,2946 vault_a,2947 vault_b,2948 entryCount,2949 };29502951 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2952 await chrome.storage.local.set({ [HISTORY_KEY]: history });2953 } catch (err) {2954 2955 2956 2957 2958 console.warn('[Peach] takeVaultSnapshot failed:', err);2959 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2960 try {2961 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2962 } catch {2963 2964 }2965 }2966 }2967}296829692970export async function clearVaultHistory(): Promise<void> {2971 try {2972 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2973 } catch {2974 2975 }2976}297729782979export async function getVaultHistory(): Promise<VaultSnapshot[]> {2980 const raw = await chrome.storage.local.get(HISTORY_KEY);2981 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2982 if (!history || !Array.isArray(history.snapshots)) return [];2983 return history.snapshots;2984}29852986298729882989299029912992export async function restoreVaultSnapshot(2993 snapshotId: string,2994): Promise<{ ok: true } | { ok: false; error: string }> {2995 const history = await getVaultHistory();2996 const snapshot = history.find((s) => s.id === snapshotId);2997 if (!snapshot) return { ok: false, error: "snapshot_not_found" };29982999 3000 3001 3002 await takeVaultSnapshot("restore");30033004 let restoredA: StoredVault;3005 let restoredB: StoredVault;3006 try {3007 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3008 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3009 } catch {3010 return { ok: false, error: "snapshot_corrupt" };3011 }30123013 await writeSlot(SLOT_A, restoredA);3014 await writeSlot(SLOT_B, restoredB);3015 await stampLocalVaultMutation();30163017 3018 3019 3020 lock();30213022 return { ok: true };3023}302430253026302730283029303030313032303330343035303630373038export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3039 if (!derivedKey || vaultMode !== "real") {3040 return { ok: false, error: "vault_locked" };3041 }3042 const slotA = await loadSlot(SLOT_A);3043 if (!slotA) return { ok: false, error: "missing_slot_a" };30443045 3046 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3047 await generateAndWrapRootKey();30483049 3050 const newRootKey = unhex(rawRootKey);3051 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);30523053 3054 3055 3056 3057 await takeVaultSnapshot("local_save");3058 const nextSlotA: StoredVault = {3059 ...slotA,3060 recovery_salt: recoverySalt,3061 recovery_wrapped_root_key: wrappedRootKey,3062 recovery_nonce: wrappedNonce,3063 recovery_tag: wrappedTag,3064 ...pwRoot,3065 };3066 await writeSlot(SLOT_A, nextSlotA);30673068 3069 if (rootKey) zeroKey(rootKey);3070 rootKey = newRootKey;30713072 3073 await clearPairedDevices();30743075 3076 await setRecoveryConfirmPending(); 30773078 await stampLocalVaultMutation();3079 return { ok: true };3080}30813082308330843085308630873088export async function changePassword(3089 currentPassword: string,3090 newPassword: string3091): Promise<boolean> {3092 if (!derivedKey || vaultMode !== "real") return false;30933094 3095 const valid = await verifyMainPassword(currentPassword);3096 if (!valid) return false;30973098 const slotA = await loadSlot(SLOT_A);3099 if (!slotA) return false;3100 if (!rootKey) return false;31013102 3103 const newSalt = generateSalt();3104 const newKey = await deriveKey(newPassword, newSalt);31053106 3107 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3108 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");31093110 3111 3112 3113 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);31143115 const finalSlotA: StoredVault = {3116 ...slotA,3117 salt: hex(newSalt),3118 nonce,3119 ciphertext,3120 tag,3121 ...pwRoot,3122 };31233124 3125 3126 3127 await takeVaultSnapshot("local_save");31283129 3130 3131 3132 3133 await writeSlot(SLOT_A, finalSlotA);31343135 3136 3137 3138 zeroKey(derivedKey);3139 derivedKey = newKey;31403141 3142 3143 3144 3145 void saveSessionKey(newKey, "real");31463147 3148 3149 3150 if (!duressConfigured) {3151 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3152 }3153 await clearPinUnlockStorage();3154 await stampLocalVaultMutation();31553156 return true;3157}