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 for (const entry of incoming) {1825 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));1826 if (!normalized) {1827 skipped += 1;1828 continue;1829 }1830 entries.push(normalized);1831 if (normalized.deletedAt) {1832 tombstones.set(1833 normalized.id,1834 Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),1835 );1836 } else {1837 tombstones.delete(normalized.id);1838 }1839 added += 1;1840 }1841 } else {1842 const byId = new Map<string, VaultEntry>();1843 for (const e of entries) byId.set(e.id, e);18441845 for (const entry of incoming) {1846 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));1847 if (!normalized) {1848 skipped += 1;1849 continue;1850 }1851 const existing = byId.get(normalized.id);1852 if (!existing) {1853 entries.push(normalized);1854 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1855 else tombstones.delete(normalized.id);1856 added += 1;1857 } else if (normalized.modifiedAt > existing.modifiedAt) {1858 const idx = entries.findIndex((e) => e.id === existing.id);1859 if (idx !== -1) {1860 entries[idx] = normalized;1861 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1862 else tombstones.delete(normalized.id);1863 updated += 1;1864 }1865 } else {1866 skipped += 1;1867 }1868 }1869 }18701871 await persistEntries("import");1872 return { added, updated, skipped };1873}187418751876187718781879188018811882188318841885188618871888188918901891const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;18921893function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {1894 if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;1895 return { ...entry, id: crypto.randomUUID() };1896}189718981899190019011902190319041905function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1906 if (!entry || typeof entry !== "object") return null;1907 const title = typeof entry.title === "string" ? entry.title.trim() : "";1908 if (!title) return null;1909 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1910 const now = Date.now();1911 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1912 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;19131914 1915 1916 const normalized = normalizeEntry({1917 ...entry,1918 id,1919 title,1920 type: entry.type ?? "login",1921 createdAt,1922 modifiedAt,1923 });19241925 if (normalized.passkey) {1926 normalized.passkey = sanitizePasskey(normalized.passkey);1927 }1928 if (normalized.canary) {1929 normalized.canary = sanitizeCanary(normalized.canary);1930 }1931 1932 normalized.tags = sanitizeTags(normalized.tags);19331934 1935 1936 if (1937 normalized.type === "login" &&1938 !normalized.passkey &&1939 typeof normalized.password !== "string"1940 ) {1941 return null;1942 }1943 if (entry.passkey && !normalized.passkey) {1944 1945 1946 return null;1947 }19481949 return normalized;1950}1951195219531954195519561957function sanitizePasskey(pk: unknown): PasskeyData | undefined {1958 if (!pk || typeof pk !== "object") return undefined;1959 const r = pk as Record<string, unknown>;1960 if (1961 typeof r.rpId !== "string" || !r.rpId ||1962 typeof r.credentialId !== "string" || !r.credentialId ||1963 typeof r.publicKeyCose !== "string" ||1964 typeof r.userHandle !== "string" ||1965 typeof r.userName !== "string"1966 ) {1967 return undefined;1968 }1969 return {1970 rpId: r.rpId,1971 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1972 credentialId: r.credentialId,1973 publicKeyCose: r.publicKeyCose,1974 privateKeyJwk:1975 r.privateKeyJwk && typeof r.privateKeyJwk === "object"1976 ? (r.privateKeyJwk as JsonWebKey)1977 : undefined,1978 userHandle: r.userHandle,1979 userName: r.userName,1980 userDisplayName:1981 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1982 algorithm: r.algorithm === -8 ? -8 : -7,1983 signCount: typeof r.signCount === "number" ? r.signCount : 0,1984 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1985 backupEligible: r.backupEligible === true,1986 backupState: r.backupState === true,1987 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1988 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1989 transports: Array.isArray(r.transports)1990 ? r.transports.filter((t): t is string => typeof t === "string")1991 : undefined,1992 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1993 };1994}19951996199719981999200020012002200320042005function sanitizeCanary(c: unknown): Canary | undefined {2006 if (!c || typeof c !== "object") return undefined;2007 const r = c as Record<string, unknown>;2008 if (typeof r.webhookUrl !== "string") return undefined;2009 let url: URL;2010 try {2011 url = new URL(r.webhookUrl);2012 } catch {2013 return undefined;2014 }2015 if (url.protocol !== "https:") return undefined;2016 return {2017 webhookUrl: url.toString(),2018 label: typeof r.label === "string" ? r.label : undefined,2019 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2020 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2021 };2022}20232024202520262027202820292030function sanitizeTags(tags: unknown): string[] | undefined {2031 if (!Array.isArray(tags)) return undefined;2032 const out: string[] = [];2033 for (const t of tags) {2034 if (typeof t !== "string") continue;2035 const trimmed = t.trim();2036 if (!trimmed) continue;2037 out.push(trimmed.slice(0, 256));2038 if (out.length >= 255) break;2039 }2040 return out.length > 0 ? out : undefined;2041}204220432044204520462047export async function clearVaultEntries(2048 confirmation: "REPLACE_ALL"2049): Promise<void> {2050 if (confirmation !== "REPLACE_ALL") {2051 throw new Error("clearVaultEntries requires explicit confirmation");2052 }2053 if (!derivedKey) throw new Error("vault_locked");2054 for (const entry of entries) {2055 if (!entry.deletedAt) {2056 const stamp = monotonicEntryTimestamp(Math.max(2057 entry.modifiedAt,2058 tombstones.get(entry.id) ?? 0,2059 ));2060 entry.deletedAt = stamp;2061 entry.modifiedAt = stamp;2062 tombstones.set(entry.id, stamp);2063 }2064 }2065 await persistEntries();2066}20672068export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2069 if (!derivedKey) throw new Error("vault_locked");20702071 const sanitized = sanitizeEntryDraft(entry);2072 const now = Date.now();2073 const stamp = monotonicEntryTimestamp();2074 const nextEntry: VaultEntry = {2075 ...sanitized,2076 id: crypto.randomUUID(),2077 createdAt: now,2078 modifiedAt: stamp,2079 2080 2081 passwordChangedAt: sanitized.password ? stamp : undefined,2082 };20832084 2085 2086 2087 const rollback = entries;2088 entries = [...entries, nextEntry];2089 try {2090 await persistEntries();2091 } catch (err) {2092 entries = rollback;2093 throw err;2094 }20952096 2097 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2098 checkBreach(nextEntry.password).then(async (count) => {2099 if (count > 0) {2100 const idx = entries.findIndex((e) => e.id === nextEntry.id);2101 if (idx !== -1) {2102 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2103 await persistEntries();2104 }2105 }2106 });2107 }21082109 fireSyncTrigger("vault_change");21102111 return nextEntry;2112}21132114211521162117211821192120export async function consolidatePasskeyEntries(): Promise<void> {2121 if (!derivedKey) return;2122 const { merged } = mergePasskeyOnlyIntoLogins(entries);2123 if (merged) {2124 await persistEntries();2125 }2126}21272128export async function updateEntry(2129 id: string,2130 patch: Partial<VaultEntry>2131): Promise<void> {2132 if (!derivedKey) throw new Error("vault_locked");21332134 const idx = entries.findIndex((entry) => entry.id === id);2135 if (idx === -1 || entries[idx].deletedAt) {2136 throw new Error("entry_not_found");2137 }21382139 const current = entries[idx];2140 const sanitizedPatch = sanitizeEntryPatch(patch);2141 2142 2143 2144 const passwordChanged =2145 "password" in patch &&2146 typeof sanitizedPatch.password === "string" &&2147 sanitizedPatch.password !== current.password;2148 const stamp = monotonicEntryTimestamp(current.modifiedAt);2149 2150 2151 2152 2153 2154 2155 2156 const priorPassword = current.password;2157 const shouldCapture =2158 passwordChanged &&2159 typeof priorPassword === "string" &&2160 priorPassword.length > 0 &&2161 current.passwordHistory?.[0]?.password !== priorPassword;2162 const nextHistory = shouldCapture2163 ? [2164 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2165 ...(current.passwordHistory ?? []),2166 ].slice(0, PASSWORD_HISTORY_MAX)2167 : current.passwordHistory;2168 entries[idx] = {2169 ...current,2170 ...sanitizedPatch,2171 modifiedAt: stamp,2172 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2173 };2174217521762177 2178 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2179 entries[idx] = current;2180 throw new Error("card number is required for card entries");2181 }21822183 2184 2185 2186 2187 try {2188 await persistEntries();2189 } catch (err) {2190 entries[idx] = current;2191 throw err;2192 }21932194 2195 2196 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2197 entries[idx] = { ...entries[idx], breach: undefined };2198 await persistEntries();2199 const password = entries[idx].password;2200 if (password) {2201 checkBreach(password).then(async (count) => {2202 const i = entries.findIndex((e) => e.id === id);2203 if (i !== -1 && count > 0) {2204 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2205 await persistEntries();2206 }2207 });2208 }2209 }22102211 fireSyncTrigger("vault_change");2212}22132214export async function deleteEntry(id: string): Promise<void> {2215 if (!derivedKey) throw new Error("vault_locked");22162217 const entry = entries.find((item) => item.id === id);2218 if (!entry || entry.deletedAt) {2219 throw new Error("entry_not_found");2220 }22212222 2223 2224 2225 const previousModifiedAt = entry.modifiedAt;2226 const previousTombstone = tombstones.get(id);2227 const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2228 entry.deletedAt = stamp;2229 entry.modifiedAt = stamp;2230 tombstones.set(id, stamp);2231 2232 2233 try {2234 await persistEntries();2235 } catch (err) {2236 delete entry.deletedAt;2237 entry.modifiedAt = previousModifiedAt;2238 if (previousTombstone === undefined) tombstones.delete(id);2239 else tombstones.set(id, previousTombstone);2240 throw err;2241 }2242 fireSyncTrigger("vault_change");2243}22442245export function getDeletedEntries(): VaultEntry[] {2246 if (!derivedKey) return [];2247 return entries.filter((e) => !!e.deletedAt);2248}224922502251export function getTombstoneManifest(): Record<string, number> {2252 if (!derivedKey) return {};2253 return Object.fromEntries(tombstones);2254}2255225622572258225922602261export async function getVaultVersion(): Promise<number> {2262 try {2263 const raw = await chrome.storage.local.get("peach_last_modified_at");2264 const v = (raw as Record<string, unknown>).peach_last_modified_at;2265 if (typeof v === "number") return v;2266 if (typeof v === "string" && v !== "") {2267 const n = Number.parseInt(v, 10);2268 return Number.isFinite(n) ? n : 0;2269 }2270 return 0;2271 } catch {2272 return 0;2273 }2274}2275227622772278227922802281228222832284228522862287export async function applyMergedRemoteEntries(2288 remoteEntries: VaultEntry[],2289 remoteTombstones: Record<string, number>,2290 _context: { deviceName?: string } = {},2291): Promise<{ received: number; applied: number; conflicts: number }> {2292 2293 2294 2295 2296 2297 2298 if (!derivedKey) throw new Error("vault_locked");22992300 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2301 let applied = 0;2302 let conflicts = 0;23032304 const byId = new Map<string, VaultEntry>();2305 for (const e of entries) byId.set(e.id, e);23062307 const mergeNow = Date.now();2308 const remoteTombstoneTimes = new Map<string, number>();2309 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2310 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2311 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2312 }2313 }23142315 for (const remote of remoteEntries) {2316 const sanitized = sanitizeRestoredEntry(remote);2317 if (!sanitized) continue;2318 2319 2320 2321 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2322 if (typeof sanitized.deletedAt === "number") {2323 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2324 }2325 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2326 const localTombstoneTs = tombstones.get(sanitized.id);2327 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2328 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2329 continue;2330 }2331 const local = byId.get(sanitized.id);2332 if (!local) {2333 entries.push(sanitized);2334 byId.set(sanitized.id, sanitized);2335 tombstones.delete(sanitized.id);2336 applied += 1;2337 } else if (sanitized.modifiedAt > local.modifiedAt) {2338 const idx = entries.findIndex((e) => e.id === local.id);2339 if (idx !== -1) {2340 entries[idx] = sanitized;2341 byId.set(sanitized.id, sanitized);2342 tombstones.delete(sanitized.id);2343 applied += 1;2344 if (!local.deletedAt) conflicts += 1;2345 }2346 }2347 }23482349 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2350 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2351 2352 const deletedAt = remoteTombstoneTimes.get(id)!;2353 const knownTombstone = tombstones.get(id);2354 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2355 const local = byId.get(id);2356 if (!local) {2357 tombstones.set(id, deletedAt);2358 applied += 1;2359 continue;2360 }2361 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2362 if (deletedAt < local.modifiedAt) continue;2363 const idx = entries.findIndex((e) => e.id === id);2364 if (idx !== -1) {2365 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2366 tombstones.set(id, deletedAt);2367 applied += 1;2368 }2369 }23702371 if (applied > 0) {2372 await persistEntries("sync_received");2373 buildDomainIndexSync(entries);2374 }2375 return { received, applied, conflicts };2376}23772378export async function restoreEntry(id: string): Promise<void> {2379 if (!derivedKey) throw new Error("vault_locked");2380 const entry = entries.find((e) => e.id === id && e.deletedAt);2381 if (!entry) throw new Error("deleted entry not found");2382 const previousDeletedAt = entry.deletedAt;2383 const previousModifiedAt = entry.modifiedAt;2384 const previousTombstone = tombstones.get(id);2385 delete entry.deletedAt;2386 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2387 entry.modifiedAt,2388 previousTombstone ?? 0,2389 ));2390 tombstones.delete(id);2391 try {2392 await persistEntries();2393 } catch (err) {2394 entry.deletedAt = previousDeletedAt;2395 entry.modifiedAt = previousModifiedAt;2396 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2397 throw err;2398 }2399 buildDomainIndexSync(entries);2400}24012402export async function purgeEntry(id: string): Promise<void> {2403 if (!derivedKey) throw new Error("vault_locked");2404 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2405 if (idx === -1) throw new Error("deleted entry not found");2406 const entry = entries[idx]!;2407 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2408 entries.splice(idx, 1);2409 await persistEntries();2410}241124122413export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2414 if (!derivedKey || retentionMs === 0) return 0;2415 const cutoff = Date.now() - retentionMs;2416 const before = entries.length;2417 for (const entry of entries) {2418 if (entry.deletedAt && entry.deletedAt < cutoff) {2419 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2420 }2421 }2422 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2423 const purged = before - entries.length;2424 if (purged > 0) await persistEntries();2425 return purged;2426}24272428export function queryDomain(domain: string): VaultEntry[] {2429 return domainIndex.get(domain) ?? [];2430}24312432export function getEntryById(id: string): VaultEntry | undefined {2433 return entries.find((entry) => entry.id === id && !entry.deletedAt);2434}24352436async function persistSlotA(2437 reason: VaultSnapshot["reason"] = "local_save",2438): Promise<StoredVault> {2439 if (!derivedKey) throw new Error("vault_locked");24402441 const slotA = await loadSlot(SLOT_A);2442 if (!slotA) throw new Error("missing slot A");24432444 2445 2446 await takeVaultSnapshot(reason);24472448 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2449 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");24502451 2452 const nextSlotA: StoredVault = {2453 ...slotA,2454 nonce,2455 ciphertext,2456 tag,2457 2458 };2459 await writeSlot(SLOT_A, nextSlotA);24602461 2462 2463 2464 2465 2466 2467 try {2468 2469 2470 2471 if (!duressConfigured) {2472 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2473 }2474 2475 2476 2477 2478 2479 if (reason !== "sync_received") {2480 await stampLocalVaultMutation();2481 }2482 } catch (err) {2483 2484 2485 await writeSlot(SLOT_A, slotA).catch(() => {});2486 throw err;2487 }24882489 return nextSlotA;2490}24912492async function persistSlotB(): Promise<void> {2493 if (!derivedKey) throw new Error("vault_locked");24942495 const slotB = await loadSlot(SLOT_B);2496 if (!slotB) throw new Error("missing slot B");24972498 2499 2500 2501 2502 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2503 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");25042505 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2506 2507 2508 2509 try {2510 await stampLocalVaultMutation();2511 } catch (err) {2512 await writeSlot(SLOT_B, slotB).catch(() => {});2513 throw err;2514 }2515}25162517async function persistEntries(2518 reason: VaultSnapshot["reason"] = "local_save",2519): Promise<void> {2520 if (!derivedKey) throw new Error("vault_locked");25212522 2523 2524 const previousTombstones = tombstones;2525 tombstones = new Map(tombstones);2526 reconcileTombstones();2527 try {2528 if (vaultMode === "decoy") {2529 await persistSlotB();2530 } else {2531 await persistSlotA(reason);2532 }2533 } catch (err) {2534 tombstones = previousTombstones;2535 throw err;2536 }2537 buildDomainIndexSync(entries);2538 await refreshLockedSyncState();2539}2540254125422543254425452546function allowHttpUrl(url: string | undefined): string {2547 const trimmed = url?.trim() ?? "";2548 if (!trimmed) return "";2549 try {2550 const parsed = new URL(trimmed);2551 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2552 } catch {2553 2554 }2555 return trimmed;2556}25572558function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2559 if (!Array.isArray(values)) return undefined;2560 const seen = new Set<string>();2561 const cleaned: string[] = [];2562 for (const value of values) {2563 const trimmed = typeof value === "string" ? value.trim() : "";2564 if (!trimmed) continue;2565 if (!allowHttpUrl(trimmed)) continue;2566 const key = trimmed.toLowerCase();2567 if (seen.has(key)) continue;2568 seen.add(key);2569 cleaned.push(trimmed);2570 }2571 return cleaned.length > 0 ? cleaned : undefined;2572}25732574function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2575 const title = entry.title.trim();2576 const type: VaultEntry["type"] = entry.type ?? "login";25772578 if (!title) throw new Error("title is required");25792580 if (entry.passkey) {2581 const p = entry.passkey;2582 if (2583 !p.rpId ||2584 !p.credentialId ||2585 !p.publicKeyCose ||2586 !p.userHandle ||2587 !p.userName ||2588 (p.algorithm !== -7 && p.algorithm !== -8)2589 ) {2590 throw new Error("invalid passkey credential");2591 }2592 }2593 if (type === "card") {2594 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2595 throw new Error("cardholder name and card number are required");2596 }2597 }25982599 return {2600 type,2601 title,2602 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2603 email: type === "login" ? entry.email?.trim() || undefined : undefined,2604 password: type === "login" ? entry.password : undefined,2605 url: type === "login" ? allowHttpUrl(entry.url) : undefined,2606 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2607 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2608 2609 2610 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2611 totpDigits: type === "login" ? entry.totpDigits : undefined,2612 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2613 notes: entry.notes?.trim() || undefined,2614 breach: type === "login" ? entry.breach : undefined,2615 2616 2617 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2618 tags: sanitizeTags(entry.tags),2619 passkey: entry.passkey,2620 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2621 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2622 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2623 expYear: type === "card" ? entry.expYear?.trim() : undefined,2624 cvc: type === "card" ? entry.cvc?.trim() : undefined,2625 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2626 network: type === "card" ? entry.network : undefined,2627 last4: type === "card" ? entry.last4?.trim() : undefined,2628 };2629}26302631function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2632 const nextPatch: Partial<VaultEntry> = {};26332634 if ("type" in patch) {2635 nextPatch.type = patch.type;2636 }26372638 if ("title" in patch) {2639 const title = patch.title?.trim() ?? "";2640 if (!title) throw new Error("title is required");2641 nextPatch.title = title;2642 }26432644 if ("url" in patch) {2645 nextPatch.url = allowHttpUrl(patch.url);2646 }26472648 if ("additionalUrls" in patch) {2649 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2650 const seen = new Set<string>();2651 const cleaned: string[] = [];2652 for (const value of incoming) {2653 const trimmed = typeof value === "string" ? value.trim() : "";2654 if (!trimmed || !allowHttpUrl(trimmed)) continue;2655 const key = trimmed.toLowerCase();2656 if (seen.has(key)) continue;2657 seen.add(key);2658 cleaned.push(trimmed);2659 }2660 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2661 }26622663 if ("username" in patch) {2664 nextPatch.username = patch.username?.trim() ?? "";2665 }26662667 if ("email" in patch) {2668 nextPatch.email = patch.email?.trim() || undefined;2669 }26702671 if ("password" in patch) {2672 const password = patch.password ?? "";2673 2674 nextPatch.password = password || undefined;2675 }26762677 if ("totp" in patch) {2678 nextPatch.totp = patch.totp?.trim() || undefined;2679 }26802681 if ("notes" in patch) {2682 nextPatch.notes = patch.notes?.trim() || undefined;2683 }26842685 if ("breach" in patch) {2686 nextPatch.breach = patch.breach;2687 }26882689 if ("canary" in patch) {2690 2691 2692 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2693 }26942695 if ("passkey" in patch) {2696 2697 2698 if (patch.passkey === undefined || patch.passkey === null) {2699 nextPatch.passkey = undefined;2700 } else {2701 const sanitized = sanitizePasskey(patch.passkey);2702 if (!sanitized) throw new Error("invalid passkey credential");2703 nextPatch.passkey = sanitized;2704 }2705 }27062707 if ("tags" in patch) {2708 2709 nextPatch.tags = sanitizeTags(patch.tags);2710 }27112712 if ("cardholderName" in patch) {2713 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2714 }2715 if ("cardNumber" in patch) {2716 const n = patch.cardNumber ?? "";2717 nextPatch.cardNumber = n || undefined;2718 }2719 if ("expMonth" in patch) {2720 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2721 }2722 if ("expYear" in patch) {2723 nextPatch.expYear = patch.expYear?.trim() || undefined;2724 }2725 if ("cvc" in patch) {2726 nextPatch.cvc = patch.cvc?.trim() || undefined;2727 }2728 if ("cardNickname" in patch) {2729 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2730 }2731 if ("network" in patch) {2732 nextPatch.network = patch.network;2733 }2734 if ("last4" in patch) {2735 nextPatch.last4 = patch.last4?.trim() || undefined;2736 }27372738 return nextPatch;2739}27402741function normalizeEntry(entry: VaultEntry): VaultEntry {2742 const base: VaultEntry = {2743 id: entry.id,2744 type: entry.type,2745 title: entry.title,2746 username: entry.username,2747 email: entry.email,2748 password: entry.password,2749 url: entry.url,2750 additionalUrls:2751 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2752 ? entry.additionalUrls2753 : undefined,2754 totp: entry.totp,2755 2756 totpAlgorithm: entry.totpAlgorithm,2757 totpDigits: entry.totpDigits,2758 totpPeriod: entry.totpPeriod,2759 notes: entry.notes,2760 breach: entry.breach,2761 2762 2763 2764 passwordChangedAt:2765 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2766 2767 2768 2769 passwordHistory:2770 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2771 ? entry.passwordHistory2772 : undefined,2773 canary: entry.canary,2774 passkey: entry.passkey,2775 createdAt: entry.createdAt,2776 modifiedAt: entry.modifiedAt,2777 deletedAt: entry.deletedAt,2778 tags: entry.tags,2779 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2780 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2781 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2782 expYear: "expYear" in entry ? entry.expYear : undefined,2783 cvc: "cvc" in entry ? entry.cvc : undefined,2784 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2785 network: "network" in entry ? entry.network : undefined,2786 last4: "last4" in entry ? entry.last4 : undefined,2787 };27882789 return base;2790}2791279227932794279527962797function entryDupeKey(e: VaultEntry): string {2798 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2799 2800 2801 2802 2803 if (e.passkey) {2804 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2805 }2806 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2807}280828092810export function countDuplicates(): number {2811 const best = new Map<string, VaultEntry>();2812 let dupeCount = 0;2813 for (const e of entries.filter((e) => !e.deletedAt)) {2814 const key = entryDupeKey(e);2815 const existing = best.get(key);2816 if (!existing) {2817 best.set(key, e);2818 } else {2819 dupeCount++;2820 2821 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2822 best.set(key, e);2823 }2824 }2825 }2826 return dupeCount;2827}282828292830283128322833export async function deduplicateEntries(): Promise<number> {2834 if (!derivedKey) throw new Error("vault_locked");2835 2836 2837 if (vaultMode === "decoy") {2838 throw new Error("deduplicate disabled in decoy mode");2839 }28402841 const best = new Map<string, VaultEntry>();2842 for (const e of entries.filter((en) => !en.deletedAt)) {2843 const key = entryDupeKey(e);2844 const existing = best.get(key);2845 if (!existing) {2846 best.set(key, e);2847 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2848 best.set(key, e);2849 }2850 }28512852 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2853 let removed = 0;2854 entries = entries.map((e) => {2855 if (!e.deletedAt && !keepIds.has(e.id)) {2856 removed++;2857 const deletedAt = monotonicEntryTimestamp(Math.max(2858 e.modifiedAt,2859 tombstones.get(e.id) ?? 0,2860 ));2861 tombstones.set(e.id, deletedAt);2862 return { ...e, deletedAt };2863 }2864 return e;2865 });28662867 if (removed > 0) await persistEntries();2868 return removed;2869}287028712872287328742875287628772878const HISTORY_KEY = "peach_vault_history";2879const HISTORY_MAX = 5;288028812882288328842885function bucketEntryCount(n: number): number {2886 if (n <= 0) return 0;2887 if (n <= 10) return 10;2888 if (n <= 25) return 25;2889 if (n <= 50) return 50;2890 if (n <= 100) return 100;2891 if (n <= 250) return 250;2892 if (n <= 500) return 500;2893 return Math.ceil(n / 500) * 500;2894}28952896289728982899290029012902export async function takeVaultSnapshot(2903 reason: VaultSnapshot["reason"],2904): Promise<void> {2905 try {2906 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2907 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2908 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];29092910 2911 2912 2913 if (!slotA) return;29142915 const vault_a = JSON.stringify(slotA);2916 const vault_b = JSON.stringify(slotB ?? slotA);29172918 2919 2920 2921 2922 2923 2924 let entryCount: number | null = null;2925 if (derivedKey && vaultMode === "real") {2926 try {2927 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);2928 } catch {2929 entryCount = null;2930 }2931 }29322933 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2934 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2935 ? existingRaw2936 : { snapshots: [] };29372938 2939 2940 2941 const latest = history.snapshots[0];2942 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2943 return;2944 }29452946 const snapshot: VaultSnapshot = {2947 id: crypto.randomUUID(),2948 savedAt: Date.now(),2949 reason,2950 vault_a,2951 vault_b,2952 entryCount,2953 };29542955 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2956 await chrome.storage.local.set({ [HISTORY_KEY]: history });2957 } catch (err) {2958 2959 2960 2961 2962 console.warn('[Peach] takeVaultSnapshot failed:', err);2963 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2964 try {2965 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2966 } catch {2967 2968 }2969 }2970 }2971}297229732974export async function clearVaultHistory(): Promise<void> {2975 try {2976 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2977 } catch {2978 2979 }2980}298129822983export async function getVaultHistory(): Promise<VaultSnapshot[]> {2984 const raw = await chrome.storage.local.get(HISTORY_KEY);2985 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2986 if (!history || !Array.isArray(history.snapshots)) return [];2987 return history.snapshots;2988}29892990299129922993299429952996export async function restoreVaultSnapshot(2997 snapshotId: string,2998): Promise<{ ok: true } | { ok: false; error: string }> {2999 const history = await getVaultHistory();3000 const snapshot = history.find((s) => s.id === snapshotId);3001 if (!snapshot) return { ok: false, error: "snapshot_not_found" };30023003 3004 3005 3006 await takeVaultSnapshot("restore");30073008 let restoredA: StoredVault;3009 let restoredB: StoredVault;3010 try {3011 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3012 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3013 } catch {3014 return { ok: false, error: "snapshot_corrupt" };3015 }30163017 await writeSlot(SLOT_A, restoredA);3018 await writeSlot(SLOT_B, restoredB);3019 await stampLocalVaultMutation();30203021 3022 3023 3024 lock();30253026 return { ok: true };3027}302830293030303130323033303430353036303730383039304030413042export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3043 if (!derivedKey || vaultMode !== "real") {3044 return { ok: false, error: "vault_locked" };3045 }3046 const slotA = await loadSlot(SLOT_A);3047 if (!slotA) return { ok: false, error: "missing_slot_a" };30483049 3050 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3051 await generateAndWrapRootKey();30523053 3054 const newRootKey = unhex(rawRootKey);3055 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);30563057 3058 3059 3060 3061 await takeVaultSnapshot("local_save");3062 const nextSlotA: StoredVault = {3063 ...slotA,3064 recovery_salt: recoverySalt,3065 recovery_wrapped_root_key: wrappedRootKey,3066 recovery_nonce: wrappedNonce,3067 recovery_tag: wrappedTag,3068 ...pwRoot,3069 };3070 await writeSlot(SLOT_A, nextSlotA);30713072 3073 if (rootKey) zeroKey(rootKey);3074 rootKey = newRootKey;30753076 3077 await clearPairedDevices();30783079 3080 await setRecoveryConfirmPending(); 30813082 await stampLocalVaultMutation();3083 return { ok: true };3084}30853086308730883089309030913092export async function changePassword(3093 currentPassword: string,3094 newPassword: string3095): Promise<boolean> {3096 if (!derivedKey || vaultMode !== "real") return false;30973098 3099 const valid = await verifyMainPassword(currentPassword);3100 if (!valid) return false;31013102 const slotA = await loadSlot(SLOT_A);3103 if (!slotA) return false;3104 if (!rootKey) return false;31053106 3107 const newSalt = generateSalt();3108 const newKey = await deriveKey(newPassword, newSalt);31093110 3111 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3112 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");31133114 3115 3116 3117 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);31183119 const finalSlotA: StoredVault = {3120 ...slotA,3121 salt: hex(newSalt),3122 nonce,3123 ciphertext,3124 tag,3125 ...pwRoot,3126 };31273128 3129 3130 3131 await takeVaultSnapshot("local_save");31323133 3134 3135 3136 3137 await writeSlot(SLOT_A, finalSlotA);31383139 3140 3141 3142 zeroKey(derivedKey);3143 derivedKey = newKey;31443145 3146 3147 3148 3149 void saveSessionKey(newKey, "real");31503151 3152 3153 3154 if (!duressConfigured) {3155 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3156 }3157 await clearPinUnlockStorage();3158 await stampLocalVaultMutation();31593160 return true;3161}