123456789101112import { deriveKey, deriveKeyRaw, 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 LegacyVaultEntry,29 ManualSummonRecord,30 PasskeyData,31 SavedIdentity,32 StoredVault,33 VaultEntry,34 VaultHistory,35 VaultSnapshot,36} from "../shared/types";37import { decrypt, encrypt, zeroKey } from "./aes";38import { clearAutoLock, touchAutoLock } from "./alarms";39import { setRecoveryConfirmPending } from "./recovery-pending";4041type EntryDraft = Omit<42 VaultEntry,43 "id" | "createdAt" | "modifiedAt" | "deletedAt"44>;454647484950const SLOT_A = "peach_vault_a";51const SLOT_B = "peach_vault_b";525354const LEGACY_MAIN = "peach_vault";55const LEGACY_DECOY = "peach_decoy_vault";56const LEGACY_DURESS_FLAG = "peach_duress";57585960const PAD_BLOCK = 1024;6162636465const PASSWORD_HISTORY_MAX = 50;6667686970717273747576const DURESS_SLOT_SIZE = 262_144;7778let derivedKey: Uint8Array | null = null;7980818283let rootKey: Uint8Array | null = null;84let entries: VaultEntry[] = [];85868788let tombstones = new Map<string, number>();89let domainIndex: Map<string, VaultEntry[]> = new Map();90let vaultMode: "real" | "decoy" = "real";91929394let duressConfigured = false;9596let migrationRan = false;979899100101102103104105106107let lastUnlockFailureReason: string | null = null;108109export function consumeLastUnlockFailureReason(): string | null {110 const r = lastUnlockFailureReason;111 lastUnlockFailureReason = null;112 return r;113}114115116117118119export type TextSize = "small" | "medium" | "large";120121export interface VaultSettings {122 accentColor?: AccentColorName;123 theme?: "dark" | "light";124 autoLockMinutes?: number;125 clipboardClearSeconds?: number;126 lockOnClose?: boolean;127 breachMonitoring?: boolean;128 passkeyProviderEnabled?: boolean;129 trashRetentionDays?: number;130 manualSummonDomains?: Record<string, ManualSummonRecord>;131 declinedCardFingerprints?: string[];132 133134135136137 textSize?: TextSize;138 139140141142143 reduceMotion?: boolean;144 145 pinEnabled?: boolean;146 147 pinPreferred?: boolean;148 149150151152153154155 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;156}157158interface StoredPinWrap {159 version: 1;160 salt: string;161 nonce: string;162 ciphertext: string;163 tag: string;164}165166const PIN_WRAP_STORAGE = "peach_pin_wrap";167const PIN_MIN_LENGTH = 4;168const PIN_MAX_LENGTH = 12;169170let settings: VaultSettings = {};171172export function getSettings(): VaultSettings {173 return settings;174}175176export async function ensureSettingsLoaded(): Promise<VaultSettings> {177 await loadSettings();178 return settings;179}180181export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {182 await loadSettings();183 184 185 186 187 const safe = Object.fromEntries(188 Object.entries(patch).filter(189 ([k]) => !["__proto__", "constructor", "prototype"].includes(k),190 ),191 ) as Partial<VaultSettings>;192 settings = { ...settings, ...safe };193 await persistSettings();194}195196function isValidPin(pin: string): boolean {197 return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);198}199200201export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {202 if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;203 if (!(await verifyMainPassword(currentPassword))) return false;204205 const salt = generateSalt();206 const pinKey = await deriveKey(pin, salt);207 try {208 const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");209 await chrome.storage.local.set({210 [PIN_WRAP_STORAGE]: { version: 1, salt: hex(salt), ...wrapped } satisfies StoredPinWrap,211 });212 await updateSettings({ pinEnabled: true });213 return true;214 } finally {215 zeroKey(pinKey);216 }217}218219async function clearPinUnlockStorage(): Promise<void> {220 await chrome.storage.local.remove(PIN_WRAP_STORAGE);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 = (await chrome.storage.local.get(PIN_WRAP_STORAGE))[PIN_WRAP_STORAGE] as239 | StoredPinWrap240 | undefined;241 if (242 !stored ||243 stored.version !== 1 ||244 !stored.salt ||245 !stored.nonce ||246 !stored.ciphertext ||247 !stored.tag248 ) return false;249250 const pinKey = await deriveKey(pin, unhex(stored.salt));251 let unwrappedKey: Uint8Array | null = null;252 try {253 const masterKeyHex = await decrypt(254 pinKey,255 stored.nonce,256 stored.ciphertext,257 stored.tag,258 "pin_wrap_v1",259 );260 if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;261 unwrappedKey = unhex(masterKeyHex);262 const result = await unlockWithKey(masterKeyHex);263 if (!result.ok) return false;264 await saveSessionKey(unwrappedKey, "real");265 return true;266 } catch {267 return false;268 } finally {269 zeroKey(pinKey);270 if (unwrappedKey) zeroKey(unwrappedKey);271 }272}273274async function persistSettings(): Promise<void> {275 try {276 await chrome.storage.local.set({ peach_settings: settings });277 } catch (err) {278 throw mapStorageError(err);279 }280}281282function mapStorageError(err: unknown): Error {283 const message = err instanceof Error ? err.message : String(err);284 if (/QUOTA_BYTES|quota/i.test(message)) {285 return new Error("STORAGE_QUOTA_EXCEEDED");286 }287 return err instanceof Error ? err : new Error(message);288}289290async function loadSettings(): Promise<void> {291 const result = await chrome.storage.local.get("peach_settings");292 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};293}294295296297function fireSyncTrigger(reason: "vault_change"): void {298 void Promise.resolve()299 .then(() => triggerSyncIfReachable({ reason }))300 .catch(() => {301 302 });303}304305let lockedSyncRefreshHook: (() => Promise<void>) | null = null;306let lockedSyncUnlockHook: (() => Promise<void>) | null = null;307308export function setLockedSyncHooks(hooks: {309 refresh: () => Promise<void>;310 afterUnlock: () => Promise<void>;311}): void {312 lockedSyncRefreshHook = hooks.refresh;313 lockedSyncUnlockHook = hooks.afterUnlock;314}315316async function refreshLockedSyncState(): Promise<void> {317 if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;318 await lockedSyncRefreshHook().catch((error) => {319 console.warn("[Peach sync] locked spool refresh failed:", error);320 });321}322323324325326327export async function checkBreach(password: string): Promise<number> {328 329 330 const encoder = new TextEncoder();331 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));332 const hashHex = Array.from(new Uint8Array(hashBuffer))333 .map((b) => b.toString(16).padStart(2, "0"))334 .join("")335 .toUpperCase();336337 const prefix = hashHex.slice(0, 5);338 const suffix = hashHex.slice(5);339340 try {341 342 343 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {344 headers: { "Add-Padding": "true" },345 });346 if (!res.ok) return 0;347 const text = await res.text();348 const match = text.split("\r\n").find((l) => l.startsWith(suffix));349 return match ? parseInt(match.split(":")[1], 10) : 0;350 } catch {351 return 0; 352 }353}354355export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {356 const fingerprint = await cardFingerprint(last4, expMonth, expYear);357 await loadSettings();358 settings = {359 ...settings,360 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],361 };362 await persistSettings();363}364365export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {366 await loadSettings();367 const fingerprint = await cardFingerprint(last4, expMonth, expYear);368 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);369}370371async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {372 const encoder = new TextEncoder();373 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));374 return Array.from(new Uint8Array(buf).slice(0, 8))375 .map((b) => b.toString(16).padStart(2, "0"))376 .join("");377}378379function hex(buf: Uint8Array): string {380 return Array.from(buf)381 .map((b) => b.toString(16).padStart(2, "0"))382 .join("");383}384385function unhex(s: string): Uint8Array {386 const out = new Uint8Array(s.length / 2);387 for (let i = 0; i < s.length; i += 2) {388 out[i / 2] = parseInt(s.slice(i, i + 2), 16);389 }390 return out;391}392393394395396397398399400401402403404405406407408409410411412async function generateAndWrapRootKey(): Promise<{413 rawRootKey: string; 414 recoverySalt: string;415 wrappedRootKey: string;416 wrappedNonce: string;417 wrappedTag: string;418}> {419 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));420421 422 const words = await rootKeyToWords(rootKeyBytes);423 const wordMaterial = await wordsToRootKeyMaterial(words);424 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));425 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);426427 428 429 const nonce = crypto.getRandomValues(new Uint8Array(12));430 const encoded = new TextEncoder().encode(hex(rootKeyBytes));431 const raw = await crypto.subtle.encrypt(432 {433 name: "AES-GCM",434 iv: nonce,435 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,436 tagLength: 128,437 },438 recoveryKey,439 encoded440 );441 442 const rawU8 = new Uint8Array(raw);443 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));444 const tag = hex(rawU8.slice(rawU8.length - 16));445446 return {447 rawRootKey: hex(rootKeyBytes),448 recoverySalt: hex(recoverySalt),449 wrappedRootKey: ciphertext,450 wrappedNonce: hex(nonce),451 wrappedTag: tag,452 };453}454455async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{456 recoverySalt: string;457 wrappedRootKey: string;458 wrappedNonce: string;459 wrappedTag: string;460}> {461 if (rootKeyBytes.length !== 32) {462 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");463 }464465 const words = await rootKeyToWords(rootKeyBytes);466 const wordMaterial = await wordsToRootKeyMaterial(words);467 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));468 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);469 const nonce = crypto.getRandomValues(new Uint8Array(12));470 const encoded = new TextEncoder().encode(hex(rootKeyBytes));471 const raw = await crypto.subtle.encrypt(472 {473 name: "AES-GCM",474 iv: nonce,475 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,476 tagLength: 128,477 },478 recoveryKey,479 encoded,480 );481 const rawU8 = new Uint8Array(raw);482 return {483 recoverySalt: hex(recoverySalt),484 wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),485 wrappedNonce: hex(nonce),486 wrappedTag: hex(rawU8.slice(rawU8.length - 16)),487 };488}489490491492493494495export async function unwrapRootKeyForRecovery(496 wrappedRootKey: string,497 wrappedNonce: string,498 wrappedTag: string,499 recoverySaltHex: string,500 words: string[]501): Promise<void> {502 const wordMaterial = await wordsToRootKeyMaterial(words);503 const recoverySalt = unhex(recoverySaltHex);504 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);505506 507 508 509 510 511 const nonceRaw = unhex(wrappedNonce);512 const ciphertextRaw = unhex(wrappedRootKey);513 const tagRaw = unhex(wrappedTag);514 515 516 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;517 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);518 combined.set(ciphertextRaw, 0);519 combined.set(tagRaw, ciphertextRaw.length);520 const raw = await crypto.subtle.decrypt(521 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },522 recoveryKey,523 combined524 );525 const decryptedHex = new TextDecoder().decode(raw);526 rootKey = unhex(decryptedHex);527}528529530531532533534535536537538export async function rewrapRootKeyForPassword(539 newDerivedKey: Uint8Array,540 _newSalt: Uint8Array541): Promise<void> {542 if (!rootKey) throw new Error("root key not set");543 const slotA = await loadSlot(SLOT_A);544 if (!slotA) throw new Error("missing slot A");545 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);546 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });547}548549550551552553554async function reencryptVaultForNewKey(555 newKey: Uint8Array,556 slotA: StoredVault557): Promise<void> {558 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);559 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");560 await writeSlot(SLOT_A, {561 ...slotA,562 nonce,563 ciphertext,564 tag,565 });566}567568569570571572573export async function changePasswordWithNewKey(574 newSalt: Uint8Array,575 newKey: Uint8Array576): Promise<void> {577 const slotA = await loadSlot(SLOT_A);578 if (!slotA) throw new Error("missing slot A");579 580 581 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });582 if (derivedKey) zeroKey(derivedKey);583 derivedKey = newKey;584 585 586 587 void saveSessionKey(newKey, "real");588 589 590 591 592 if (rootKey) {593 const slotAfter = await loadSlot(SLOT_A);594 if (slotAfter) {595 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);596 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });597 }598 }599 if (!duressConfigured) {600 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));601 }602 await clearPinUnlockStorage();603 await stampLocalVaultMutation();604}605606607608609610611export { deriveKey, generateSalt };612613export async function loadSlotA(): Promise<StoredVault | null> {614 return loadSlot(SLOT_A);615}616617618619620621622623624625626627628629async function deriveRootKeyFromDerivedKey(630 derivedKeyBytes: Uint8Array,631 slotA: StoredVault,632): Promise<Uint8Array | null> {633 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {634 return null;635 }636 try {637 const rootHex = await decrypt(638 derivedKeyBytes,639 slotA.pw_root_nonce,640 slotA.pw_root_ciphertext,641 slotA.pw_root_tag,642 "pw_root_v1",643 );644 return unhex(rootHex);645 } catch {646 return null;647 }648}649650651652async function wrapRootKeyForDerivedKey(653 derivedKeyBytes: Uint8Array,654 rootKeyBytes: Uint8Array,655): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {656 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");657 return {658 pw_root_ciphertext: ciphertext,659 pw_root_nonce: nonce,660 pw_root_tag: tag,661 };662}663664665666667668669670671export async function migrateToRecovery(): Promise<void> {672 if (!derivedKey || vaultMode !== "real") {673 throw new Error("vault must be unlocked in real mode");674 }675 const slotA = await loadSlot(SLOT_A);676 if (!slotA) throw new Error("missing slot A");677 const hasRecoveryEscrow =678 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;679 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;680 if (hasRecoveryEscrow && hasPwRoot) return; 681682 683 684 685 686 687 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =688 await generateAndWrapRootKey();689 rootKey = unhex(rawRootKey);690691 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);692 const nextSlotA: StoredVault = {693 ...slotA,694 recovery_salt: recoverySalt,695 recovery_wrapped_root_key: wrappedRootKey,696 recovery_nonce: wrappedNonce,697 recovery_tag: wrappedTag,698 ...pwRoot,699 };700 await writeSlot(SLOT_A, nextSlotA);701 await stampLocalVaultMutation();702}703704705706707708709export async function getRecoveryWords(): Promise<string[]> {710 if (!rootKey) {711 throw new Error("recovery not set up — vault may need migration");712 }713 return rootKeyToWords(rootKey);714}715716async function loadSlot(key: string): Promise<StoredVault | null> {717 const result = await chrome.storage.local.get(key);718 return (result as Record<string, StoredVault | undefined>)[key] ?? null;719}720721async function writeSlot(key: string, vault: StoredVault): Promise<void> {722 try {723 await chrome.storage.local.set({ [key]: vault });724 } catch (err) {725 throw mapStorageError(err);726 }727}728729730731732733734735736737738export async function rollbackAdoptedVault(): Promise<void> {739 lock();740 try {741 await chrome.storage.local.remove([742 SLOT_A,743 SLOT_B,744 "peach_last_modified_at",745 ]);746 } catch (err) {747 throw mapStorageError(err);748 }749}750751async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {752 const raw = await chrome.storage.local.get("peach_last_modified_at");753 const previous = Number(754 (raw as Record<string, unknown>).peach_last_modified_at,755 );756 const next = Math.max(757 Date.now(),758 Number.isFinite(previous) ? previous + 1 : 0,759 );760 try {761 await chrome.storage.local.set({762 [SLOT_A]: slotA,763 [SLOT_B]: slotB,764 peach_last_modified_at: String(next),765 });766 } catch (err) {767 throw mapStorageError(err);768 }769}770771772773774775776777778let lastEntryTimestamp = 0;779function monotonicEntryTimestamp(currentModifiedAt?: number): number {780 const base = Math.max(781 Date.now(),782 (currentModifiedAt ?? 0) + 1,783 lastEntryTimestamp + 1,784 );785 lastEntryTimestamp = base;786 return base;787}788789async function stampLocalVaultMutation(): Promise<void> {790 const raw = await chrome.storage.local.get("peach_last_modified_at");791 const previous = Number(792 (raw as Record<string, unknown>).peach_last_modified_at,793 );794 const next = Math.max(795 Date.now(),796 Number.isFinite(previous) ? previous + 1 : 0,797 );798 await chrome.storage.local.set({799 peach_last_modified_at: String(next),800 });801}802803804805806807808async function ensureMigrated(): Promise<void> {809 if (migrationRan) return;810 migrationRan = true;811812 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];813 const raw = await chrome.storage.local.get(keys);814 const slotAExists = raw[SLOT_A] !== undefined;815 const slotBExists = raw[SLOT_B] !== undefined;816 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;817 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;818819 820 if (!slotAExists && !legacyMain) return;821822 823 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;824825 let didMigrate = false;826 if (legacyMain && !slotAExists) {827 await writeSlot(SLOT_A, legacyMain);828 didMigrate = true;829 }830831 832 if (!slotBExists) {833 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;834 if (legacyDecoy) {835 await writeSlot(SLOT_B, legacyDecoy);836 didMigrate = true;837 } else if (target) {838 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));839 didMigrate = true;840 }841 }842843 const toRemove: string[] = [];844 if (legacyMain) toRemove.push(LEGACY_MAIN);845 if (legacyDecoy) toRemove.push(LEGACY_DECOY);846 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);847 if (toRemove.length > 0) {848 await chrome.storage.local.remove(toRemove);849 didMigrate = true;850 }851 if (didMigrate) {852 await stampLocalVaultMutation();853 }854}855856857858859860861862863864865866function padPlaintext(json: string, minBytes = 0): string {867 const encoded = new TextEncoder().encode(json);868 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);869 870 const target = Math.max(bucket, minBytes);871 const padLen = target - encoded.length;872 return json + " ".repeat(padLen);873}874875876877878879880881882883884885const CSPRNG_CHUNK = 65_536;886887function fillRandom(buf: Uint8Array): Uint8Array {888 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {889 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);890 const chunk = new Uint8Array(end - offset);891 crypto.getRandomValues(chunk);892 buf.set(chunk, offset);893 }894 return buf;895}896897function generateNoiseBlob(ciphertextBytes: number): StoredVault {898 const salt = crypto.getRandomValues(new Uint8Array(16));899 const nonce = crypto.getRandomValues(new Uint8Array(12));900 const cipher = fillRandom(new Uint8Array(ciphertextBytes));901 const tag = crypto.getRandomValues(new Uint8Array(16));902 return {903 version: 1,904 salt: hex(salt),905 nonce: hex(nonce),906 ciphertext: hex(cipher),907 tag: hex(tag),908 };909}910911912913914915type VaultPayload = {916 entries: VaultEntry[];917 tombstones?: Record<string, number>;918 duressConfigured?: boolean;919};920921type DecryptedPayload = {922 entries: VaultEntry[];923 tombstones: Map<string, number>;924 duressConfigured: boolean;925 926927928929930 migratedFromLegacy: boolean;931};932933function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {934 const envelope: VaultPayload = {935 entries,936 tombstones: Object.fromEntries(tombstones),937 duressConfigured: duress,938 };939 return padPlaintext(JSON.stringify(envelope), minBytes);940}941942function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {943 const result = new Map<string, number>();944 if (raw && typeof raw === "object" && !Array.isArray(raw)) {945 for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {946 if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {947 result.set(id, timestamp);948 }949 }950 }951 952 953 for (const entry of allEntries) {954 if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {955 continue;956 }957 if ((result.get(entry.id) ?? 0) < entry.deletedAt) {958 result.set(entry.id, entry.deletedAt);959 }960 }961 return result;962}963964function reconcileTombstones(): void {965 const byId = new Map(entries.map((entry) => [entry.id, entry]));966 for (const [id] of tombstones) {967 if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);968 }969 for (const entry of entries) {970 if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {971 if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {972 tombstones.set(entry.id, entry.deletedAt);973 }974 }975 }976}977978function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {979 if ("created" in raw || "updated" in raw) return true;980 const flat = raw as Partial<VaultEntry>;981 if (flat.type === "passkey") return true;982 983 if (flat.rpId !== undefined && flat.passkey === undefined) return true;984 return false;985}986987function decodePayload(plain: string): DecryptedPayload {988 const parsed = JSON.parse(plain) as989 | Array<VaultEntry | LegacyVaultEntry>990 | VaultPayload;991992 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];993 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);994995 const normalized = rawEntries.map(normalizeEntry);996 const { merged } = mergePasskeyOnlyIntoLogins(normalized);997 const decodedTombstones = decodeTombstones(998 Array.isArray(parsed) ? undefined : parsed.tombstones,999 normalized,1000 );10011002 if (Array.isArray(parsed)) {1003 return {1004 entries: normalized,1005 tombstones: decodedTombstones,1006 duressConfigured: false,1007 migratedFromLegacy: true, 1008 };1009 }10101011 return {1012 entries: normalized,1013 tombstones: decodedTombstones,1014 duressConfigured: parsed.duressConfigured === true,1015 migratedFromLegacy:1016 flatMigrationNeeded || merged || !Object.hasOwn(parsed, "tombstones"),1017 };1018}1019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {1044 let merged = false;1045 const now = Date.now();10461047 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);1048 const byRpId = new Map<string, VaultEntry[]>();1049 for (const e of candidates) {1050 const rpId = e.passkey!.rpId;1051 const list = byRpId.get(rpId);1052 if (list) list.push(e);1053 else byRpId.set(rpId, [e]);1054 }10551056 for (const [rpId, group] of byRpId) {1057 if (group.length === 0) continue;1058 group.sort((a, b) => {1059 const aT =1060 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;1061 const bT =1062 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;1063 return bT - aT;1064 });1065 const winner = group[0]!;10661067 1068 1069 1070 const loginTarget = entries.find((e) => {1071 if (e.deletedAt || e.passkey || e.type !== "login") return false;1072 if (e.url && extractDomain(e.url) === rpId) return true;1073 for (const extra of e.additionalUrls ?? []) {1074 if (extractDomain(extra) === rpId) return true;1075 }1076 return false;1077 });10781079 if (loginTarget) {1080 loginTarget.passkey = winner.passkey;1081 1082 1083 1084 if (!loginTarget.totp && winner.totp) {1085 loginTarget.totp = winner.totp;1086 loginTarget.totpAlgorithm = winner.totpAlgorithm;1087 loginTarget.totpDigits = winner.totpDigits;1088 loginTarget.totpPeriod = winner.totpPeriod;1089 }1090 loginTarget.modifiedAt = now;1091 for (const dup of group) {1092 if (!dup.deletedAt) {1093 dup.deletedAt = now;1094 dup.modifiedAt = now;1095 }1096 }1097 merged = true;1098 } else if (group.length > 1) {1099 for (let i = 1; i < group.length; i += 1) {1100 const dup = group[i]!;1101 if (!dup.deletedAt) {1102 dup.deletedAt = now;1103 dup.modifiedAt = now;1104 }1105 }1106 merged = true;1107 }1108 }11091110 return { merged };1111}11121113111411151116111711181119function hasValidVaultSchema(v: unknown): v is StoredVault {1120 if (typeof v !== "object" || v === null) return false;1121 const r = v as Record<string, unknown>;1122 return (1123 r.version === 1 &&1124 typeof r.salt === "string" &&1125 typeof r.nonce === "string" &&1126 typeof r.ciphertext === "string" &&1127 typeof r.tag === "string"1128 );1129}11301131export type TryDecryptResult =1132 | { status: "ok"; payload: DecryptedPayload }1133 | { status: "wrong_key" }1134 | { status: "corrupted"; reason: string };1135113611371138113911401141export async function tryDecryptSlot(1142 vault: StoredVault,1143 key: Uint8Array,1144 aadTag?: string,1145): Promise<DecryptedPayload | null> {1146 const res = await tryDecryptSlotResult(vault, key, aadTag);1147 return res.status === "ok" ? res.payload : null;1148}11491150export async function tryDecryptSlotResult(1151 vault: StoredVault,1152 key: Uint8Array,1153 aadTag?: string,1154): Promise<TryDecryptResult> {1155 if (!hasValidVaultSchema(vault)) {1156 return { status: "corrupted", reason: "invalid_slot_schema" };1157 }1158 let plain: string;1159 try {1160 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1161 } catch {1162 return { status: "wrong_key" };1163 }1164 try {1165 const parsed = JSON.parse(plain) as unknown;1166 if (typeof parsed !== "object" || parsed === null) {1167 return { status: "corrupted", reason: "invalid_payload_shape" };1168 }1169 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1170 return { status: "corrupted", reason: "invalid_payload_shape" };1171 }1172 return { status: "ok", payload: decodePayload(plain) };1173 } catch {1174 return { status: "corrupted", reason: "invalid_payload_json" };1175 }1176}117711781179118011811182function buildDomainIndexSync(all: VaultEntry[]): void {1183 domainIndex = new Map();11841185 for (const entry of all) {1186 if (entry.deletedAt) continue;1187 if (entry.type === "note" || entry.type === "card") continue;11881189 1190 1191 1192 1193 const domains = new Set<string>();1194 const primary = extractDomain(entry.url ?? "");1195 if (primary) domains.add(primary);1196 for (const extra of entry.additionalUrls ?? []) {1197 const d = extractDomain(extra);1198 if (d) domains.add(d);1199 }1200 if (domains.size === 0) continue;12011202 for (const domain of domains) {1203 const bucket = domainIndex.get(domain) ?? [];1204 bucket.push(entry);1205 domainIndex.set(domain, bucket);1206 }1207 }1208}12091210export async function createVault(password: string): Promise<void> {1211 await ensureMigrated();12121213 const salt = generateSalt();1214 derivedKey = await deriveKey(password, salt);1215 entries = [];1216 tombstones = new Map();1217 vaultMode = "real";1218 duressConfigured = false;1219 buildDomainIndexSync(entries);12201221 1222 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1223 await generateAndWrapRootKey();1224 rootKey = unhex(rawRootKey);12251226 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1227 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");12281229 1230 1231 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);12321233 const slotA: StoredVault = {1234 version: 1,1235 salt: hex(salt),1236 nonce,1237 ciphertext,1238 tag,1239 recovery_salt: recoverySalt,1240 recovery_wrapped_root_key: wrappedRootKey,1241 recovery_nonce: wrappedNonce,1242 recovery_tag: wrappedTag,1243 ...pwRoot,1244 };1245 await writeSlot(SLOT_A, slotA);12461247 1248 1249 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1250 await stampLocalVaultMutation();12511252 touchAutoLock();1253}1254125512561257125812591260export async function createVaultFromRootKey(1261 password: string,1262 rootKeyHex: string,1263): Promise<{ vaultId: string }> {1264 if (typeof password !== "string" || password.length === 0) {1265 throw new Error("ADOPT_INVALID_PASSWORD");1266 }1267 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1268 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1269 }12701271 const rootKeyBytes = unhex(rootKeyHex);1272 if (rootKeyBytes.length !== 32) {1273 rootKeyBytes.fill(0);1274 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1275 }12761277 let nextDerivedKey: Uint8Array | null = null;1278 let sessionRootKey: Uint8Array | null = null;1279 try {1280 await ensureMigrated();1281 if ((await loadSlot(SLOT_A)) !== null) {1282 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1283 }12841285 const nextEntries: VaultEntry[] = [];1286 const nextDuressConfigured = false;1287 const vaultId = await computeVaultId(rootKeyBytes);1288 const salt = generateSalt();1289 nextDerivedKey = await deriveKey(password, salt);1290 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);12911292 sessionRootKey = new Uint8Array(rootKeyBytes);1293 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1294 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1295 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1296 const slotA: StoredVault = {1297 version: 1,1298 salt: hex(salt),1299 nonce,1300 ciphertext,1301 tag,1302 recovery_salt: recovery.recoverySalt,1303 recovery_wrapped_root_key: recovery.wrappedRootKey,1304 recovery_nonce: recovery.wrappedNonce,1305 recovery_tag: recovery.wrappedTag,1306 ...pwRoot,1307 };1308 const slotB = generateNoiseBlob(ciphertext.length / 2);1309 await commitCreatedVaultSlots(slotA, slotB);13101311 const previousDerivedKey = derivedKey;1312 const previousRootKey = rootKey;1313 derivedKey = nextDerivedKey;1314 rootKey = sessionRootKey;1315 nextDerivedKey = null;1316 sessionRootKey = null;1317 entries = nextEntries;1318 tombstones = new Map();1319 vaultMode = "real";1320 duressConfigured = nextDuressConfigured;1321 buildDomainIndexSync(entries);1322 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1323 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1324 touchAutoLock();13251326 return { vaultId };1327 } finally {1328 rootKeyBytes.fill(0);1329 if (nextDerivedKey) zeroKey(nextDerivedKey);1330 if (sessionRootKey) sessionRootKey.fill(0);1331 }1332}13331334export async function hasVault(): Promise<boolean> {1335 await ensureMigrated();1336 return (await loadSlot(SLOT_A)) !== null;1337}1338133913401341134213431344export async function isDuressEnabled(): Promise<boolean> {1345 return vaultMode === "real" && duressConfigured;1346}13471348export async function setupDuress(duressPassword: string): Promise<void> {1349 if (!derivedKey || vaultMode !== "real") {1350 throw new Error("vault must be unlocked in real mode");1351 }1352 await ensureMigrated();13531354 1355 1356 1357 const slotA = await loadSlot(SLOT_A);1358 if (!slotA) throw new Error("missing slot A");13591360 const decoySalt = generateSalt();1361 const decoyKey = await deriveKey(duressPassword, decoySalt);1362 try {1363 1364 1365 1366 1367 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1368 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1369 await writeSlot(SLOT_B, {1370 version: 1,1371 salt: hex(decoySalt),1372 nonce,1373 ciphertext,1374 tag,1375 });1376 } finally {1377 zeroKey(decoyKey);1378 }13791380 1381 1382 duressConfigured = true;1383 await persistSlotA();1384}13851386export async function disableDuress(): Promise<void> {1387 if (!derivedKey || vaultMode !== "real") {1388 throw new Error("vault must be unlocked in real mode");1389 }1390 await ensureMigrated();13911392 1393 1394 1395 const slotA = await loadSlot(SLOT_A);1396 if (slotA) {1397 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1398 }13991400 duressConfigured = false;1401 await persistSlotA();1402}14031404140514061407140814091410export async function verifyMainPassword(password: string): Promise<boolean> {1411 await ensureMigrated();1412 const slotA = await loadSlot(SLOT_A);1413 if (!slotA) return false;1414 const key = await deriveKey(password, unhex(slotA.salt));1415 try {1416 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1417 } finally {1418 zeroKey(key);1419 }1420}14211422export async function unlock(password: string): Promise<boolean> {1423 1424 1425 1426 if (await attemptUnlockWithDerive(password, deriveKey)) return true;14271428 const nfc = password.normalize("NFC");1429 if (password === nfc) return false; 14301431 if (await attemptUnlockWithDerive(password, deriveKeyRaw)) {1432 1433 1434 1435 if (vaultMode === "real") {1436 try {1437 const newSalt = generateSalt();1438 const newKey = await deriveKey(password, newSalt);1439 await changePasswordWithNewKey(newSalt, newKey);1440 } catch (healErr) {1441 console.warn("[Peach] LV-1 NFC heal failed:", healErr);1442 }1443 }1444 return true;1445 }1446 return false;1447}14481449async function attemptUnlockWithDerive(1450 password: string,1451 derive: typeof deriveKey,1452): Promise<boolean> {1453 await ensureMigrated();14541455 1456 1457 1458 1459 1460 const [slotA, slotB] = await Promise.all([1461 loadSlot(SLOT_A),1462 loadSlot(SLOT_B),1463 ]);14641465 1466 const [keyA, keyB] = await Promise.all([1467 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1468 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1469 ]);14701471 1472 1473 1474 const [resultA, resultB] = await Promise.all([1475 keyA && slotA1476 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1477 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1478 keyB && slotB1479 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1480 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1481 ]);1482 const payloadA = resultA.status === "ok" ? resultA.payload : null;1483 const payloadB = resultB.status === "ok" ? resultB.payload : null;14841485 if (payloadA && keyA) {1486 derivedKey = keyA;1487 void saveSessionKey(keyA, "real");1488 vaultMode = "real";1489 entries = payloadA.entries;1490 tombstones = payloadA.tombstones;1491 duressConfigured = payloadA.duressConfigured;1492 buildDomainIndexSync(entries);1493 await loadSettings();1494 touchAutoLock();1495 if (keyB) zeroKey(keyB);14961497 1498 if (slotA) {1499 const needsRecoverySetup =1500 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1501 if (!needsRecoverySetup) {1502 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1503 }1504 1505 1506 1507 1508 if (needsRecoverySetup || !rootKey) {1509 try {1510 await migrateToRecovery();1511 if (needsRecoverySetup) {1512 1513 1514 1515 await chrome.storage.local.set({ peach_recovery_pending: '1' });1516 }1517 } catch (migrationErr) {1518 console.warn('[Peach] recovery migration failed:', migrationErr);1519 }1520 }1521 }15221523 1524 1525 1526 if (payloadA.migratedFromLegacy) {1527 await persistSlotA();1528 }1529 await lockedSyncUnlockHook?.().catch((error) => {1530 console.warn("[Peach sync] locked inbox drain failed:", error);1531 });1532 return true;1533 }15341535 if (payloadB && keyB) {1536 derivedKey = keyB;1537 void saveSessionKey(keyB, "decoy");1538 vaultMode = "decoy";1539 entries = payloadB.entries;1540 tombstones = payloadB.tombstones;1541 duressConfigured = false;1542 buildDomainIndexSync(entries);1543 await loadSettings();1544 touchAutoLock();1545 if (keyA) zeroKey(keyA);1546 return true;1547 }15481549 if (keyA) zeroKey(keyA);1550 if (keyB) zeroKey(keyB);1551 1552 1553 if (resultA.status === "corrupted") {1554 lastUnlockFailureReason = resultA.reason;1555 } else if (resultB.status === "corrupted") {1556 lastUnlockFailureReason = resultB.reason;1557 }1558 return false;1559}15601561156215631564156515661567const SESSION_KEY_STORAGE = "peach_session_dk";15681569async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1570 if (!chrome.storage.session) return;1571 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1572}15731574async function clearSessionKey(): Promise<void> {1575 if (!chrome.storage.session) return;1576 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1577}157815791580158115821583158415851586export async function restoreFromSession(): Promise<boolean> {1587 if (derivedKey !== null) return true;1588 if (!chrome.storage.session) return false;1589 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1590 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1591 if (!entry?.keyHex) return false;15921593 const key = unhex(entry.keyHex);1594 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1595 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1596 const slot = await loadSlot(slotKey);1597 if (!slot) { await clearSessionKey(); return false; }15981599 const result = await tryDecryptSlotResult(slot, key, aadTag);1600 if (result.status !== "ok") { await clearSessionKey(); return false; }16011602 derivedKey = key;1603 vaultMode = entry.mode;1604 entries = result.payload.entries;1605 tombstones = result.payload.tombstones;1606 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1607 buildDomainIndexSync(entries);1608 await loadSettings();1609 touchAutoLock();16101611 1612 1613 1614 1615 1616 1617 1618 1619 1620 if (entry.mode === "real") {1621 try {1622 rootKey = await deriveRootKeyFromDerivedKey(key, slot);1623 } catch {1624 1625 }1626 }16271628 await lockedSyncUnlockHook?.().catch((error) => {1629 console.warn("[Peach sync] locked inbox drain failed:", error);1630 });16311632 return true;1633}16341635export function lock(): void {1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 void clearSessionKey();1646 if (derivedKey) {1647 zeroKey(derivedKey);1648 derivedKey = null;1649 }1650 if (rootKey) {1651 rootKey.fill(0);1652 rootKey = null;1653 }16541655 entries = [];1656 tombstones = new Map();1657 domainIndex = new Map();1658 vaultMode = "real";1659 duressConfigured = false;1660 clearAutoLock();1661}16621663export function isUnlocked(): boolean {1664 return derivedKey !== null;1665}16661667166816691670export async function unlockWithKey(1671 masterKeyHex: string,1672): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1673 try {1674 const masterKeyBytes = unhex(masterKeyHex);1675 const stored = await loadSlot(SLOT_A);1676 if (!stored) return { ok: false, error: "invalid_key" };1677 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1678 if (!payload) return { ok: false, error: "invalid_key" };16791680 derivedKey = masterKeyBytes;1681 vaultMode = "real";1682 entries = payload.entries;1683 tombstones = payload.tombstones;1684 duressConfigured = payload.duressConfigured;1685 buildDomainIndexSync(entries);1686 await loadSettings();1687 touchAutoLock();16881689 1690 1691 1692 1693 1694 if (stored.recovery_salt && stored.recovery_wrapped_root_key) {1695 try {1696 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1697 } catch { }1698 }1699 return { ok: true, entries };1700 } catch {1701 return { ok: false, error: "invalid_key" };1702 }1703}170417051706170717081709171017111712export async function exportDuressSlotForAdoption(): Promise<string | null> {1713 if (!(await isDuressEnabled())) return null;1714 await ensureMigrated();1715 const slotB = await loadSlot(SLOT_B);1716 if (!slotB) return null;1717 return JSON.stringify(slotB);1718}17191720172117221723172417251726export async function installAdoptedDuressSlot(json: string): Promise<void> {1727 let parsed: StoredVault;1728 try {1729 const raw = JSON.parse(json) as unknown;1730 if (!hasValidVaultSchema(raw)) {1731 throw new Error("invalid_duress_slot_shape");1732 }1733 parsed = raw;1734 } catch (err) {1735 if (err instanceof SyntaxError) {1736 throw new Error("invalid_duress_slot_shape");1737 }1738 throw err;1739 }1740 await writeSlot(SLOT_B, parsed);1741}17421743174417451746174717481749175017511752175317541755175617571758175917601761export async function unlockFromCodex(params: {1762 slotA: string; 1763 slotB: string | null; 1764 timestamp: number;1765 masterPassphrase: string;1766}): Promise<1767 | { ok: true; entryCount: number }1768 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1769> {1770 let parsedSlotA: StoredVault;1771 let parsedSlotB: StoredVault | null = null;1772 try {1773 const rawA = JSON.parse(params.slotA) as unknown;1774 if (!hasValidVaultSchema(rawA)) {1775 return { ok: false, reason: "invalid_slot_format" };1776 }1777 parsedSlotA = rawA;1778 if (params.slotB !== null) {1779 const rawB = JSON.parse(params.slotB) as unknown;1780 if (!hasValidVaultSchema(rawB)) {1781 return { ok: false, reason: "invalid_slot_format" };1782 }1783 parsedSlotB = rawB;1784 }1785 } catch {1786 return { ok: false, reason: "invalid_slot_format" };1787 }17881789 1790 1791 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1792 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1793 if (!trialPayload) {1794 zeroKey(trialKey);1795 return { ok: false, reason: "wrong_passphrase" };1796 }1797 1798 1799 zeroKey(trialKey);18001801 1802 1803 await takeVaultSnapshot("restore");18041805 await writeSlot(SLOT_A, parsedSlotA);1806 if (parsedSlotB) {1807 await writeSlot(SLOT_B, parsedSlotB);1808 } else {1809 1810 1811 1812 await writeSlot(1813 SLOT_B,1814 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1815 );1816 }1817 await chrome.storage.local.set({1818 peach_last_modified_at: String(params.timestamp),1819 });18201821 1822 1823 const ok = await unlock(params.masterPassphrase);1824 if (!ok) {1825 1826 1827 1828 return { ok: false, reason: "wrong_passphrase" };1829 }1830 return { ok: true, entryCount: getEntries().length };1831}18321833183418351836export function getDerivedKeyHex(): string {1837 if (!derivedKey) throw new Error("vault_not_unlocked");1838 return hex(derivedKey);1839}18401841184218431844184518461847184818491850185118521853185418551856export function getRootKeyHex(): string | null {1857 return rootKey ? hex(rootKey) : null;1858}18591860export function getEntries(): VaultEntry[] {1861 if (!derivedKey) throw new Error("vault_locked");1862 return entries.filter((entry) => !entry.deletedAt);1863}18641865const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;18661867export function getIdentities(): SavedIdentity[] {1868 if (!derivedKey) throw new Error("vault_locked");1869 const active = entries.filter((e) => !e.deletedAt && e.type === "login");18701871 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1872 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();18731874 for (const entry of active) {1875 const domain = extractDomain(entry.url ?? "") ?? "";1876 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;18771878 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1879 const existing = map.get(v);1880 if (!existing || modifiedAt > existing.lastUsed) {1881 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1882 } else {1883 map.set(v, { ...existing, count: existing.count + 1 });1884 }1885 }18861887 const uVal = entry.username?.trim();1888 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);18891890 1891 1892 const eVal = entry.email?.trim();1893 if (eVal) tally(emailMap, eVal);1894 }18951896 const toIdentity = (type: "email" | "username") =>1897 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1898 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1899 });19001901 return [1902 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1903 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1904 ];1905}19061907export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1908 if (!derivedKey) throw new Error("vault_locked");19091910 const now = Date.now();1911 const added: VaultEntry[] = [];1912 for (const draft of drafts) {1913 try {1914 const sanitized = sanitizeEntryDraft(draft);1915 const nextEntry: VaultEntry = {1916 ...sanitized,1917 id: crypto.randomUUID(),1918 createdAt: now,1919 modifiedAt: monotonicEntryTimestamp(),1920 };1921 entries.push(nextEntry);1922 added.push(nextEntry);1923 } catch {1924 1925 }1926 }19271928 if (added.length > 0) {1929 await persistEntries();1930 }19311932 return added;1933}193419351936193719381939194019411942export async function restoreEntries(1943 incoming: VaultEntry[],1944 mode: "replace" | "merge"1945): Promise<{ added: number; updated: number; skipped: number }> {1946 if (!derivedKey) throw new Error("vault_locked");19471948 1949 1950 1951 if (mode === "replace" && incoming.length === 0) {1952 throw new Error("refuse to replace with empty entry set");1953 }19541955 let added = 0;1956 let updated = 0;1957 let skipped = 0;19581959 if (mode === "replace") {1960 entries = [];1961 tombstones = new Map();1962 for (const entry of incoming) {1963 const normalized = sanitizeRestoredEntry(entry);1964 if (!normalized) {1965 skipped += 1;1966 continue;1967 }1968 entries.push(normalized);1969 if (normalized.deletedAt) {1970 tombstones.set(normalized.id, normalized.deletedAt);1971 }1972 added += 1;1973 }1974 } else {1975 const byId = new Map<string, VaultEntry>();1976 for (const e of entries) byId.set(e.id, e);19771978 for (const entry of incoming) {1979 const normalized = sanitizeRestoredEntry(entry);1980 if (!normalized) {1981 skipped += 1;1982 continue;1983 }1984 const existing = byId.get(normalized.id);1985 if (!existing) {1986 entries.push(normalized);1987 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1988 else tombstones.delete(normalized.id);1989 added += 1;1990 } else if (normalized.modifiedAt > existing.modifiedAt) {1991 const idx = entries.findIndex((e) => e.id === existing.id);1992 if (idx !== -1) {1993 entries[idx] = normalized;1994 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1995 else tombstones.delete(normalized.id);1996 updated += 1;1997 }1998 } else {1999 skipped += 1;2000 }2001 }2002 }20032004 await persistEntries("import");2005 return { added, updated, skipped };2006}200720082009201020112012201320142015function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2016 if (!entry || typeof entry !== "object") return null;2017 const title = typeof entry.title === "string" ? entry.title.trim() : "";2018 if (!title) return null;2019 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2020 const now = Date.now();2021 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2022 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;20232024 2025 2026 2027 2028 const normalized = normalizeEntry({2029 ...entry,2030 id,2031 title,2032 type: entry.type ?? "login",2033 createdAt,2034 modifiedAt,2035 });20362037 if (normalized.passkey) {2038 normalized.passkey = sanitizePasskey(normalized.passkey);2039 }2040 if (normalized.canary) {2041 normalized.canary = sanitizeCanary(normalized.canary);2042 }2043 2044 normalized.tags = sanitizeTags(normalized.tags);20452046 2047 2048 if (2049 normalized.type === "login" &&2050 !normalized.passkey &&2051 typeof normalized.password !== "string"2052 ) {2053 return null;2054 }2055 if (entry.passkey && !normalized.passkey) {2056 2057 2058 return null;2059 }20602061 return normalized;2062}2063206420652066206720682069function sanitizePasskey(pk: unknown): PasskeyData | undefined {2070 if (!pk || typeof pk !== "object") return undefined;2071 const r = pk as Record<string, unknown>;2072 if (2073 typeof r.rpId !== "string" || !r.rpId ||2074 typeof r.credentialId !== "string" || !r.credentialId ||2075 typeof r.publicKeyCose !== "string" ||2076 typeof r.userHandle !== "string" ||2077 typeof r.userName !== "string"2078 ) {2079 return undefined;2080 }2081 return {2082 rpId: r.rpId,2083 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2084 credentialId: r.credentialId,2085 publicKeyCose: r.publicKeyCose,2086 privateKeyJwk:2087 r.privateKeyJwk && typeof r.privateKeyJwk === "object"2088 ? (r.privateKeyJwk as JsonWebKey)2089 : undefined,2090 userHandle: r.userHandle,2091 userName: r.userName,2092 userDisplayName:2093 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2094 algorithm: r.algorithm === -8 ? -8 : -7,2095 signCount: typeof r.signCount === "number" ? r.signCount : 0,2096 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2097 backupEligible: r.backupEligible === true,2098 backupState: r.backupState === true,2099 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2100 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2101 transports: Array.isArray(r.transports)2102 ? r.transports.filter((t): t is string => typeof t === "string")2103 : undefined,2104 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2105 };2106}21072108210921102111211221132114211521162117function sanitizeCanary(c: unknown): Canary | undefined {2118 if (!c || typeof c !== "object") return undefined;2119 const r = c as Record<string, unknown>;2120 if (typeof r.webhookUrl !== "string") return undefined;2121 let url: URL;2122 try {2123 url = new URL(r.webhookUrl);2124 } catch {2125 return undefined;2126 }2127 if (url.protocol !== "https:") return undefined;2128 return {2129 webhookUrl: url.toString(),2130 label: typeof r.label === "string" ? r.label : undefined,2131 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2132 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2133 };2134}21352136213721382139214021412142function sanitizeTags(tags: unknown): string[] | undefined {2143 if (!Array.isArray(tags)) return undefined;2144 const out: string[] = [];2145 for (const t of tags) {2146 if (typeof t !== "string") continue;2147 const trimmed = t.trim();2148 if (!trimmed) continue;2149 out.push(trimmed.slice(0, 256));2150 if (out.length >= 255) break;2151 }2152 return out.length > 0 ? out : undefined;2153}215421552156215721582159export async function clearVaultEntries(2160 confirmation: "REPLACE_ALL"2161): Promise<void> {2162 if (confirmation !== "REPLACE_ALL") {2163 throw new Error("clearVaultEntries requires explicit confirmation");2164 }2165 if (!derivedKey) throw new Error("vault_locked");2166 for (const entry of entries) {2167 if (!entry.deletedAt) {2168 const stamp = monotonicEntryTimestamp(Math.max(2169 entry.modifiedAt,2170 tombstones.get(entry.id) ?? 0,2171 ));2172 entry.deletedAt = stamp;2173 entry.modifiedAt = stamp;2174 tombstones.set(entry.id, stamp);2175 }2176 }2177 await persistEntries();2178}21792180export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2181 if (!derivedKey) throw new Error("vault_locked");21822183 const sanitized = sanitizeEntryDraft(entry);2184 const now = Date.now();2185 const stamp = monotonicEntryTimestamp();2186 const nextEntry: VaultEntry = {2187 ...sanitized,2188 id: crypto.randomUUID(),2189 createdAt: now,2190 modifiedAt: stamp,2191 2192 2193 passwordChangedAt: sanitized.password ? stamp : undefined,2194 };21952196 2197 2198 2199 const rollback = entries;2200 entries = [...entries, nextEntry];2201 try {2202 await persistEntries();2203 } catch (err) {2204 entries = rollback;2205 throw err;2206 }22072208 2209 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2210 checkBreach(nextEntry.password).then(async (count) => {2211 if (count > 0) {2212 const idx = entries.findIndex((e) => e.id === nextEntry.id);2213 if (idx !== -1) {2214 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2215 await persistEntries();2216 }2217 }2218 });2219 }22202221 fireSyncTrigger("vault_change");22222223 return nextEntry;2224}22252226222722282229223022312232export async function consolidatePasskeyEntries(): Promise<void> {2233 if (!derivedKey) return;2234 const { merged } = mergePasskeyOnlyIntoLogins(entries);2235 if (merged) {2236 await persistEntries();2237 }2238}22392240export async function updateEntry(2241 id: string,2242 patch: Partial<VaultEntry>2243): Promise<void> {2244 if (!derivedKey) throw new Error("vault_locked");22452246 const idx = entries.findIndex((entry) => entry.id === id);2247 if (idx === -1 || entries[idx].deletedAt) {2248 throw new Error("entry_not_found");2249 }22502251 const current = entries[idx];2252 const sanitizedPatch = sanitizeEntryPatch(patch);2253 2254 2255 2256 const passwordChanged =2257 "password" in patch &&2258 typeof sanitizedPatch.password === "string" &&2259 sanitizedPatch.password !== current.password;2260 const stamp = monotonicEntryTimestamp(current.modifiedAt);2261 2262 2263 2264 2265 2266 2267 2268 const priorPassword = current.password;2269 const shouldCapture =2270 passwordChanged &&2271 typeof priorPassword === "string" &&2272 priorPassword.length > 0 &&2273 current.passwordHistory?.[0]?.password !== priorPassword;2274 const nextHistory = shouldCapture2275 ? [2276 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2277 ...(current.passwordHistory ?? []),2278 ].slice(0, PASSWORD_HISTORY_MAX)2279 : current.passwordHistory;2280 entries[idx] = {2281 ...current,2282 ...sanitizedPatch,2283 modifiedAt: stamp,2284 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2285 };2286228722882289 2290 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2291 entries[idx] = current;2292 throw new Error("card number is required for card entries");2293 }22942295 2296 2297 2298 2299 try {2300 await persistEntries();2301 } catch (err) {2302 entries[idx] = current;2303 throw err;2304 }23052306 2307 2308 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2309 entries[idx] = { ...entries[idx], breach: undefined };2310 await persistEntries();2311 const password = entries[idx].password;2312 if (password) {2313 checkBreach(password).then(async (count) => {2314 const i = entries.findIndex((e) => e.id === id);2315 if (i !== -1 && count > 0) {2316 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2317 await persistEntries();2318 }2319 });2320 }2321 }23222323 fireSyncTrigger("vault_change");2324}23252326export async function deleteEntry(id: string): Promise<void> {2327 if (!derivedKey) throw new Error("vault_locked");23282329 const entry = entries.find((item) => item.id === id);2330 if (!entry || entry.deletedAt) {2331 throw new Error("entry_not_found");2332 }23332334 2335 2336 2337 const previousModifiedAt = entry.modifiedAt;2338 const previousTombstone = tombstones.get(id);2339 const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2340 entry.deletedAt = stamp;2341 entry.modifiedAt = stamp;2342 tombstones.set(id, stamp);2343 2344 2345 try {2346 await persistEntries();2347 } catch (err) {2348 delete entry.deletedAt;2349 entry.modifiedAt = previousModifiedAt;2350 if (previousTombstone === undefined) tombstones.delete(id);2351 else tombstones.set(id, previousTombstone);2352 throw err;2353 }2354 fireSyncTrigger("vault_change");2355}23562357export function getDeletedEntries(): VaultEntry[] {2358 if (!derivedKey) return [];2359 return entries.filter((e) => !!e.deletedAt);2360}236123622363export function getTombstoneManifest(): Record<string, number> {2364 if (!derivedKey) return {};2365 return Object.fromEntries(tombstones);2366}2367236823692370237123722373export async function getVaultVersion(): Promise<number> {2374 try {2375 const raw = await chrome.storage.local.get("peach_last_modified_at");2376 const v = (raw as Record<string, unknown>).peach_last_modified_at;2377 if (typeof v === "number") return v;2378 if (typeof v === "string" && v !== "") {2379 const n = Number.parseInt(v, 10);2380 return Number.isFinite(n) ? n : 0;2381 }2382 return 0;2383 } catch {2384 return 0;2385 }2386}2387238823892390239123922393239423952396239723982399export async function applyMergedRemoteEntries(2400 remoteEntries: VaultEntry[],2401 remoteTombstones: Record<string, number>,2402 _context: { deviceName?: string } = {},2403): Promise<{ received: number; applied: number; conflicts: number }> {2404 2405 2406 2407 2408 2409 2410 if (!derivedKey) throw new Error("vault_locked");24112412 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2413 let applied = 0;2414 let conflicts = 0;24152416 const byId = new Map<string, VaultEntry>();2417 for (const e of entries) byId.set(e.id, e);24182419 const mergeNow = Date.now();2420 const remoteTombstoneTimes = new Map<string, number>();2421 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2422 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2423 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2424 }2425 }24262427 for (const remote of remoteEntries) {2428 const sanitized = sanitizeRestoredEntry(remote);2429 if (!sanitized) continue;2430 2431 2432 2433 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2434 if (typeof sanitized.deletedAt === "number") {2435 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2436 }2437 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2438 const localTombstoneTs = tombstones.get(sanitized.id);2439 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2440 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2441 continue;2442 }2443 const local = byId.get(sanitized.id);2444 if (!local) {2445 entries.push(sanitized);2446 byId.set(sanitized.id, sanitized);2447 tombstones.delete(sanitized.id);2448 applied += 1;2449 } else if (sanitized.modifiedAt > local.modifiedAt) {2450 const idx = entries.findIndex((e) => e.id === local.id);2451 if (idx !== -1) {2452 entries[idx] = sanitized;2453 byId.set(sanitized.id, sanitized);2454 tombstones.delete(sanitized.id);2455 applied += 1;2456 if (!local.deletedAt) conflicts += 1;2457 }2458 }2459 }24602461 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2462 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2463 2464 const deletedAt = remoteTombstoneTimes.get(id)!;2465 const knownTombstone = tombstones.get(id);2466 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2467 const local = byId.get(id);2468 if (!local) {2469 tombstones.set(id, deletedAt);2470 applied += 1;2471 continue;2472 }2473 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2474 if (deletedAt < local.modifiedAt) continue;2475 const idx = entries.findIndex((e) => e.id === id);2476 if (idx !== -1) {2477 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2478 tombstones.set(id, deletedAt);2479 applied += 1;2480 }2481 }24822483 if (applied > 0) {2484 await persistEntries("sync_received");2485 buildDomainIndexSync(entries);2486 }2487 return { received, applied, conflicts };2488}24892490export async function restoreEntry(id: string): Promise<void> {2491 if (!derivedKey) throw new Error("vault_locked");2492 const entry = entries.find((e) => e.id === id && e.deletedAt);2493 if (!entry) throw new Error("deleted entry not found");2494 const previousDeletedAt = entry.deletedAt;2495 const previousModifiedAt = entry.modifiedAt;2496 const previousTombstone = tombstones.get(id);2497 delete entry.deletedAt;2498 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2499 entry.modifiedAt,2500 previousTombstone ?? 0,2501 ));2502 tombstones.delete(id);2503 try {2504 await persistEntries();2505 } catch (err) {2506 entry.deletedAt = previousDeletedAt;2507 entry.modifiedAt = previousModifiedAt;2508 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2509 throw err;2510 }2511 buildDomainIndexSync(entries);2512}25132514export async function purgeEntry(id: string): Promise<void> {2515 if (!derivedKey) throw new Error("vault_locked");2516 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2517 if (idx === -1) throw new Error("deleted entry not found");2518 const entry = entries[idx]!;2519 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2520 entries.splice(idx, 1);2521 await persistEntries();2522}252325242525export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2526 if (!derivedKey || retentionMs === 0) return 0;2527 const cutoff = Date.now() - retentionMs;2528 const before = entries.length;2529 for (const entry of entries) {2530 if (entry.deletedAt && entry.deletedAt < cutoff) {2531 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2532 }2533 }2534 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2535 const purged = before - entries.length;2536 if (purged > 0) await persistEntries();2537 return purged;2538}25392540export function queryDomain(domain: string): VaultEntry[] {2541 return domainIndex.get(domain) ?? [];2542}25432544export function getEntryById(id: string): VaultEntry | undefined {2545 return entries.find((entry) => entry.id === id && !entry.deletedAt);2546}25472548async function persistSlotA(2549 reason: VaultSnapshot["reason"] = "local_save",2550): Promise<StoredVault> {2551 if (!derivedKey) throw new Error("vault_locked");25522553 const slotA = await loadSlot(SLOT_A);2554 if (!slotA) throw new Error("missing slot A");25552556 2557 2558 await takeVaultSnapshot(reason);25592560 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2561 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");25622563 2564 const nextSlotA: StoredVault = {2565 ...slotA,2566 nonce,2567 ciphertext,2568 tag,2569 2570 };2571 await writeSlot(SLOT_A, nextSlotA);25722573 2574 2575 2576 2577 2578 2579 try {2580 2581 2582 2583 if (!duressConfigured) {2584 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2585 }2586 2587 2588 2589 2590 2591 if (reason !== "sync_received") {2592 await stampLocalVaultMutation();2593 }2594 } catch (err) {2595 2596 2597 await writeSlot(SLOT_A, slotA).catch(() => {});2598 throw err;2599 }26002601 return nextSlotA;2602}26032604async function persistSlotB(): Promise<void> {2605 if (!derivedKey) throw new Error("vault_locked");26062607 const slotB = await loadSlot(SLOT_B);2608 if (!slotB) throw new Error("missing slot B");26092610 2611 2612 2613 2614 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2615 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");26162617 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2618 2619 2620 2621 try {2622 await stampLocalVaultMutation();2623 } catch (err) {2624 await writeSlot(SLOT_B, slotB).catch(() => {});2625 throw err;2626 }2627}26282629async function persistEntries(2630 reason: VaultSnapshot["reason"] = "local_save",2631): Promise<void> {2632 if (!derivedKey) throw new Error("vault_locked");26332634 2635 2636 const previousTombstones = tombstones;2637 tombstones = new Map(tombstones);2638 reconcileTombstones();2639 try {2640 if (vaultMode === "decoy") {2641 await persistSlotB();2642 } else {2643 await persistSlotA(reason);2644 }2645 } catch (err) {2646 tombstones = previousTombstones;2647 throw err;2648 }2649 buildDomainIndexSync(entries);2650 await refreshLockedSyncState();2651}26522653function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2654 if (!Array.isArray(values)) return undefined;2655 const seen = new Set<string>();2656 const cleaned: string[] = [];2657 for (const value of values) {2658 const trimmed = typeof value === "string" ? value.trim() : "";2659 if (!trimmed) continue;2660 const key = trimmed.toLowerCase();2661 if (seen.has(key)) continue;2662 seen.add(key);2663 cleaned.push(trimmed);2664 }2665 return cleaned.length > 0 ? cleaned : undefined;2666}26672668function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2669 const title = entry.title.trim();2670 2671 2672 2673 let type: VaultEntry["type"] = entry.type ?? "login";2674 if (type === "passkey") type = "login";26752676 if (!title) throw new Error("title is required");26772678 if (entry.passkey) {2679 const p = entry.passkey;2680 if (2681 !p.rpId ||2682 !p.credentialId ||2683 !p.publicKeyCose ||2684 !p.userHandle ||2685 !p.userName ||2686 (p.algorithm !== -7 && p.algorithm !== -8)2687 ) {2688 throw new Error("invalid passkey credential");2689 }2690 }2691 if (type === "card") {2692 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2693 throw new Error("cardholder name and card number are required");2694 }2695 }26962697 return {2698 type,2699 title,2700 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2701 email: type === "login" ? entry.email?.trim() || undefined : undefined,2702 password: type === "login" ? entry.password : undefined,2703 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2704 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2705 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2706 2707 2708 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2709 totpDigits: type === "login" ? entry.totpDigits : undefined,2710 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2711 notes: entry.notes?.trim() || undefined,2712 breach: type === "login" ? entry.breach : undefined,2713 2714 2715 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2716 tags: sanitizeTags(entry.tags),2717 passkey: entry.passkey,2718 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2719 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2720 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2721 expYear: type === "card" ? entry.expYear?.trim() : undefined,2722 cvc: type === "card" ? entry.cvc?.trim() : undefined,2723 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2724 network: type === "card" ? entry.network : undefined,2725 last4: type === "card" ? entry.last4?.trim() : undefined,2726 };2727}27282729function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2730 const nextPatch: Partial<VaultEntry> = {};27312732 if ("type" in patch) {2733 nextPatch.type = patch.type;2734 }27352736 if ("title" in patch) {2737 const title = patch.title?.trim() ?? "";2738 if (!title) throw new Error("title is required");2739 nextPatch.title = title;2740 }27412742 if ("url" in patch) {2743 nextPatch.url = patch.url?.trim() ?? "";2744 }27452746 if ("additionalUrls" in patch) {2747 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2748 const seen = new Set<string>();2749 const cleaned: string[] = [];2750 for (const value of incoming) {2751 const trimmed = typeof value === "string" ? value.trim() : "";2752 if (!trimmed) continue;2753 const key = trimmed.toLowerCase();2754 if (seen.has(key)) continue;2755 seen.add(key);2756 cleaned.push(trimmed);2757 }2758 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2759 }27602761 if ("username" in patch) {2762 nextPatch.username = patch.username?.trim() ?? "";2763 }27642765 if ("email" in patch) {2766 nextPatch.email = patch.email?.trim() || undefined;2767 }27682769 if ("password" in patch) {2770 const password = patch.password ?? "";2771 2772 nextPatch.password = password || undefined;2773 }27742775 if ("totp" in patch) {2776 nextPatch.totp = patch.totp?.trim() || undefined;2777 }27782779 if ("notes" in patch) {2780 nextPatch.notes = patch.notes?.trim() || undefined;2781 }27822783 if ("breach" in patch) {2784 nextPatch.breach = patch.breach;2785 }27862787 if ("canary" in patch) {2788 2789 2790 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2791 }27922793 if ("passkey" in patch) {2794 2795 2796 2797 if (patch.passkey === undefined || patch.passkey === null) {2798 nextPatch.passkey = undefined;2799 } else {2800 const sanitized = sanitizePasskey(patch.passkey);2801 if (!sanitized) throw new Error("invalid passkey credential");2802 nextPatch.passkey = sanitized;2803 }2804 }28052806 if ("tags" in patch) {2807 2808 nextPatch.tags = sanitizeTags(patch.tags);2809 }28102811 if ("cardholderName" in patch) {2812 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2813 }2814 if ("cardNumber" in patch) {2815 const n = patch.cardNumber ?? "";2816 nextPatch.cardNumber = n || undefined;2817 }2818 if ("expMonth" in patch) {2819 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2820 }2821 if ("expYear" in patch) {2822 nextPatch.expYear = patch.expYear?.trim() || undefined;2823 }2824 if ("cvc" in patch) {2825 nextPatch.cvc = patch.cvc?.trim() || undefined;2826 }2827 if ("cardNickname" in patch) {2828 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2829 }2830 if ("network" in patch) {2831 nextPatch.network = patch.network;2832 }2833 if ("last4" in patch) {2834 nextPatch.last4 = patch.last4?.trim() || undefined;2835 }28362837 return nextPatch;2838}28392840function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2841 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2842 const modifiedAt =2843 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;28442845 const base: VaultEntry = {2846 id: entry.id,2847 type: "type" in entry ? entry.type : "login",2848 title: entry.title,2849 username: entry.username,2850 email: "email" in entry ? entry.email : undefined,2851 password: entry.password,2852 url: entry.url,2853 additionalUrls:2854 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2855 ? entry.additionalUrls2856 : undefined,2857 totp: entry.totp,2858 2859 totpAlgorithm: entry.totpAlgorithm,2860 totpDigits: entry.totpDigits,2861 totpPeriod: entry.totpPeriod,2862 notes: entry.notes,2863 breach: entry.breach,2864 2865 2866 2867 passwordChangedAt:2868 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2869 2870 2871 2872 passwordHistory:2873 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2874 ? entry.passwordHistory2875 : undefined,2876 canary: "canary" in entry ? entry.canary : undefined,2877 passkey: "passkey" in entry ? entry.passkey : undefined,2878 createdAt,2879 modifiedAt,2880 deletedAt: entry.deletedAt,2881 tags: "tags" in entry ? entry.tags : undefined,2882 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2883 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2884 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2885 expYear: "expYear" in entry ? entry.expYear : undefined,2886 cvc: "cvc" in entry ? entry.cvc : undefined,2887 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2888 network: "network" in entry ? entry.network : undefined,2889 last4: "last4" in entry ? entry.last4 : undefined,2890 };28912892 return migrateLegacyPasskey(base, entry);2893}289428952896289728982899290029012902function migrateLegacyPasskey(2903 base: VaultEntry,2904 raw: VaultEntry | LegacyVaultEntry2905): VaultEntry {2906 if (base.passkey) return base;2907 const flat = raw as Partial<VaultEntry>;2908 const isPasskeyType = base.type === "passkey";2909 const hasFlatPasskeyFields =2910 typeof flat.rpId === "string" &&2911 typeof flat.credentialId === "string" &&2912 typeof flat.publicKeyCose === "string" &&2913 typeof flat.userHandle === "string" &&2914 typeof flat.userName === "string";2915 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2916 if (!hasFlatPasskeyFields) return base;29172918 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2919 base.passkey = {2920 rpId: flat.rpId!,2921 rpName: flat.rpName ?? flat.rpId!,2922 credentialId: flat.credentialId!,2923 publicKeyCose: flat.publicKeyCose!,2924 privateKeyJwk: flat.privateKeyJwk,2925 userHandle: flat.userHandle!,2926 userName: flat.userName!,2927 userDisplayName: flat.userDisplayName ?? flat.userName!,2928 algorithm,2929 signCount: flat.signCount ?? 0,2930 aaguid: flat.aaguid ?? "",2931 backupEligible: flat.backupEligible === true,2932 backupState: flat.backupState === true,2933 lastUsedAt: flat.lastUsedAt,2934 browser: flat.browser,2935 transports: flat.transports,2936 createdAt: base.createdAt,2937 };2938 2939 2940 2941 if (isPasskeyType) base.type = "login";2942 if (!base.username) base.username = flat.userName;2943 if (!base.url) base.url = `https://${flat.rpId}`;2944 return base;2945}2946294729482949295029512952function entryDupeKey(e: VaultEntry): string {2953 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2954 2955 2956 2957 2958 if (e.passkey) {2959 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2960 }2961 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2962}296329642965export function countDuplicates(): number {2966 const best = new Map<string, VaultEntry>();2967 let dupeCount = 0;2968 for (const e of entries.filter((e) => !e.deletedAt)) {2969 const key = entryDupeKey(e);2970 const existing = best.get(key);2971 if (!existing) {2972 best.set(key, e);2973 } else {2974 dupeCount++;2975 2976 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2977 best.set(key, e);2978 }2979 }2980 }2981 return dupeCount;2982}298329842985298629872988export async function deduplicateEntries(): Promise<number> {2989 if (!derivedKey) throw new Error("vault_locked");2990 2991 2992 if (vaultMode === "decoy") {2993 throw new Error("deduplicate disabled in decoy mode");2994 }29952996 const best = new Map<string, VaultEntry>();2997 for (const e of entries.filter((en) => !en.deletedAt)) {2998 const key = entryDupeKey(e);2999 const existing = best.get(key);3000 if (!existing) {3001 best.set(key, e);3002 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3003 best.set(key, e);3004 }3005 }30063007 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));3008 let removed = 0;3009 entries = entries.map((e) => {3010 if (!e.deletedAt && !keepIds.has(e.id)) {3011 removed++;3012 const deletedAt = monotonicEntryTimestamp(Math.max(3013 e.modifiedAt,3014 tombstones.get(e.id) ?? 0,3015 ));3016 tombstones.set(e.id, deletedAt);3017 return { ...e, deletedAt };3018 }3019 return e;3020 });30213022 if (removed > 0) await persistEntries();3023 return removed;3024}302530263027302830293030303130323033const HISTORY_KEY = "peach_vault_history";3034const HISTORY_MAX = 5;303530363037303830393040function bucketEntryCount(n: number): number {3041 if (n <= 0) return 0;3042 if (n <= 10) return 10;3043 if (n <= 25) return 25;3044 if (n <= 50) return 50;3045 if (n <= 100) return 100;3046 if (n <= 250) return 250;3047 if (n <= 500) return 500;3048 return Math.ceil(n / 500) * 500;3049}30503051305230533054305530563057export async function takeVaultSnapshot(3058 reason: VaultSnapshot["reason"],3059): Promise<void> {3060 try {3061 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3062 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3063 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];30643065 3066 3067 3068 if (!slotA) return;30693070 const vault_a = JSON.stringify(slotA);3071 const vault_b = JSON.stringify(slotB ?? slotA);30723073 3074 3075 3076 3077 3078 3079 let entryCount: number | null = null;3080 if (derivedKey && vaultMode === "real") {3081 try {3082 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3083 } catch {3084 entryCount = null;3085 }3086 }30873088 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3089 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3090 ? existingRaw3091 : { snapshots: [] };30923093 3094 3095 3096 const latest = history.snapshots[0];3097 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3098 return;3099 }31003101 const snapshot: VaultSnapshot = {3102 id: crypto.randomUUID(),3103 savedAt: Date.now(),3104 reason,3105 vault_a,3106 vault_b,3107 entryCount,3108 };31093110 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3111 await chrome.storage.local.set({ [HISTORY_KEY]: history });3112 } catch (err) {3113 3114 3115 3116 3117 console.warn('[Peach] takeVaultSnapshot failed:', err);3118 if (String(err).includes('QUOTA') || String(err).includes('quota')) {3119 try {3120 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3121 } catch {3122 3123 }3124 }3125 }3126}312731283129export async function clearVaultHistory(): Promise<void> {3130 try {3131 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3132 } catch {3133 3134 }3135}313631373138export async function getVaultHistory(): Promise<VaultSnapshot[]> {3139 const raw = await chrome.storage.local.get(HISTORY_KEY);3140 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3141 if (!history || !Array.isArray(history.snapshots)) return [];3142 return history.snapshots;3143}31443145314631473148314931503151export async function restoreVaultSnapshot(3152 snapshotId: string,3153): Promise<{ ok: true } | { ok: false; error: string }> {3154 const history = await getVaultHistory();3155 const snapshot = history.find((s) => s.id === snapshotId);3156 if (!snapshot) return { ok: false, error: "snapshot_not_found" };31573158 3159 3160 3161 await takeVaultSnapshot("restore");31623163 let restoredA: StoredVault;3164 let restoredB: StoredVault;3165 try {3166 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3167 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3168 } catch {3169 return { ok: false, error: "snapshot_corrupt" };3170 }31713172 await writeSlot(SLOT_A, restoredA);3173 await writeSlot(SLOT_B, restoredB);3174 await stampLocalVaultMutation();31753176 3177 3178 3179 lock();31803181 return { ok: true };3182}318331843185318631873188318931903191319231933194319531963197export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3198 if (!derivedKey || vaultMode !== "real") {3199 return { ok: false, error: "vault_locked" };3200 }3201 const slotA = await loadSlot(SLOT_A);3202 if (!slotA) return { ok: false, error: "missing_slot_a" };32033204 3205 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3206 await generateAndWrapRootKey();32073208 3209 const newRootKey = unhex(rawRootKey);3210 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);32113212 3213 3214 3215 3216 await takeVaultSnapshot("local_save");3217 const nextSlotA: StoredVault = {3218 ...slotA,3219 recovery_salt: recoverySalt,3220 recovery_wrapped_root_key: wrappedRootKey,3221 recovery_nonce: wrappedNonce,3222 recovery_tag: wrappedTag,3223 ...pwRoot,3224 };3225 await writeSlot(SLOT_A, nextSlotA);32263227 3228 if (rootKey) zeroKey(rootKey);3229 rootKey = newRootKey;32303231 3232 await clearPairedDevices();32333234 3235 await setRecoveryConfirmPending(); 32363237 await stampLocalVaultMutation();3238 return { ok: true };3239}32403241324232433244324532463247export async function changePassword(3248 currentPassword: string,3249 newPassword: string3250): Promise<boolean> {3251 if (!derivedKey || vaultMode !== "real") return false;32523253 3254 const valid = await verifyMainPassword(currentPassword);3255 if (!valid) return false;32563257 const slotA = await loadSlot(SLOT_A);3258 if (!slotA) return false;3259 if (!rootKey) return false;32603261 3262 const newSalt = generateSalt();3263 const newKey = await deriveKey(newPassword, newSalt);32643265 3266 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3267 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");32683269 3270 3271 3272 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);32733274 const finalSlotA: StoredVault = {3275 ...slotA,3276 salt: hex(newSalt),3277 nonce,3278 ciphertext,3279 tag,3280 ...pwRoot,3281 };32823283 3284 3285 3286 await takeVaultSnapshot("local_save");32873288 3289 3290 3291 3292 await writeSlot(SLOT_A, finalSlotA);32933294 3295 3296 3297 zeroKey(derivedKey);3298 derivedKey = newKey;32993300 3301 3302 3303 3304 void saveSessionKey(newKey, "real");33053306 3307 3308 3309 if (!duressConfigured) {3310 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3311 }3312 await clearPinUnlockStorage();3313 await stampLocalVaultMutation();33143315 return true;3316}