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[] = [];85let domainIndex: Map<string, VaultEntry[]> = new Map();86let vaultMode: "real" | "decoy" = "real";87888990let duressConfigured = false;9192let migrationRan = false;93949596979899100101102103let lastUnlockFailureReason: string | null = null;104105export function consumeLastUnlockFailureReason(): string | null {106 const r = lastUnlockFailureReason;107 lastUnlockFailureReason = null;108 return r;109}110111112113114115export type TextSize = "small" | "medium" | "large";116117export interface VaultSettings {118 accentColor?: AccentColorName;119 theme?: "dark" | "light";120 autoLockMinutes?: number;121 clipboardClearSeconds?: number;122 lockOnClose?: boolean;123 breachMonitoring?: boolean;124 passkeyProviderEnabled?: boolean;125 trashRetentionDays?: number;126 manualSummonDomains?: Record<string, ManualSummonRecord>;127 declinedCardFingerprints?: string[];128 129130131132133 textSize?: TextSize;134 135136137138139 reduceMotion?: boolean;140 141142143144145 biometricsEnabled?: boolean;146 147 pinEnabled?: boolean;148 149 pinPreferred?: boolean;150 151152153154155156157 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;158}159160interface StoredPinWrap {161 version: 1;162 salt: string;163 nonce: string;164 ciphertext: string;165 tag: string;166}167168const PIN_WRAP_STORAGE = "peach_pin_wrap";169const PIN_MIN_LENGTH = 4;170const PIN_MAX_LENGTH = 12;171172let settings: VaultSettings = {};173174export function getSettings(): VaultSettings {175 return settings;176}177178export async function ensureSettingsLoaded(): Promise<VaultSettings> {179 await loadSettings();180 return settings;181}182183export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {184 await loadSettings();185 186 187 188 189 const safe = Object.fromEntries(190 Object.entries(patch).filter(191 ([k]) => !["__proto__", "constructor", "prototype"].includes(k),192 ),193 ) as Partial<VaultSettings>;194 settings = { ...settings, ...safe };195 await persistSettings();196}197198function isValidPin(pin: string): boolean {199 return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);200}201202203export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {204 if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;205 if (!(await verifyMainPassword(currentPassword))) return false;206207 const salt = generateSalt();208 const pinKey = await deriveKey(pin, salt);209 try {210 const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");211 await chrome.storage.local.set({212 [PIN_WRAP_STORAGE]: { version: 1, salt: hex(salt), ...wrapped } satisfies StoredPinWrap,213 });214 await updateSettings({ pinEnabled: true });215 return true;216 } finally {217 zeroKey(pinKey);218 }219}220221async function clearPinUnlockStorage(): Promise<void> {222 await chrome.storage.local.remove(PIN_WRAP_STORAGE);223 await updateSettings({ pinEnabled: false });224}225226227export async function disablePinUnlock(currentPassword: string): Promise<boolean> {228 if (!derivedKey || vaultMode !== "real") return false;229 if (!(await verifyMainPassword(currentPassword))) return false;230 await clearPinUnlockStorage();231 return true;232}233234235export async function unlockWithPin(pin: string): Promise<boolean> {236 if (!isValidPin(pin)) return false;237 await loadSettings();238 if (!settings.pinEnabled) return false;239240 const stored = (await chrome.storage.local.get(PIN_WRAP_STORAGE))[PIN_WRAP_STORAGE] as241 | StoredPinWrap242 | undefined;243 if (244 !stored ||245 stored.version !== 1 ||246 !stored.salt ||247 !stored.nonce ||248 !stored.ciphertext ||249 !stored.tag250 ) return false;251252 const pinKey = await deriveKey(pin, unhex(stored.salt));253 let unwrappedKey: Uint8Array | null = null;254 try {255 const masterKeyHex = await decrypt(256 pinKey,257 stored.nonce,258 stored.ciphertext,259 stored.tag,260 "pin_wrap_v1",261 );262 if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;263 unwrappedKey = unhex(masterKeyHex);264 const result = await unlockWithKey(masterKeyHex);265 if (!result.ok) return false;266 await saveSessionKey(unwrappedKey, "real");267 return true;268 } catch {269 return false;270 } finally {271 zeroKey(pinKey);272 if (unwrappedKey) zeroKey(unwrappedKey);273 }274}275276async function persistSettings(): Promise<void> {277 try {278 await chrome.storage.local.set({ peach_settings: settings });279 } catch (err) {280 throw mapStorageError(err);281 }282}283284function mapStorageError(err: unknown): Error {285 const message = err instanceof Error ? err.message : String(err);286 if (/QUOTA_BYTES|quota/i.test(message)) {287 return new Error("STORAGE_QUOTA_EXCEEDED");288 }289 return err instanceof Error ? err : new Error(message);290}291292async function loadSettings(): Promise<void> {293 const result = await chrome.storage.local.get("peach_settings");294 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};295}296297298299function fireSyncTrigger(reason: "vault_change"): void {300 void Promise.resolve()301 .then(() => triggerSyncIfReachable({ reason }))302 .catch(() => {303 304 });305}306307let lockedSyncRefreshHook: (() => Promise<void>) | null = null;308let lockedSyncUnlockHook: (() => Promise<void>) | null = null;309310export function setLockedSyncHooks(hooks: {311 refresh: () => Promise<void>;312 afterUnlock: () => Promise<void>;313}): void {314 lockedSyncRefreshHook = hooks.refresh;315 lockedSyncUnlockHook = hooks.afterUnlock;316}317318async function refreshLockedSyncState(): Promise<void> {319 if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;320 await lockedSyncRefreshHook().catch((error) => {321 console.warn("[Peach sync] locked spool refresh failed:", error);322 });323}324325326327328329export async function checkBreach(password: string): Promise<number> {330 331 332 const encoder = new TextEncoder();333 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));334 const hashHex = Array.from(new Uint8Array(hashBuffer))335 .map((b) => b.toString(16).padStart(2, "0"))336 .join("")337 .toUpperCase();338339 const prefix = hashHex.slice(0, 5);340 const suffix = hashHex.slice(5);341342 try {343 344 345 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {346 headers: { "Add-Padding": "true" },347 });348 if (!res.ok) return 0;349 const text = await res.text();350 const match = text.split("\r\n").find((l) => l.startsWith(suffix));351 return match ? parseInt(match.split(":")[1], 10) : 0;352 } catch {353 return 0; 354 }355}356357export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {358 const fingerprint = await cardFingerprint(last4, expMonth, expYear);359 await loadSettings();360 settings = {361 ...settings,362 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],363 };364 await persistSettings();365}366367export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {368 await loadSettings();369 const fingerprint = await cardFingerprint(last4, expMonth, expYear);370 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);371}372373async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {374 const encoder = new TextEncoder();375 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));376 return Array.from(new Uint8Array(buf).slice(0, 8))377 .map((b) => b.toString(16).padStart(2, "0"))378 .join("");379}380381function hex(buf: Uint8Array): string {382 return Array.from(buf)383 .map((b) => b.toString(16).padStart(2, "0"))384 .join("");385}386387function unhex(s: string): Uint8Array {388 const out = new Uint8Array(s.length / 2);389 for (let i = 0; i < s.length; i += 2) {390 out[i / 2] = parseInt(s.slice(i, i + 2), 16);391 }392 return out;393}394395396397398399400401402403404405406407408409410411412413414async function generateAndWrapRootKey(): Promise<{415 rawRootKey: string; 416 recoverySalt: string;417 wrappedRootKey: string;418 wrappedNonce: string;419 wrappedTag: string;420}> {421 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));422423 424 const words = await rootKeyToWords(rootKeyBytes);425 const wordMaterial = await wordsToRootKeyMaterial(words);426 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));427 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);428429 430 431 const nonce = crypto.getRandomValues(new Uint8Array(12));432 const encoded = new TextEncoder().encode(hex(rootKeyBytes));433 const raw = await crypto.subtle.encrypt(434 {435 name: "AES-GCM",436 iv: nonce,437 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,438 tagLength: 128,439 },440 recoveryKey,441 encoded442 );443 444 const rawU8 = new Uint8Array(raw);445 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));446 const tag = hex(rawU8.slice(rawU8.length - 16));447448 return {449 rawRootKey: hex(rootKeyBytes),450 recoverySalt: hex(recoverySalt),451 wrappedRootKey: ciphertext,452 wrappedNonce: hex(nonce),453 wrappedTag: tag,454 };455}456457async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{458 recoverySalt: string;459 wrappedRootKey: string;460 wrappedNonce: string;461 wrappedTag: string;462}> {463 if (rootKeyBytes.length !== 32) {464 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");465 }466467 const words = await rootKeyToWords(rootKeyBytes);468 const wordMaterial = await wordsToRootKeyMaterial(words);469 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));470 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);471 const nonce = crypto.getRandomValues(new Uint8Array(12));472 const encoded = new TextEncoder().encode(hex(rootKeyBytes));473 const raw = await crypto.subtle.encrypt(474 {475 name: "AES-GCM",476 iv: nonce,477 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,478 tagLength: 128,479 },480 recoveryKey,481 encoded,482 );483 const rawU8 = new Uint8Array(raw);484 return {485 recoverySalt: hex(recoverySalt),486 wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),487 wrappedNonce: hex(nonce),488 wrappedTag: hex(rawU8.slice(rawU8.length - 16)),489 };490}491492493494495496497export async function unwrapRootKeyForRecovery(498 wrappedRootKey: string,499 wrappedNonce: string,500 wrappedTag: string,501 recoverySaltHex: string,502 words: string[]503): Promise<void> {504 const wordMaterial = await wordsToRootKeyMaterial(words);505 const recoverySalt = unhex(recoverySaltHex);506 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);507508 509 510 511 512 513 const nonceRaw = unhex(wrappedNonce);514 const ciphertextRaw = unhex(wrappedRootKey);515 const tagRaw = unhex(wrappedTag);516 517 518 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;519 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);520 combined.set(ciphertextRaw, 0);521 combined.set(tagRaw, ciphertextRaw.length);522 const raw = await crypto.subtle.decrypt(523 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },524 recoveryKey,525 combined526 );527 const decryptedHex = new TextDecoder().decode(raw);528 rootKey = unhex(decryptedHex);529}530531532533534535536537538539540export async function rewrapRootKeyForPassword(541 newDerivedKey: Uint8Array,542 _newSalt: Uint8Array543): Promise<void> {544 if (!rootKey) throw new Error("root key not set");545 const slotA = await loadSlot(SLOT_A);546 if (!slotA) throw new Error("missing slot A");547 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);548 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });549}550551552553554555556async function reencryptVaultForNewKey(557 newKey: Uint8Array,558 slotA: StoredVault559): Promise<void> {560 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);561 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");562 await writeSlot(SLOT_A, {563 ...slotA,564 nonce,565 ciphertext,566 tag,567 });568}569570571572573574575export async function changePasswordWithNewKey(576 newSalt: Uint8Array,577 newKey: Uint8Array578): Promise<void> {579 const slotA = await loadSlot(SLOT_A);580 if (!slotA) throw new Error("missing slot A");581 582 583 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });584 if (derivedKey) zeroKey(derivedKey);585 derivedKey = newKey;586 587 588 589 void saveSessionKey(newKey, "real");590 591 592 593 594 if (rootKey) {595 const slotAfter = await loadSlot(SLOT_A);596 if (slotAfter) {597 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);598 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });599 }600 }601 if (!duressConfigured) {602 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));603 }604 await clearPinUnlockStorage();605 await stampLocalVaultMutation();606}607608609610611612613export { deriveKey, generateSalt };614615export async function loadSlotA(): Promise<StoredVault | null> {616 return loadSlot(SLOT_A);617}618619620621622623624625626627628629630631async function deriveRootKeyFromDerivedKey(632 derivedKeyBytes: Uint8Array,633 slotA: StoredVault,634): Promise<Uint8Array | null> {635 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {636 return null;637 }638 try {639 const rootHex = await decrypt(640 derivedKeyBytes,641 slotA.pw_root_nonce,642 slotA.pw_root_ciphertext,643 slotA.pw_root_tag,644 "pw_root_v1",645 );646 return unhex(rootHex);647 } catch {648 return null;649 }650}651652653654async function wrapRootKeyForDerivedKey(655 derivedKeyBytes: Uint8Array,656 rootKeyBytes: Uint8Array,657): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {658 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");659 return {660 pw_root_ciphertext: ciphertext,661 pw_root_nonce: nonce,662 pw_root_tag: tag,663 };664}665666667668669670671672673export async function migrateToRecovery(): Promise<void> {674 if (!derivedKey || vaultMode !== "real") {675 throw new Error("vault must be unlocked in real mode");676 }677 const slotA = await loadSlot(SLOT_A);678 if (!slotA) throw new Error("missing slot A");679 const hasRecoveryEscrow =680 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;681 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;682 if (hasRecoveryEscrow && hasPwRoot) return; 683684 685 686 687 688 689 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =690 await generateAndWrapRootKey();691 rootKey = unhex(rawRootKey);692693 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);694 const nextSlotA: StoredVault = {695 ...slotA,696 recovery_salt: recoverySalt,697 recovery_wrapped_root_key: wrappedRootKey,698 recovery_nonce: wrappedNonce,699 recovery_tag: wrappedTag,700 ...pwRoot,701 };702 await writeSlot(SLOT_A, nextSlotA);703 await stampLocalVaultMutation();704}705706707708709710711export async function getRecoveryWords(): Promise<string[]> {712 if (!rootKey) {713 throw new Error("recovery not set up — vault may need migration");714 }715 return rootKeyToWords(rootKey);716}717718async function loadSlot(key: string): Promise<StoredVault | null> {719 const result = await chrome.storage.local.get(key);720 return (result as Record<string, StoredVault | undefined>)[key] ?? null;721}722723async function writeSlot(key: string, vault: StoredVault): Promise<void> {724 try {725 await chrome.storage.local.set({ [key]: vault });726 } catch (err) {727 throw mapStorageError(err);728 }729}730731732733734735736737738739740export async function rollbackAdoptedVault(): Promise<void> {741 lock();742 try {743 await chrome.storage.local.remove([744 SLOT_A,745 SLOT_B,746 "peach_last_modified_at",747 ]);748 } catch (err) {749 throw mapStorageError(err);750 }751}752753async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {754 const raw = await chrome.storage.local.get("peach_last_modified_at");755 const previous = Number(756 (raw as Record<string, unknown>).peach_last_modified_at,757 );758 const next = Math.max(759 Date.now(),760 Number.isFinite(previous) ? previous + 1 : 0,761 );762 try {763 await chrome.storage.local.set({764 [SLOT_A]: slotA,765 [SLOT_B]: slotB,766 peach_last_modified_at: String(next),767 });768 } catch (err) {769 throw mapStorageError(err);770 }771}772773774775776777778779780let lastEntryTimestamp = 0;781function monotonicEntryTimestamp(currentModifiedAt?: number): number {782 const base = Math.max(783 Date.now(),784 (currentModifiedAt ?? 0) + 1,785 lastEntryTimestamp + 1,786 );787 lastEntryTimestamp = base;788 return base;789}790791async function stampLocalVaultMutation(): Promise<void> {792 const raw = await chrome.storage.local.get("peach_last_modified_at");793 const previous = Number(794 (raw as Record<string, unknown>).peach_last_modified_at,795 );796 const next = Math.max(797 Date.now(),798 Number.isFinite(previous) ? previous + 1 : 0,799 );800 await chrome.storage.local.set({801 peach_last_modified_at: String(next),802 });803}804805806807808809810async function ensureMigrated(): Promise<void> {811 if (migrationRan) return;812 migrationRan = true;813814 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];815 const raw = await chrome.storage.local.get(keys);816 const slotAExists = raw[SLOT_A] !== undefined;817 const slotBExists = raw[SLOT_B] !== undefined;818 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;819 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;820821 822 if (!slotAExists && !legacyMain) return;823824 825 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;826827 let didMigrate = false;828 if (legacyMain && !slotAExists) {829 await writeSlot(SLOT_A, legacyMain);830 didMigrate = true;831 }832833 834 if (!slotBExists) {835 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;836 if (legacyDecoy) {837 await writeSlot(SLOT_B, legacyDecoy);838 didMigrate = true;839 } else if (target) {840 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));841 didMigrate = true;842 }843 }844845 const toRemove: string[] = [];846 if (legacyMain) toRemove.push(LEGACY_MAIN);847 if (legacyDecoy) toRemove.push(LEGACY_DECOY);848 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);849 if (toRemove.length > 0) {850 await chrome.storage.local.remove(toRemove);851 didMigrate = true;852 }853 if (didMigrate) {854 await stampLocalVaultMutation();855 }856}857858859860861862863864865866867868function padPlaintext(json: string, minBytes = 0): string {869 const encoded = new TextEncoder().encode(json);870 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);871 872 const target = Math.max(bucket, minBytes);873 const padLen = target - encoded.length;874 return json + " ".repeat(padLen);875}876877878879880881882883884885886887const CSPRNG_CHUNK = 65_536;888889function fillRandom(buf: Uint8Array): Uint8Array {890 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {891 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);892 const chunk = new Uint8Array(end - offset);893 crypto.getRandomValues(chunk);894 buf.set(chunk, offset);895 }896 return buf;897}898899function generateNoiseBlob(ciphertextBytes: number): StoredVault {900 const salt = crypto.getRandomValues(new Uint8Array(16));901 const nonce = crypto.getRandomValues(new Uint8Array(12));902 const cipher = fillRandom(new Uint8Array(ciphertextBytes));903 const tag = crypto.getRandomValues(new Uint8Array(16));904 return {905 version: 1,906 salt: hex(salt),907 nonce: hex(nonce),908 ciphertext: hex(cipher),909 tag: hex(tag),910 };911}912913914915916917type VaultPayload = {918 entries: VaultEntry[];919 duressConfigured?: boolean;920};921922type DecryptedPayload = {923 entries: VaultEntry[];924 duressConfigured: boolean;925 926927928929930931932 migratedFromLegacy: boolean;933};934935function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {936 const envelope: VaultPayload = { entries, duressConfigured: duress };937 return padPlaintext(JSON.stringify(envelope), minBytes);938}939940function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {941 if ("created" in raw || "updated" in raw) return true;942 const flat = raw as Partial<VaultEntry>;943 if (flat.type === "passkey") return true;944 945 if (flat.rpId !== undefined && flat.passkey === undefined) return true;946 return false;947}948949function decodePayload(plain: string): DecryptedPayload {950 const parsed = JSON.parse(plain) as951 | Array<VaultEntry | LegacyVaultEntry>952 | VaultPayload;953954 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];955 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);956957 const normalized = rawEntries.map(normalizeEntry);958 const { merged } = mergePasskeyOnlyIntoLogins(normalized);959960 if (Array.isArray(parsed)) {961 return {962 entries: normalized,963 duressConfigured: false,964 migratedFromLegacy: true, 965 };966 }967968 return {969 entries: normalized,970 duressConfigured: parsed.duressConfigured === true,971 migratedFromLegacy: flatMigrationNeeded || merged,972 };973}974975976977978979980981982983984985986987988989990991992993994995996997998function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {999 let merged = false;1000 const now = Date.now();10011002 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);1003 const byRpId = new Map<string, VaultEntry[]>();1004 for (const e of candidates) {1005 const rpId = e.passkey!.rpId;1006 const list = byRpId.get(rpId);1007 if (list) list.push(e);1008 else byRpId.set(rpId, [e]);1009 }10101011 for (const [rpId, group] of byRpId) {1012 if (group.length === 0) continue;1013 group.sort((a, b) => {1014 const aT =1015 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;1016 const bT =1017 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;1018 return bT - aT;1019 });1020 const winner = group[0]!;10211022 1023 1024 1025 const loginTarget = entries.find((e) => {1026 if (e.deletedAt || e.passkey || e.type !== "login") return false;1027 if (e.url && extractDomain(e.url) === rpId) return true;1028 for (const extra of e.additionalUrls ?? []) {1029 if (extractDomain(extra) === rpId) return true;1030 }1031 return false;1032 });10331034 if (loginTarget) {1035 loginTarget.passkey = winner.passkey;1036 1037 1038 1039 if (!loginTarget.totp && winner.totp) {1040 loginTarget.totp = winner.totp;1041 loginTarget.totpAlgorithm = winner.totpAlgorithm;1042 loginTarget.totpDigits = winner.totpDigits;1043 loginTarget.totpPeriod = winner.totpPeriod;1044 }1045 loginTarget.modifiedAt = now;1046 for (const dup of group) {1047 if (!dup.deletedAt) {1048 dup.deletedAt = now;1049 dup.modifiedAt = now;1050 }1051 }1052 merged = true;1053 } else if (group.length > 1) {1054 for (let i = 1; i < group.length; i += 1) {1055 const dup = group[i]!;1056 if (!dup.deletedAt) {1057 dup.deletedAt = now;1058 dup.modifiedAt = now;1059 }1060 }1061 merged = true;1062 }1063 }10641065 return { merged };1066}10671068106910701071107210731074function hasValidVaultSchema(v: unknown): v is StoredVault {1075 if (typeof v !== "object" || v === null) return false;1076 const r = v as Record<string, unknown>;1077 return (1078 r.version === 1 &&1079 typeof r.salt === "string" &&1080 typeof r.nonce === "string" &&1081 typeof r.ciphertext === "string" &&1082 typeof r.tag === "string"1083 );1084}10851086export type TryDecryptResult =1087 | { status: "ok"; payload: DecryptedPayload }1088 | { status: "wrong_key" }1089 | { status: "corrupted"; reason: string };1090109110921093109410951096export async function tryDecryptSlot(1097 vault: StoredVault,1098 key: Uint8Array,1099 aadTag?: string,1100): Promise<DecryptedPayload | null> {1101 const res = await tryDecryptSlotResult(vault, key, aadTag);1102 return res.status === "ok" ? res.payload : null;1103}11041105export async function tryDecryptSlotResult(1106 vault: StoredVault,1107 key: Uint8Array,1108 aadTag?: string,1109): Promise<TryDecryptResult> {1110 if (!hasValidVaultSchema(vault)) {1111 return { status: "corrupted", reason: "invalid_slot_schema" };1112 }1113 let plain: string;1114 try {1115 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1116 } catch {1117 return { status: "wrong_key" };1118 }1119 try {1120 const parsed = JSON.parse(plain) as unknown;1121 if (typeof parsed !== "object" || parsed === null) {1122 return { status: "corrupted", reason: "invalid_payload_shape" };1123 }1124 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1125 return { status: "corrupted", reason: "invalid_payload_shape" };1126 }1127 return { status: "ok", payload: decodePayload(plain) };1128 } catch {1129 return { status: "corrupted", reason: "invalid_payload_json" };1130 }1131}113211331134113511361137function buildDomainIndexSync(all: VaultEntry[]): void {1138 domainIndex = new Map();11391140 for (const entry of all) {1141 if (entry.deletedAt) continue;1142 if (entry.type === "note" || entry.type === "card") continue;11431144 1145 1146 1147 1148 const domains = new Set<string>();1149 const primary = extractDomain(entry.url ?? "");1150 if (primary) domains.add(primary);1151 for (const extra of entry.additionalUrls ?? []) {1152 const d = extractDomain(extra);1153 if (d) domains.add(d);1154 }1155 if (domains.size === 0) continue;11561157 for (const domain of domains) {1158 const bucket = domainIndex.get(domain) ?? [];1159 bucket.push(entry);1160 domainIndex.set(domain, bucket);1161 }1162 }1163}11641165export async function createVault(password: string): Promise<void> {1166 await ensureMigrated();11671168 const salt = generateSalt();1169 derivedKey = await deriveKey(password, salt);1170 entries = [];1171 vaultMode = "real";1172 duressConfigured = false;1173 buildDomainIndexSync(entries);11741175 1176 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1177 await generateAndWrapRootKey();1178 rootKey = unhex(rawRootKey);11791180 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1181 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");11821183 1184 1185 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);11861187 const slotA: StoredVault = {1188 version: 1,1189 salt: hex(salt),1190 nonce,1191 ciphertext,1192 tag,1193 recovery_salt: recoverySalt,1194 recovery_wrapped_root_key: wrappedRootKey,1195 recovery_nonce: wrappedNonce,1196 recovery_tag: wrappedTag,1197 ...pwRoot,1198 };1199 await writeSlot(SLOT_A, slotA);12001201 1202 1203 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1204 await stampLocalVaultMutation();12051206 touchAutoLock();1207}1208120912101211121212131214export async function createVaultFromRootKey(1215 password: string,1216 rootKeyHex: string,1217): Promise<{ vaultId: string }> {1218 if (typeof password !== "string" || password.length === 0) {1219 throw new Error("ADOPT_INVALID_PASSWORD");1220 }1221 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1222 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1223 }12241225 const rootKeyBytes = unhex(rootKeyHex);1226 if (rootKeyBytes.length !== 32) {1227 rootKeyBytes.fill(0);1228 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1229 }12301231 let nextDerivedKey: Uint8Array | null = null;1232 let sessionRootKey: Uint8Array | null = null;1233 try {1234 await ensureMigrated();1235 if ((await loadSlot(SLOT_A)) !== null) {1236 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1237 }12381239 const nextEntries: VaultEntry[] = [];1240 const nextDuressConfigured = false;1241 const vaultId = await computeVaultId(rootKeyBytes);1242 const salt = generateSalt();1243 nextDerivedKey = await deriveKey(password, salt);1244 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);12451246 sessionRootKey = new Uint8Array(rootKeyBytes);1247 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1248 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1249 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1250 const slotA: StoredVault = {1251 version: 1,1252 salt: hex(salt),1253 nonce,1254 ciphertext,1255 tag,1256 recovery_salt: recovery.recoverySalt,1257 recovery_wrapped_root_key: recovery.wrappedRootKey,1258 recovery_nonce: recovery.wrappedNonce,1259 recovery_tag: recovery.wrappedTag,1260 ...pwRoot,1261 };1262 const slotB = generateNoiseBlob(ciphertext.length / 2);1263 await commitCreatedVaultSlots(slotA, slotB);12641265 const previousDerivedKey = derivedKey;1266 const previousRootKey = rootKey;1267 derivedKey = nextDerivedKey;1268 rootKey = sessionRootKey;1269 nextDerivedKey = null;1270 sessionRootKey = null;1271 entries = nextEntries;1272 vaultMode = "real";1273 duressConfigured = nextDuressConfigured;1274 buildDomainIndexSync(entries);1275 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1276 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1277 touchAutoLock();12781279 return { vaultId };1280 } finally {1281 rootKeyBytes.fill(0);1282 if (nextDerivedKey) zeroKey(nextDerivedKey);1283 if (sessionRootKey) sessionRootKey.fill(0);1284 }1285}12861287export async function hasVault(): Promise<boolean> {1288 await ensureMigrated();1289 return (await loadSlot(SLOT_A)) !== null;1290}1291129212931294129512961297export async function isDuressEnabled(): Promise<boolean> {1298 return vaultMode === "real" && duressConfigured;1299}13001301export async function setupDuress(duressPassword: string): Promise<void> {1302 if (!derivedKey || vaultMode !== "real") {1303 throw new Error("vault must be unlocked in real mode");1304 }1305 await ensureMigrated();13061307 1308 1309 1310 const slotA = await loadSlot(SLOT_A);1311 if (!slotA) throw new Error("missing slot A");13121313 const decoySalt = generateSalt();1314 const decoyKey = await deriveKey(duressPassword, decoySalt);1315 try {1316 1317 1318 1319 1320 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1321 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1322 await writeSlot(SLOT_B, {1323 version: 1,1324 salt: hex(decoySalt),1325 nonce,1326 ciphertext,1327 tag,1328 });1329 } finally {1330 zeroKey(decoyKey);1331 }13321333 1334 1335 duressConfigured = true;1336 await persistSlotA();1337}13381339export async function disableDuress(): Promise<void> {1340 if (!derivedKey || vaultMode !== "real") {1341 throw new Error("vault must be unlocked in real mode");1342 }1343 await ensureMigrated();13441345 1346 1347 1348 const slotA = await loadSlot(SLOT_A);1349 if (slotA) {1350 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1351 }13521353 duressConfigured = false;1354 await persistSlotA();1355}13561357135813591360136113621363export async function verifyMainPassword(password: string): Promise<boolean> {1364 await ensureMigrated();1365 const slotA = await loadSlot(SLOT_A);1366 if (!slotA) return false;1367 const key = await deriveKey(password, unhex(slotA.salt));1368 try {1369 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1370 } finally {1371 zeroKey(key);1372 }1373}13741375export async function unlock(password: string): Promise<boolean> {1376 1377 1378 1379 if (await attemptUnlockWithDerive(password, deriveKey)) return true;13801381 const nfc = password.normalize("NFC");1382 if (password === nfc) return false; 13831384 if (await attemptUnlockWithDerive(password, deriveKeyRaw)) {1385 1386 1387 1388 if (vaultMode === "real") {1389 try {1390 const newSalt = generateSalt();1391 const newKey = await deriveKey(password, newSalt);1392 await changePasswordWithNewKey(newSalt, newKey);1393 } catch (healErr) {1394 console.warn("[Peach] LV-1 NFC heal failed:", healErr);1395 }1396 }1397 return true;1398 }1399 return false;1400}14011402async function attemptUnlockWithDerive(1403 password: string,1404 derive: typeof deriveKey,1405): Promise<boolean> {1406 await ensureMigrated();14071408 1409 1410 1411 1412 1413 const [slotA, slotB] = await Promise.all([1414 loadSlot(SLOT_A),1415 loadSlot(SLOT_B),1416 ]);14171418 1419 const [keyA, keyB] = await Promise.all([1420 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1421 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1422 ]);14231424 1425 1426 1427 const [resultA, resultB] = await Promise.all([1428 keyA && slotA1429 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1430 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1431 keyB && slotB1432 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1433 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1434 ]);1435 const payloadA = resultA.status === "ok" ? resultA.payload : null;1436 const payloadB = resultB.status === "ok" ? resultB.payload : null;14371438 if (payloadA && keyA) {1439 derivedKey = keyA;1440 void saveSessionKey(keyA, "real");1441 vaultMode = "real";1442 entries = payloadA.entries;1443 duressConfigured = payloadA.duressConfigured;1444 buildDomainIndexSync(entries);1445 await loadSettings();1446 touchAutoLock();1447 if (keyB) zeroKey(keyB);14481449 1450 if (slotA) {1451 const needsRecoverySetup =1452 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1453 if (!needsRecoverySetup) {1454 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1455 }1456 1457 1458 1459 1460 if (needsRecoverySetup || !rootKey) {1461 try {1462 await migrateToRecovery();1463 if (needsRecoverySetup) {1464 1465 1466 1467 await chrome.storage.local.set({ peach_recovery_pending: '1' });1468 }1469 } catch (migrationErr) {1470 console.warn('[Peach] recovery migration failed:', migrationErr);1471 }1472 }1473 }14741475 1476 1477 1478 if (payloadA.migratedFromLegacy) {1479 await persistSlotA();1480 }1481 await lockedSyncUnlockHook?.().catch((error) => {1482 console.warn("[Peach sync] locked inbox drain failed:", error);1483 });1484 return true;1485 }14861487 if (payloadB && keyB) {1488 derivedKey = keyB;1489 void saveSessionKey(keyB, "decoy");1490 vaultMode = "decoy";1491 entries = payloadB.entries;1492 duressConfigured = false;1493 buildDomainIndexSync(entries);1494 await loadSettings();1495 touchAutoLock();1496 if (keyA) zeroKey(keyA);1497 return true;1498 }14991500 if (keyA) zeroKey(keyA);1501 if (keyB) zeroKey(keyB);1502 1503 1504 if (resultA.status === "corrupted") {1505 lastUnlockFailureReason = resultA.reason;1506 } else if (resultB.status === "corrupted") {1507 lastUnlockFailureReason = resultB.reason;1508 }1509 return false;1510}15111512151315141515151615171518const SESSION_KEY_STORAGE = "peach_session_dk";15191520async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1521 if (!chrome.storage.session) return;1522 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1523}15241525async function clearSessionKey(): Promise<void> {1526 if (!chrome.storage.session) return;1527 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1528}152915301531153215331534153515361537export async function restoreFromSession(): Promise<boolean> {1538 if (derivedKey !== null) return true;1539 if (!chrome.storage.session) return false;1540 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1541 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1542 if (!entry?.keyHex) return false;15431544 const key = unhex(entry.keyHex);1545 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1546 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1547 const slot = await loadSlot(slotKey);1548 if (!slot) { await clearSessionKey(); return false; }15491550 const result = await tryDecryptSlotResult(slot, key, aadTag);1551 if (result.status !== "ok") { await clearSessionKey(); return false; }15521553 derivedKey = key;1554 vaultMode = entry.mode;1555 entries = result.payload.entries;1556 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1557 buildDomainIndexSync(entries);1558 await loadSettings();1559 touchAutoLock();15601561 1562 1563 1564 1565 1566 1567 1568 1569 1570 if (entry.mode === "real") {1571 try {1572 rootKey = await deriveRootKeyFromDerivedKey(key, slot);1573 } catch {1574 1575 }1576 }15771578 await lockedSyncUnlockHook?.().catch((error) => {1579 console.warn("[Peach sync] locked inbox drain failed:", error);1580 });15811582 return true;1583}15841585export function lock(): void {1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 void clearSessionKey();1596 if (derivedKey) {1597 zeroKey(derivedKey);1598 derivedKey = null;1599 }1600 if (rootKey) {1601 rootKey.fill(0);1602 rootKey = null;1603 }16041605 entries = [];1606 domainIndex = new Map();1607 vaultMode = "real";1608 duressConfigured = false;1609 clearAutoLock();1610}16111612export function isUnlocked(): boolean {1613 return derivedKey !== null;1614}16151616161716181619export async function unlockWithKey(1620 masterKeyHex: string,1621): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1622 try {1623 const masterKeyBytes = unhex(masterKeyHex);1624 const stored = await loadSlot(SLOT_A);1625 if (!stored) return { ok: false, error: "invalid_key" };1626 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1627 if (!payload) return { ok: false, error: "invalid_key" };16281629 derivedKey = masterKeyBytes;1630 vaultMode = "real";1631 entries = payload.entries;1632 duressConfigured = payload.duressConfigured;1633 buildDomainIndexSync(entries);1634 await loadSettings();1635 touchAutoLock();16361637 1638 1639 1640 1641 1642 if (stored.recovery_salt && stored.recovery_wrapped_root_key) {1643 try {1644 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1645 } catch { }1646 }1647 return { ok: true, entries };1648 } catch {1649 return { ok: false, error: "invalid_key" };1650 }1651}165216531654165516561657165816591660export async function exportDuressSlotForAdoption(): Promise<string | null> {1661 if (!(await isDuressEnabled())) return null;1662 await ensureMigrated();1663 const slotB = await loadSlot(SLOT_B);1664 if (!slotB) return null;1665 return JSON.stringify(slotB);1666}16671668166916701671167216731674export async function installAdoptedDuressSlot(json: string): Promise<void> {1675 let parsed: StoredVault;1676 try {1677 const raw = JSON.parse(json) as unknown;1678 if (!hasValidVaultSchema(raw)) {1679 throw new Error("invalid_duress_slot_shape");1680 }1681 parsed = raw;1682 } catch (err) {1683 if (err instanceof SyntaxError) {1684 throw new Error("invalid_duress_slot_shape");1685 }1686 throw err;1687 }1688 await writeSlot(SLOT_B, parsed);1689}16901691169216931694169516961697169816991700170117021703170417051706170717081709export async function unlockFromCodex(params: {1710 slotA: string; 1711 slotB: string | null; 1712 timestamp: number;1713 masterPassphrase: string;1714}): Promise<1715 | { ok: true; entryCount: number }1716 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1717> {1718 let parsedSlotA: StoredVault;1719 let parsedSlotB: StoredVault | null = null;1720 try {1721 const rawA = JSON.parse(params.slotA) as unknown;1722 if (!hasValidVaultSchema(rawA)) {1723 return { ok: false, reason: "invalid_slot_format" };1724 }1725 parsedSlotA = rawA;1726 if (params.slotB !== null) {1727 const rawB = JSON.parse(params.slotB) as unknown;1728 if (!hasValidVaultSchema(rawB)) {1729 return { ok: false, reason: "invalid_slot_format" };1730 }1731 parsedSlotB = rawB;1732 }1733 } catch {1734 return { ok: false, reason: "invalid_slot_format" };1735 }17361737 1738 1739 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1740 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1741 if (!trialPayload) {1742 zeroKey(trialKey);1743 return { ok: false, reason: "wrong_passphrase" };1744 }1745 1746 1747 zeroKey(trialKey);17481749 1750 1751 await takeVaultSnapshot("restore");17521753 await writeSlot(SLOT_A, parsedSlotA);1754 if (parsedSlotB) {1755 await writeSlot(SLOT_B, parsedSlotB);1756 } else {1757 1758 1759 1760 await writeSlot(1761 SLOT_B,1762 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1763 );1764 }1765 await chrome.storage.local.set({1766 peach_last_modified_at: String(params.timestamp),1767 });17681769 1770 1771 const ok = await unlock(params.masterPassphrase);1772 if (!ok) {1773 1774 1775 1776 return { ok: false, reason: "wrong_passphrase" };1777 }1778 return { ok: true, entryCount: getEntries().length };1779}1780178117821783export function getDerivedKeyHex(): string {1784 if (!derivedKey) throw new Error("vault_not_unlocked");1785 return hex(derivedKey);1786}17871788178917901791179217931794179517961797179817991800180118021803export function getRootKeyHex(): string | null {1804 return rootKey ? hex(rootKey) : null;1805}18061807export function getEntries(): VaultEntry[] {1808 if (!derivedKey) throw new Error("vault_locked");1809 return entries.filter((entry) => !entry.deletedAt);1810}18111812const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;18131814export function getIdentities(): SavedIdentity[] {1815 if (!derivedKey) throw new Error("vault_locked");1816 const active = entries.filter((e) => !e.deletedAt && e.type === "login");18171818 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1819 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();18201821 for (const entry of active) {1822 const domain = extractDomain(entry.url ?? "") ?? "";1823 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;18241825 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1826 const existing = map.get(v);1827 if (!existing || modifiedAt > existing.lastUsed) {1828 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1829 } else {1830 map.set(v, { ...existing, count: existing.count + 1 });1831 }1832 }18331834 const uVal = entry.username?.trim();1835 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);18361837 1838 1839 const eVal = entry.email?.trim();1840 if (eVal) tally(emailMap, eVal);1841 }18421843 const toIdentity = (type: "email" | "username") =>1844 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1845 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1846 });18471848 return [1849 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1850 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1851 ];1852}18531854export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1855 if (!derivedKey) throw new Error("vault_locked");18561857 const now = Date.now();1858 const added: VaultEntry[] = [];1859 for (const draft of drafts) {1860 try {1861 const sanitized = sanitizeEntryDraft(draft);1862 const nextEntry: VaultEntry = {1863 ...sanitized,1864 id: crypto.randomUUID(),1865 createdAt: now,1866 modifiedAt: monotonicEntryTimestamp(),1867 };1868 entries.push(nextEntry);1869 added.push(nextEntry);1870 } catch {1871 1872 }1873 }18741875 if (added.length > 0) {1876 await persistEntries();1877 }18781879 return added;1880}188118821883188418851886188718881889export async function restoreEntries(1890 incoming: VaultEntry[],1891 mode: "replace" | "merge"1892): Promise<{ added: number; updated: number; skipped: number }> {1893 if (!derivedKey) throw new Error("vault_locked");18941895 1896 1897 1898 if (mode === "replace" && incoming.length === 0) {1899 throw new Error("refuse to replace with empty entry set");1900 }19011902 let added = 0;1903 let updated = 0;1904 let skipped = 0;19051906 if (mode === "replace") {1907 entries = [];1908 for (const entry of incoming) {1909 const normalized = sanitizeRestoredEntry(entry);1910 if (!normalized) {1911 skipped += 1;1912 continue;1913 }1914 entries.push(normalized);1915 added += 1;1916 }1917 } else {1918 const byId = new Map<string, VaultEntry>();1919 for (const e of entries) byId.set(e.id, e);19201921 for (const entry of incoming) {1922 const normalized = sanitizeRestoredEntry(entry);1923 if (!normalized) {1924 skipped += 1;1925 continue;1926 }1927 const existing = byId.get(normalized.id);1928 if (!existing) {1929 entries.push(normalized);1930 added += 1;1931 } else if (normalized.modifiedAt > existing.modifiedAt) {1932 const idx = entries.findIndex((e) => e.id === existing.id);1933 if (idx !== -1) {1934 entries[idx] = normalized;1935 updated += 1;1936 }1937 } else {1938 skipped += 1;1939 }1940 }1941 }19421943 await persistEntries("import");1944 return { added, updated, skipped };1945}194619471948194919501951195219531954function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1955 if (!entry || typeof entry !== "object") return null;1956 const title = typeof entry.title === "string" ? entry.title.trim() : "";1957 if (!title) return null;1958 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1959 const now = Date.now();1960 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1961 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;19621963 1964 1965 1966 1967 const normalized = normalizeEntry({1968 ...entry,1969 id,1970 title,1971 type: entry.type ?? "login",1972 createdAt,1973 modifiedAt,1974 });19751976 if (normalized.passkey) {1977 normalized.passkey = sanitizePasskey(normalized.passkey);1978 }1979 if (normalized.canary) {1980 normalized.canary = sanitizeCanary(normalized.canary);1981 }1982 1983 normalized.tags = sanitizeTags(normalized.tags);19841985 1986 1987 if (1988 normalized.type === "login" &&1989 !normalized.passkey &&1990 typeof normalized.password !== "string"1991 ) {1992 return null;1993 }1994 if (entry.passkey && !normalized.passkey) {1995 1996 1997 return null;1998 }19992000 return normalized;2001}2002200320042005200620072008function sanitizePasskey(pk: unknown): PasskeyData | undefined {2009 if (!pk || typeof pk !== "object") return undefined;2010 const r = pk as Record<string, unknown>;2011 if (2012 typeof r.rpId !== "string" || !r.rpId ||2013 typeof r.credentialId !== "string" || !r.credentialId ||2014 typeof r.publicKeyCose !== "string" ||2015 typeof r.userHandle !== "string" ||2016 typeof r.userName !== "string"2017 ) {2018 return undefined;2019 }2020 return {2021 rpId: r.rpId,2022 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2023 credentialId: r.credentialId,2024 publicKeyCose: r.publicKeyCose,2025 privateKeyJwk:2026 r.privateKeyJwk && typeof r.privateKeyJwk === "object"2027 ? (r.privateKeyJwk as JsonWebKey)2028 : undefined,2029 userHandle: r.userHandle,2030 userName: r.userName,2031 userDisplayName:2032 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2033 algorithm: r.algorithm === -8 ? -8 : -7,2034 signCount: typeof r.signCount === "number" ? r.signCount : 0,2035 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2036 backupEligible: r.backupEligible === true,2037 backupState: r.backupState === true,2038 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2039 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2040 transports: Array.isArray(r.transports)2041 ? r.transports.filter((t): t is string => typeof t === "string")2042 : undefined,2043 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2044 };2045}20462047204820492050205120522053205420552056function sanitizeCanary(c: unknown): Canary | undefined {2057 if (!c || typeof c !== "object") return undefined;2058 const r = c as Record<string, unknown>;2059 if (typeof r.webhookUrl !== "string") return undefined;2060 let url: URL;2061 try {2062 url = new URL(r.webhookUrl);2063 } catch {2064 return undefined;2065 }2066 if (url.protocol !== "https:") return undefined;2067 return {2068 webhookUrl: url.toString(),2069 label: typeof r.label === "string" ? r.label : undefined,2070 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2071 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2072 };2073}20742075207620772078207920802081function sanitizeTags(tags: unknown): string[] | undefined {2082 if (!Array.isArray(tags)) return undefined;2083 const out: string[] = [];2084 for (const t of tags) {2085 if (typeof t !== "string") continue;2086 const trimmed = t.trim();2087 if (!trimmed) continue;2088 out.push(trimmed.slice(0, 256));2089 if (out.length >= 255) break;2090 }2091 return out.length > 0 ? out : undefined;2092}209320942095209620972098export async function clearVaultEntries(2099 confirmation: "REPLACE_ALL"2100): Promise<void> {2101 if (confirmation !== "REPLACE_ALL") {2102 throw new Error("clearVaultEntries requires explicit confirmation");2103 }2104 if (!derivedKey) throw new Error("vault_locked");2105 for (const entry of entries) {2106 if (!entry.deletedAt) {2107 const stamp = monotonicEntryTimestamp(entry.modifiedAt);2108 entry.deletedAt = stamp;2109 entry.modifiedAt = stamp;2110 }2111 }2112 await persistEntries();2113}21142115export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2116 if (!derivedKey) throw new Error("vault_locked");21172118 const sanitized = sanitizeEntryDraft(entry);2119 const now = Date.now();2120 const stamp = monotonicEntryTimestamp();2121 const nextEntry: VaultEntry = {2122 ...sanitized,2123 id: crypto.randomUUID(),2124 createdAt: now,2125 modifiedAt: stamp,2126 2127 2128 passwordChangedAt: sanitized.password ? stamp : undefined,2129 };21302131 2132 2133 2134 const rollback = entries;2135 entries = [...entries, nextEntry];2136 try {2137 await persistEntries();2138 } catch (err) {2139 entries = rollback;2140 throw err;2141 }21422143 2144 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2145 checkBreach(nextEntry.password).then(async (count) => {2146 if (count > 0) {2147 const idx = entries.findIndex((e) => e.id === nextEntry.id);2148 if (idx !== -1) {2149 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2150 await persistEntries();2151 }2152 }2153 });2154 }21552156 fireSyncTrigger("vault_change");21572158 return nextEntry;2159}21602161216221632164216521662167export async function consolidatePasskeyEntries(): Promise<void> {2168 if (!derivedKey) return;2169 const { merged } = mergePasskeyOnlyIntoLogins(entries);2170 if (merged) {2171 await persistEntries();2172 }2173}21742175export async function updateEntry(2176 id: string,2177 patch: Partial<VaultEntry>2178): Promise<void> {2179 if (!derivedKey) throw new Error("vault_locked");21802181 const idx = entries.findIndex((entry) => entry.id === id);2182 if (idx === -1 || entries[idx].deletedAt) {2183 throw new Error("entry_not_found");2184 }21852186 const current = entries[idx];2187 const sanitizedPatch = sanitizeEntryPatch(patch);2188 2189 2190 2191 const passwordChanged =2192 "password" in patch &&2193 typeof sanitizedPatch.password === "string" &&2194 sanitizedPatch.password !== current.password;2195 const stamp = monotonicEntryTimestamp(current.modifiedAt);2196 2197 2198 2199 2200 2201 2202 2203 const priorPassword = current.password;2204 const shouldCapture =2205 passwordChanged &&2206 typeof priorPassword === "string" &&2207 priorPassword.length > 0 &&2208 current.passwordHistory?.[0]?.password !== priorPassword;2209 const nextHistory = shouldCapture2210 ? [2211 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2212 ...(current.passwordHistory ?? []),2213 ].slice(0, PASSWORD_HISTORY_MAX)2214 : current.passwordHistory;2215 entries[idx] = {2216 ...current,2217 ...sanitizedPatch,2218 modifiedAt: stamp,2219 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2220 };2221222222232224 2225 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2226 entries[idx] = current;2227 throw new Error("card number is required for card entries");2228 }22292230 2231 2232 2233 2234 try {2235 await persistEntries();2236 } catch (err) {2237 entries[idx] = current;2238 throw err;2239 }22402241 2242 2243 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2244 entries[idx] = { ...entries[idx], breach: undefined };2245 await persistEntries();2246 const password = entries[idx].password;2247 if (password) {2248 checkBreach(password).then(async (count) => {2249 const i = entries.findIndex((e) => e.id === id);2250 if (i !== -1 && count > 0) {2251 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2252 await persistEntries();2253 }2254 });2255 }2256 }22572258 fireSyncTrigger("vault_change");2259}22602261export async function deleteEntry(id: string): Promise<void> {2262 if (!derivedKey) throw new Error("vault_locked");22632264 const entry = entries.find((item) => item.id === id);2265 if (!entry || entry.deletedAt) {2266 throw new Error("entry_not_found");2267 }22682269 2270 2271 2272 const previousModifiedAt = entry.modifiedAt;2273 const stamp = monotonicEntryTimestamp(entry.modifiedAt);2274 entry.deletedAt = stamp;2275 entry.modifiedAt = stamp;2276 2277 2278 try {2279 await persistEntries();2280 } catch (err) {2281 delete entry.deletedAt;2282 entry.modifiedAt = previousModifiedAt;2283 throw err;2284 }2285 fireSyncTrigger("vault_change");2286}22872288export function getDeletedEntries(): VaultEntry[] {2289 if (!derivedKey) return [];2290 return entries.filter((e) => !!e.deletedAt);2291}2292229322942295229622972298export async function getVaultVersion(): Promise<number> {2299 try {2300 const raw = await chrome.storage.local.get("peach_last_modified_at");2301 const v = (raw as Record<string, unknown>).peach_last_modified_at;2302 if (typeof v === "number") return v;2303 if (typeof v === "string" && v !== "") {2304 const n = Number.parseInt(v, 10);2305 return Number.isFinite(n) ? n : 0;2306 }2307 return 0;2308 } catch {2309 return 0;2310 }2311}231223132314231523162317231823192320232123222323export async function addEntryDirect(entry: VaultEntry): Promise<void> {2324 if (!derivedKey) throw new Error("vault_locked");2325 entries = [...entries, entry];2326 await persistEntries("sync_received");2327}23282329export async function updateEntryDirect(patch: VaultEntry): Promise<void> {2330 if (!derivedKey) throw new Error("vault_locked");2331 const idx = entries.findIndex((e) => e.id === patch.id);2332 if (idx === -1) throw new Error("entry_not_found");2333 entries[idx] = { ...entries[idx], ...patch };2334 await persistEntries("sync_received");2335}23362337export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {2338 if (!derivedKey) throw new Error("vault_locked");2339 const idx = entries.findIndex((e) => e.id === id);2340 if (idx === -1) throw new Error("entry_not_found");2341 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };2342 await persistEntries("sync_received");2343}2344234523462347234823492350235123522353235423552356export async function applyMergedRemoteEntries(2357 remoteEntries: VaultEntry[],2358 remoteTombstones: Record<string, number>,2359 _context: { deviceName?: string } = {},2360): Promise<{ received: number; applied: number; conflicts: number }> {2361 2362 2363 2364 2365 2366 2367 if (!derivedKey) throw new Error("vault_locked");23682369 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2370 let applied = 0;2371 let conflicts = 0;23722373 const byId = new Map<string, VaultEntry>();2374 for (const e of entries) byId.set(e.id, e);23752376 const mergeNow = Date.now();23772378 for (const remote of remoteEntries) {2379 const sanitized = sanitizeRestoredEntry(remote);2380 if (!sanitized) continue;2381 2382 2383 2384 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2385 if (typeof sanitized.deletedAt === "number") {2386 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2387 }2388 const tombstoneTs = remoteTombstones[sanitized.id];2389 if (tombstoneTs !== undefined && clampSyncTimestamp(tombstoneTs, mergeNow) >= sanitized.modifiedAt) {2390 continue;2391 }2392 const local = byId.get(sanitized.id);2393 if (!local) {2394 entries.push(sanitized);2395 byId.set(sanitized.id, sanitized);2396 applied += 1;2397 } else if (sanitized.modifiedAt > local.modifiedAt) {2398 const idx = entries.findIndex((e) => e.id === local.id);2399 if (idx !== -1) {2400 entries[idx] = sanitized;2401 byId.set(sanitized.id, sanitized);2402 applied += 1;2403 if (!local.deletedAt) conflicts += 1;2404 }2405 }2406 }24072408 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2409 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt)) continue;2410 2411 const deletedAt = clampSyncTimestamp(rawDeletedAt, mergeNow);2412 const local = byId.get(id);2413 if (!local) continue;2414 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2415 if (deletedAt < local.modifiedAt) continue;2416 const idx = entries.findIndex((e) => e.id === id);2417 if (idx !== -1) {2418 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2419 applied += 1;2420 }2421 }24222423 if (applied > 0) {2424 await persistEntries("sync_received");2425 buildDomainIndexSync(entries);2426 }2427 return { received, applied, conflicts };2428}24292430export async function restoreEntry(id: string): Promise<void> {2431 if (!derivedKey) throw new Error("vault_locked");2432 const entry = entries.find((e) => e.id === id && e.deletedAt);2433 if (!entry) throw new Error("deleted entry not found");2434 delete entry.deletedAt;2435 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);2436 await persistEntries();2437 buildDomainIndexSync(entries);2438}24392440export async function purgeEntry(id: string): Promise<void> {2441 if (!derivedKey) throw new Error("vault_locked");2442 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2443 if (idx === -1) throw new Error("deleted entry not found");2444 entries.splice(idx, 1);2445 await persistEntries();2446}244724482449export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2450 if (!derivedKey || retentionMs === 0) return 0;2451 const cutoff = Date.now() - retentionMs;2452 const before = entries.length;2453 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2454 const purged = before - entries.length;2455 if (purged > 0) await persistEntries();2456 return purged;2457}24582459export function queryDomain(domain: string): VaultEntry[] {2460 return domainIndex.get(domain) ?? [];2461}24622463export function getEntryById(id: string): VaultEntry | undefined {2464 return entries.find((entry) => entry.id === id && !entry.deletedAt);2465}24662467async function persistSlotA(2468 reason: VaultSnapshot["reason"] = "local_save",2469): Promise<StoredVault> {2470 if (!derivedKey) throw new Error("vault_locked");24712472 const slotA = await loadSlot(SLOT_A);2473 if (!slotA) throw new Error("missing slot A");24742475 2476 2477 await takeVaultSnapshot(reason);24782479 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2480 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");24812482 2483 const nextSlotA: StoredVault = {2484 ...slotA,2485 nonce,2486 ciphertext,2487 tag,2488 2489 };2490 await writeSlot(SLOT_A, nextSlotA);24912492 2493 2494 2495 2496 2497 2498 try {2499 2500 2501 2502 if (!duressConfigured) {2503 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2504 }2505 2506 2507 2508 2509 2510 if (reason !== "sync_received") {2511 await stampLocalVaultMutation();2512 }2513 } catch (err) {2514 2515 2516 await writeSlot(SLOT_A, slotA).catch(() => {});2517 throw err;2518 }25192520 return nextSlotA;2521}25222523async function persistSlotB(): Promise<void> {2524 if (!derivedKey) throw new Error("vault_locked");25252526 const slotB = await loadSlot(SLOT_B);2527 if (!slotB) throw new Error("missing slot B");25282529 2530 2531 2532 2533 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2534 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");25352536 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2537 2538 2539 2540 try {2541 await stampLocalVaultMutation();2542 } catch (err) {2543 await writeSlot(SLOT_B, slotB).catch(() => {});2544 throw err;2545 }2546}25472548async function persistEntries(2549 reason: VaultSnapshot["reason"] = "local_save",2550): Promise<void> {2551 if (!derivedKey) throw new Error("vault_locked");25522553 if (vaultMode === "decoy") {2554 await persistSlotB();2555 } else {2556 await persistSlotA(reason);2557 }2558 buildDomainIndexSync(entries);2559 await refreshLockedSyncState();2560}25612562function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2563 if (!Array.isArray(values)) return undefined;2564 const seen = new Set<string>();2565 const cleaned: string[] = [];2566 for (const value of values) {2567 const trimmed = typeof value === "string" ? value.trim() : "";2568 if (!trimmed) continue;2569 const key = trimmed.toLowerCase();2570 if (seen.has(key)) continue;2571 seen.add(key);2572 cleaned.push(trimmed);2573 }2574 return cleaned.length > 0 ? cleaned : undefined;2575}25762577function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2578 const title = entry.title.trim();2579 2580 2581 2582 let type: VaultEntry["type"] = entry.type ?? "login";2583 if (type === "passkey") type = "login";25842585 if (!title) throw new Error("title is required");25862587 if (entry.passkey) {2588 const p = entry.passkey;2589 if (2590 !p.rpId ||2591 !p.credentialId ||2592 !p.publicKeyCose ||2593 !p.userHandle ||2594 !p.userName ||2595 (p.algorithm !== -7 && p.algorithm !== -8)2596 ) {2597 throw new Error("invalid passkey credential");2598 }2599 }2600 if (type === "card") {2601 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2602 throw new Error("cardholder name and card number are required");2603 }2604 }26052606 return {2607 type,2608 title,2609 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2610 email: type === "login" ? entry.email?.trim() || undefined : undefined,2611 password: type === "login" ? entry.password : undefined,2612 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2613 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2614 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2615 2616 2617 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2618 totpDigits: type === "login" ? entry.totpDigits : undefined,2619 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2620 notes: entry.notes?.trim() || undefined,2621 breach: type === "login" ? entry.breach : undefined,2622 2623 2624 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2625 tags: sanitizeTags(entry.tags),2626 passkey: entry.passkey,2627 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2628 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2629 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2630 expYear: type === "card" ? entry.expYear?.trim() : undefined,2631 cvc: type === "card" ? entry.cvc?.trim() : undefined,2632 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2633 network: type === "card" ? entry.network : undefined,2634 last4: type === "card" ? entry.last4?.trim() : undefined,2635 };2636}26372638function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2639 const nextPatch: Partial<VaultEntry> = {};26402641 if ("type" in patch) {2642 nextPatch.type = patch.type;2643 }26442645 if ("title" in patch) {2646 const title = patch.title?.trim() ?? "";2647 if (!title) throw new Error("title is required");2648 nextPatch.title = title;2649 }26502651 if ("url" in patch) {2652 nextPatch.url = patch.url?.trim() ?? "";2653 }26542655 if ("additionalUrls" in patch) {2656 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2657 const seen = new Set<string>();2658 const cleaned: string[] = [];2659 for (const value of incoming) {2660 const trimmed = typeof value === "string" ? value.trim() : "";2661 if (!trimmed) continue;2662 const key = trimmed.toLowerCase();2663 if (seen.has(key)) continue;2664 seen.add(key);2665 cleaned.push(trimmed);2666 }2667 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2668 }26692670 if ("username" in patch) {2671 nextPatch.username = patch.username?.trim() ?? "";2672 }26732674 if ("email" in patch) {2675 nextPatch.email = patch.email?.trim() || undefined;2676 }26772678 if ("password" in patch) {2679 const password = patch.password ?? "";2680 2681 nextPatch.password = password || undefined;2682 }26832684 if ("totp" in patch) {2685 nextPatch.totp = patch.totp?.trim() || undefined;2686 }26872688 if ("notes" in patch) {2689 nextPatch.notes = patch.notes?.trim() || undefined;2690 }26912692 if ("breach" in patch) {2693 nextPatch.breach = patch.breach;2694 }26952696 if ("canary" in patch) {2697 2698 2699 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2700 }27012702 if ("passkey" in patch) {2703 2704 2705 2706 if (patch.passkey === undefined || patch.passkey === null) {2707 nextPatch.passkey = undefined;2708 } else {2709 const sanitized = sanitizePasskey(patch.passkey);2710 if (!sanitized) throw new Error("invalid passkey credential");2711 nextPatch.passkey = sanitized;2712 }2713 }27142715 if ("tags" in patch) {2716 2717 nextPatch.tags = sanitizeTags(patch.tags);2718 }27192720 if ("cardholderName" in patch) {2721 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2722 }2723 if ("cardNumber" in patch) {2724 const n = patch.cardNumber ?? "";2725 nextPatch.cardNumber = n || undefined;2726 }2727 if ("expMonth" in patch) {2728 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2729 }2730 if ("expYear" in patch) {2731 nextPatch.expYear = patch.expYear?.trim() || undefined;2732 }2733 if ("cvc" in patch) {2734 nextPatch.cvc = patch.cvc?.trim() || undefined;2735 }2736 if ("cardNickname" in patch) {2737 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2738 }2739 if ("network" in patch) {2740 nextPatch.network = patch.network;2741 }2742 if ("last4" in patch) {2743 nextPatch.last4 = patch.last4?.trim() || undefined;2744 }27452746 return nextPatch;2747}27482749function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2750 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2751 const modifiedAt =2752 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;27532754 const base: VaultEntry = {2755 id: entry.id,2756 type: "type" in entry ? entry.type : "login",2757 title: entry.title,2758 username: entry.username,2759 email: "email" in entry ? entry.email : undefined,2760 password: entry.password,2761 url: entry.url,2762 additionalUrls:2763 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2764 ? entry.additionalUrls2765 : undefined,2766 totp: entry.totp,2767 2768 totpAlgorithm: entry.totpAlgorithm,2769 totpDigits: entry.totpDigits,2770 totpPeriod: entry.totpPeriod,2771 notes: entry.notes,2772 breach: entry.breach,2773 2774 2775 2776 passwordChangedAt:2777 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2778 2779 2780 2781 passwordHistory:2782 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2783 ? entry.passwordHistory2784 : undefined,2785 canary: "canary" in entry ? entry.canary : undefined,2786 passkey: "passkey" in entry ? entry.passkey : undefined,2787 createdAt,2788 modifiedAt,2789 deletedAt: entry.deletedAt,2790 tags: "tags" in entry ? entry.tags : undefined,2791 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2792 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2793 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2794 expYear: "expYear" in entry ? entry.expYear : undefined,2795 cvc: "cvc" in entry ? entry.cvc : undefined,2796 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2797 network: "network" in entry ? entry.network : undefined,2798 last4: "last4" in entry ? entry.last4 : undefined,2799 };28002801 return migrateLegacyPasskey(base, entry);2802}280328042805280628072808280928102811function migrateLegacyPasskey(2812 base: VaultEntry,2813 raw: VaultEntry | LegacyVaultEntry2814): VaultEntry {2815 if (base.passkey) return base;2816 const flat = raw as Partial<VaultEntry>;2817 const isPasskeyType = base.type === "passkey";2818 const hasFlatPasskeyFields =2819 typeof flat.rpId === "string" &&2820 typeof flat.credentialId === "string" &&2821 typeof flat.publicKeyCose === "string" &&2822 typeof flat.userHandle === "string" &&2823 typeof flat.userName === "string";2824 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2825 if (!hasFlatPasskeyFields) return base;28262827 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2828 base.passkey = {2829 rpId: flat.rpId!,2830 rpName: flat.rpName ?? flat.rpId!,2831 credentialId: flat.credentialId!,2832 publicKeyCose: flat.publicKeyCose!,2833 privateKeyJwk: flat.privateKeyJwk,2834 userHandle: flat.userHandle!,2835 userName: flat.userName!,2836 userDisplayName: flat.userDisplayName ?? flat.userName!,2837 algorithm,2838 signCount: flat.signCount ?? 0,2839 aaguid: flat.aaguid ?? "",2840 backupEligible: flat.backupEligible === true,2841 backupState: flat.backupState === true,2842 lastUsedAt: flat.lastUsedAt,2843 browser: flat.browser,2844 transports: flat.transports,2845 createdAt: base.createdAt,2846 };2847 2848 2849 2850 if (isPasskeyType) base.type = "login";2851 if (!base.username) base.username = flat.userName;2852 if (!base.url) base.url = `https://${flat.rpId}`;2853 return base;2854}2855285628572858285928602861function entryDupeKey(e: VaultEntry): string {2862 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2863 2864 2865 2866 2867 if (e.passkey) {2868 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2869 }2870 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2871}287228732874export function countDuplicates(): number {2875 const best = new Map<string, VaultEntry>();2876 let dupeCount = 0;2877 for (const e of entries.filter((e) => !e.deletedAt)) {2878 const key = entryDupeKey(e);2879 const existing = best.get(key);2880 if (!existing) {2881 best.set(key, e);2882 } else {2883 dupeCount++;2884 2885 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2886 best.set(key, e);2887 }2888 }2889 }2890 return dupeCount;2891}289228932894289528962897export async function deduplicateEntries(): Promise<number> {2898 if (!derivedKey) throw new Error("vault_locked");2899 2900 2901 if (vaultMode === "decoy") {2902 throw new Error("deduplicate disabled in decoy mode");2903 }29042905 const best = new Map<string, VaultEntry>();2906 for (const e of entries.filter((en) => !en.deletedAt)) {2907 const key = entryDupeKey(e);2908 const existing = best.get(key);2909 if (!existing) {2910 best.set(key, e);2911 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2912 best.set(key, e);2913 }2914 }29152916 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2917 let removed = 0;2918 entries = entries.map((e) => {2919 if (!e.deletedAt && !keepIds.has(e.id)) {2920 removed++;2921 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2922 }2923 return e;2924 });29252926 if (removed > 0) await persistEntries();2927 return removed;2928}292929302931293229332934293529362937const HISTORY_KEY = "peach_vault_history";2938const HISTORY_MAX = 5;293929402941294229432944function bucketEntryCount(n: number): number {2945 if (n <= 0) return 0;2946 if (n <= 10) return 10;2947 if (n <= 25) return 25;2948 if (n <= 50) return 50;2949 if (n <= 100) return 100;2950 if (n <= 250) return 250;2951 if (n <= 500) return 500;2952 return Math.ceil(n / 500) * 500;2953}29542955295629572958295929602961export async function takeVaultSnapshot(2962 reason: VaultSnapshot["reason"],2963): Promise<void> {2964 try {2965 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2966 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2967 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];29682969 2970 2971 2972 if (!slotA) return;29732974 const vault_a = JSON.stringify(slotA);2975 const vault_b = JSON.stringify(slotB ?? slotA);29762977 2978 2979 2980 2981 2982 2983 let entryCount: number | null = null;2984 if (derivedKey && vaultMode === "real") {2985 try {2986 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);2987 } catch {2988 entryCount = null;2989 }2990 }29912992 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2993 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2994 ? existingRaw2995 : { snapshots: [] };29962997 2998 2999 3000 const latest = history.snapshots[0];3001 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3002 return;3003 }30043005 const snapshot: VaultSnapshot = {3006 id: crypto.randomUUID(),3007 savedAt: Date.now(),3008 reason,3009 vault_a,3010 vault_b,3011 entryCount,3012 };30133014 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3015 await chrome.storage.local.set({ [HISTORY_KEY]: history });3016 } catch (err) {3017 3018 3019 3020 3021 console.warn('[Peach] takeVaultSnapshot failed:', err);3022 if (String(err).includes('QUOTA') || String(err).includes('quota')) {3023 try {3024 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3025 } catch {3026 3027 }3028 }3029 }3030}303130323033export async function clearVaultHistory(): Promise<void> {3034 try {3035 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3036 } catch {3037 3038 }3039}304030413042export async function getVaultHistory(): Promise<VaultSnapshot[]> {3043 const raw = await chrome.storage.local.get(HISTORY_KEY);3044 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3045 if (!history || !Array.isArray(history.snapshots)) return [];3046 return history.snapshots;3047}30483049305030513052305330543055export async function restoreVaultSnapshot(3056 snapshotId: string,3057): Promise<{ ok: true } | { ok: false; error: string }> {3058 const history = await getVaultHistory();3059 const snapshot = history.find((s) => s.id === snapshotId);3060 if (!snapshot) return { ok: false, error: "snapshot_not_found" };30613062 3063 3064 3065 await takeVaultSnapshot("restore");30663067 let restoredA: StoredVault;3068 let restoredB: StoredVault;3069 try {3070 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3071 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3072 } catch {3073 return { ok: false, error: "snapshot_corrupt" };3074 }30753076 await writeSlot(SLOT_A, restoredA);3077 await writeSlot(SLOT_B, restoredB);3078 await stampLocalVaultMutation();30793080 3081 3082 3083 lock();30843085 return { ok: true };3086}308730883089309030913092309330943095309630973098309931003101export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3102 if (!derivedKey || vaultMode !== "real") {3103 return { ok: false, error: "vault_locked" };3104 }3105 const slotA = await loadSlot(SLOT_A);3106 if (!slotA) return { ok: false, error: "missing_slot_a" };31073108 3109 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3110 await generateAndWrapRootKey();31113112 3113 const newRootKey = unhex(rawRootKey);3114 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);31153116 3117 3118 3119 3120 await takeVaultSnapshot("local_save");3121 const nextSlotA: StoredVault = {3122 ...slotA,3123 recovery_salt: recoverySalt,3124 recovery_wrapped_root_key: wrappedRootKey,3125 recovery_nonce: wrappedNonce,3126 recovery_tag: wrappedTag,3127 ...pwRoot,3128 };3129 await writeSlot(SLOT_A, nextSlotA);31303131 3132 if (rootKey) zeroKey(rootKey);3133 rootKey = newRootKey;31343135 3136 await clearPairedDevices();31373138 3139 await setRecoveryConfirmPending(); 31403141 await stampLocalVaultMutation();3142 return { ok: true };3143}31443145314631473148314931503151export async function changePassword(3152 currentPassword: string,3153 newPassword: string3154): Promise<boolean> {3155 if (!derivedKey || vaultMode !== "real") return false;31563157 3158 const valid = await verifyMainPassword(currentPassword);3159 if (!valid) return false;31603161 const slotA = await loadSlot(SLOT_A);3162 if (!slotA) return false;3163 if (!rootKey) return false;31643165 3166 const newSalt = generateSalt();3167 const newKey = await deriveKey(newPassword, newSalt);31683169 3170 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3171 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");31723173 3174 3175 3176 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);31773178 const finalSlotA: StoredVault = {3179 ...slotA,3180 salt: hex(newSalt),3181 nonce,3182 ciphertext,3183 tag,3184 ...pwRoot,3185 };31863187 3188 3189 3190 await takeVaultSnapshot("local_save");31913192 3193 3194 3195 3196 await writeSlot(SLOT_A, finalSlotA);31973198 3199 3200 3201 zeroKey(derivedKey);3202 derivedKey = newKey;32033204 3205 3206 3207 3208 void saveSessionKey(newKey, "real");32093210 3211 3212 3213 if (!duressConfigured) {3214 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3215 }3216 await clearPinUnlockStorage();3217 await stampLocalVaultMutation();32183219 return true;3220}