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 141 pinEnabled?: boolean;142 143 pinPreferred?: boolean;144 145146147148149150151 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;152}153154interface StoredPinWrap {155 version: 1;156 salt: string;157 nonce: string;158 ciphertext: string;159 tag: string;160}161162const PIN_WRAP_STORAGE = "peach_pin_wrap";163const PIN_MIN_LENGTH = 4;164const PIN_MAX_LENGTH = 12;165166let settings: VaultSettings = {};167168export function getSettings(): VaultSettings {169 return settings;170}171172export async function ensureSettingsLoaded(): Promise<VaultSettings> {173 await loadSettings();174 return settings;175}176177export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {178 await loadSettings();179 180 181 182 183 const safe = Object.fromEntries(184 Object.entries(patch).filter(185 ([k]) => !["__proto__", "constructor", "prototype"].includes(k),186 ),187 ) as Partial<VaultSettings>;188 settings = { ...settings, ...safe };189 await persistSettings();190}191192function isValidPin(pin: string): boolean {193 return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);194}195196197export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {198 if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;199 if (!(await verifyMainPassword(currentPassword))) return false;200201 const salt = generateSalt();202 const pinKey = await deriveKey(pin, salt);203 try {204 const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");205 await chrome.storage.local.set({206 [PIN_WRAP_STORAGE]: { version: 1, salt: hex(salt), ...wrapped } satisfies StoredPinWrap,207 });208 await updateSettings({ pinEnabled: true });209 return true;210 } finally {211 zeroKey(pinKey);212 }213}214215async function clearPinUnlockStorage(): Promise<void> {216 await chrome.storage.local.remove(PIN_WRAP_STORAGE);217 await updateSettings({ pinEnabled: false });218}219220221export async function disablePinUnlock(currentPassword: string): Promise<boolean> {222 if (!derivedKey || vaultMode !== "real") return false;223 if (!(await verifyMainPassword(currentPassword))) return false;224 await clearPinUnlockStorage();225 return true;226}227228229export async function unlockWithPin(pin: string): Promise<boolean> {230 if (!isValidPin(pin)) return false;231 await loadSettings();232 if (!settings.pinEnabled) return false;233234 const stored = (await chrome.storage.local.get(PIN_WRAP_STORAGE))[PIN_WRAP_STORAGE] as235 | StoredPinWrap236 | undefined;237 if (238 !stored ||239 stored.version !== 1 ||240 !stored.salt ||241 !stored.nonce ||242 !stored.ciphertext ||243 !stored.tag244 ) return false;245246 const pinKey = await deriveKey(pin, unhex(stored.salt));247 let unwrappedKey: Uint8Array | null = null;248 try {249 const masterKeyHex = await decrypt(250 pinKey,251 stored.nonce,252 stored.ciphertext,253 stored.tag,254 "pin_wrap_v1",255 );256 if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;257 unwrappedKey = unhex(masterKeyHex);258 const result = await unlockWithKey(masterKeyHex);259 if (!result.ok) return false;260 await saveSessionKey(unwrappedKey, "real");261 return true;262 } catch {263 return false;264 } finally {265 zeroKey(pinKey);266 if (unwrappedKey) zeroKey(unwrappedKey);267 }268}269270async function persistSettings(): Promise<void> {271 try {272 await chrome.storage.local.set({ peach_settings: settings });273 } catch (err) {274 throw mapStorageError(err);275 }276}277278function mapStorageError(err: unknown): Error {279 const message = err instanceof Error ? err.message : String(err);280 if (/QUOTA_BYTES|quota/i.test(message)) {281 return new Error("STORAGE_QUOTA_EXCEEDED");282 }283 return err instanceof Error ? err : new Error(message);284}285286async function loadSettings(): Promise<void> {287 const result = await chrome.storage.local.get("peach_settings");288 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};289}290291292293function fireSyncTrigger(reason: "vault_change"): void {294 void Promise.resolve()295 .then(() => triggerSyncIfReachable({ reason }))296 .catch(() => {297 298 });299}300301let lockedSyncRefreshHook: (() => Promise<void>) | null = null;302let lockedSyncUnlockHook: (() => Promise<void>) | null = null;303304export function setLockedSyncHooks(hooks: {305 refresh: () => Promise<void>;306 afterUnlock: () => Promise<void>;307}): void {308 lockedSyncRefreshHook = hooks.refresh;309 lockedSyncUnlockHook = hooks.afterUnlock;310}311312async function refreshLockedSyncState(): Promise<void> {313 if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;314 await lockedSyncRefreshHook().catch((error) => {315 console.warn("[Peach sync] locked spool refresh failed:", error);316 });317}318319320321322323export async function checkBreach(password: string): Promise<number> {324 325 326 const encoder = new TextEncoder();327 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));328 const hashHex = Array.from(new Uint8Array(hashBuffer))329 .map((b) => b.toString(16).padStart(2, "0"))330 .join("")331 .toUpperCase();332333 const prefix = hashHex.slice(0, 5);334 const suffix = hashHex.slice(5);335336 try {337 338 339 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {340 headers: { "Add-Padding": "true" },341 });342 if (!res.ok) return 0;343 const text = await res.text();344 const match = text.split("\r\n").find((l) => l.startsWith(suffix));345 return match ? parseInt(match.split(":")[1], 10) : 0;346 } catch {347 return 0; 348 }349}350351export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {352 const fingerprint = await cardFingerprint(last4, expMonth, expYear);353 await loadSettings();354 settings = {355 ...settings,356 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],357 };358 await persistSettings();359}360361export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {362 await loadSettings();363 const fingerprint = await cardFingerprint(last4, expMonth, expYear);364 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);365}366367async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {368 const encoder = new TextEncoder();369 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));370 return Array.from(new Uint8Array(buf).slice(0, 8))371 .map((b) => b.toString(16).padStart(2, "0"))372 .join("");373}374375function hex(buf: Uint8Array): string {376 return Array.from(buf)377 .map((b) => b.toString(16).padStart(2, "0"))378 .join("");379}380381function unhex(s: string): Uint8Array {382 const out = new Uint8Array(s.length / 2);383 for (let i = 0; i < s.length; i += 2) {384 out[i / 2] = parseInt(s.slice(i, i + 2), 16);385 }386 return out;387}388389390391392393394395396397398399400401402403404405406407408async function generateAndWrapRootKey(): Promise<{409 rawRootKey: string; 410 recoverySalt: string;411 wrappedRootKey: string;412 wrappedNonce: string;413 wrappedTag: string;414}> {415 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));416417 418 const words = await rootKeyToWords(rootKeyBytes);419 const wordMaterial = await wordsToRootKeyMaterial(words);420 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));421 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);422423 424 425 const nonce = crypto.getRandomValues(new Uint8Array(12));426 const encoded = new TextEncoder().encode(hex(rootKeyBytes));427 const raw = await crypto.subtle.encrypt(428 {429 name: "AES-GCM",430 iv: nonce,431 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,432 tagLength: 128,433 },434 recoveryKey,435 encoded436 );437 438 const rawU8 = new Uint8Array(raw);439 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));440 const tag = hex(rawU8.slice(rawU8.length - 16));441442 return {443 rawRootKey: hex(rootKeyBytes),444 recoverySalt: hex(recoverySalt),445 wrappedRootKey: ciphertext,446 wrappedNonce: hex(nonce),447 wrappedTag: tag,448 };449}450451async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{452 recoverySalt: string;453 wrappedRootKey: string;454 wrappedNonce: string;455 wrappedTag: string;456}> {457 if (rootKeyBytes.length !== 32) {458 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");459 }460461 const words = await rootKeyToWords(rootKeyBytes);462 const wordMaterial = await wordsToRootKeyMaterial(words);463 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));464 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);465 const nonce = crypto.getRandomValues(new Uint8Array(12));466 const encoded = new TextEncoder().encode(hex(rootKeyBytes));467 const raw = await crypto.subtle.encrypt(468 {469 name: "AES-GCM",470 iv: nonce,471 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,472 tagLength: 128,473 },474 recoveryKey,475 encoded,476 );477 const rawU8 = new Uint8Array(raw);478 return {479 recoverySalt: hex(recoverySalt),480 wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),481 wrappedNonce: hex(nonce),482 wrappedTag: hex(rawU8.slice(rawU8.length - 16)),483 };484}485486487488489490491export async function unwrapRootKeyForRecovery(492 wrappedRootKey: string,493 wrappedNonce: string,494 wrappedTag: string,495 recoverySaltHex: string,496 words: string[]497): Promise<void> {498 const wordMaterial = await wordsToRootKeyMaterial(words);499 const recoverySalt = unhex(recoverySaltHex);500 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);501502 503 504 505 506 507 const nonceRaw = unhex(wrappedNonce);508 const ciphertextRaw = unhex(wrappedRootKey);509 const tagRaw = unhex(wrappedTag);510 511 512 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;513 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);514 combined.set(ciphertextRaw, 0);515 combined.set(tagRaw, ciphertextRaw.length);516 const raw = await crypto.subtle.decrypt(517 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },518 recoveryKey,519 combined520 );521 const decryptedHex = new TextDecoder().decode(raw);522 rootKey = unhex(decryptedHex);523}524525526527528529530531532533534export async function rewrapRootKeyForPassword(535 newDerivedKey: Uint8Array,536 _newSalt: Uint8Array537): Promise<void> {538 if (!rootKey) throw new Error("root key not set");539 const slotA = await loadSlot(SLOT_A);540 if (!slotA) throw new Error("missing slot A");541 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);542 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });543}544545546547548549550async function reencryptVaultForNewKey(551 newKey: Uint8Array,552 slotA: StoredVault553): Promise<void> {554 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);555 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");556 await writeSlot(SLOT_A, {557 ...slotA,558 nonce,559 ciphertext,560 tag,561 });562}563564565566567568569export async function changePasswordWithNewKey(570 newSalt: Uint8Array,571 newKey: Uint8Array572): Promise<void> {573 const slotA = await loadSlot(SLOT_A);574 if (!slotA) throw new Error("missing slot A");575 576 577 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });578 if (derivedKey) zeroKey(derivedKey);579 derivedKey = newKey;580 581 582 583 void saveSessionKey(newKey, "real");584 585 586 587 588 if (rootKey) {589 const slotAfter = await loadSlot(SLOT_A);590 if (slotAfter) {591 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);592 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });593 }594 }595 if (!duressConfigured) {596 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));597 }598 await clearPinUnlockStorage();599 await stampLocalVaultMutation();600}601602603604605606607export { deriveKey, generateSalt };608609export async function loadSlotA(): Promise<StoredVault | null> {610 return loadSlot(SLOT_A);611}612613614615616617618619620621622623624625async function deriveRootKeyFromDerivedKey(626 derivedKeyBytes: Uint8Array,627 slotA: StoredVault,628): Promise<Uint8Array | null> {629 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {630 return null;631 }632 try {633 const rootHex = await decrypt(634 derivedKeyBytes,635 slotA.pw_root_nonce,636 slotA.pw_root_ciphertext,637 slotA.pw_root_tag,638 "pw_root_v1",639 );640 return unhex(rootHex);641 } catch {642 return null;643 }644}645646647648async function wrapRootKeyForDerivedKey(649 derivedKeyBytes: Uint8Array,650 rootKeyBytes: Uint8Array,651): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {652 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");653 return {654 pw_root_ciphertext: ciphertext,655 pw_root_nonce: nonce,656 pw_root_tag: tag,657 };658}659660661662663664665666667export async function migrateToRecovery(): Promise<void> {668 if (!derivedKey || vaultMode !== "real") {669 throw new Error("vault must be unlocked in real mode");670 }671 const slotA = await loadSlot(SLOT_A);672 if (!slotA) throw new Error("missing slot A");673 const hasRecoveryEscrow =674 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;675 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;676 if (hasRecoveryEscrow && hasPwRoot) return; 677678 679 680 681 682 683 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =684 await generateAndWrapRootKey();685 rootKey = unhex(rawRootKey);686687 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);688 const nextSlotA: StoredVault = {689 ...slotA,690 recovery_salt: recoverySalt,691 recovery_wrapped_root_key: wrappedRootKey,692 recovery_nonce: wrappedNonce,693 recovery_tag: wrappedTag,694 ...pwRoot,695 };696 await writeSlot(SLOT_A, nextSlotA);697 await stampLocalVaultMutation();698}699700701702703704705export async function getRecoveryWords(): Promise<string[]> {706 if (!rootKey) {707 throw new Error("recovery not set up — vault may need migration");708 }709 return rootKeyToWords(rootKey);710}711712async function loadSlot(key: string): Promise<StoredVault | null> {713 const result = await chrome.storage.local.get(key);714 return (result as Record<string, StoredVault | undefined>)[key] ?? null;715}716717async function writeSlot(key: string, vault: StoredVault): Promise<void> {718 try {719 await chrome.storage.local.set({ [key]: vault });720 } catch (err) {721 throw mapStorageError(err);722 }723}724725726727728729730731732733734export async function rollbackAdoptedVault(): Promise<void> {735 lock();736 try {737 await chrome.storage.local.remove([738 SLOT_A,739 SLOT_B,740 "peach_last_modified_at",741 ]);742 } catch (err) {743 throw mapStorageError(err);744 }745}746747async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {748 const raw = await chrome.storage.local.get("peach_last_modified_at");749 const previous = Number(750 (raw as Record<string, unknown>).peach_last_modified_at,751 );752 const next = Math.max(753 Date.now(),754 Number.isFinite(previous) ? previous + 1 : 0,755 );756 try {757 await chrome.storage.local.set({758 [SLOT_A]: slotA,759 [SLOT_B]: slotB,760 peach_last_modified_at: String(next),761 });762 } catch (err) {763 throw mapStorageError(err);764 }765}766767768769770771772773774let lastEntryTimestamp = 0;775function monotonicEntryTimestamp(currentModifiedAt?: number): number {776 const base = Math.max(777 Date.now(),778 (currentModifiedAt ?? 0) + 1,779 lastEntryTimestamp + 1,780 );781 lastEntryTimestamp = base;782 return base;783}784785async function stampLocalVaultMutation(): Promise<void> {786 const raw = await chrome.storage.local.get("peach_last_modified_at");787 const previous = Number(788 (raw as Record<string, unknown>).peach_last_modified_at,789 );790 const next = Math.max(791 Date.now(),792 Number.isFinite(previous) ? previous + 1 : 0,793 );794 await chrome.storage.local.set({795 peach_last_modified_at: String(next),796 });797}798799800801802803804async function ensureMigrated(): Promise<void> {805 if (migrationRan) return;806 migrationRan = true;807808 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];809 const raw = await chrome.storage.local.get(keys);810 const slotAExists = raw[SLOT_A] !== undefined;811 const slotBExists = raw[SLOT_B] !== undefined;812 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;813 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;814815 816 if (!slotAExists && !legacyMain) return;817818 819 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;820821 let didMigrate = false;822 if (legacyMain && !slotAExists) {823 await writeSlot(SLOT_A, legacyMain);824 didMigrate = true;825 }826827 828 if (!slotBExists) {829 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;830 if (legacyDecoy) {831 await writeSlot(SLOT_B, legacyDecoy);832 didMigrate = true;833 } else if (target) {834 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));835 didMigrate = true;836 }837 }838839 const toRemove: string[] = [];840 if (legacyMain) toRemove.push(LEGACY_MAIN);841 if (legacyDecoy) toRemove.push(LEGACY_DECOY);842 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);843 if (toRemove.length > 0) {844 await chrome.storage.local.remove(toRemove);845 didMigrate = true;846 }847 if (didMigrate) {848 await stampLocalVaultMutation();849 }850}851852853854855856857858859860861862function padPlaintext(json: string, minBytes = 0): string {863 const encoded = new TextEncoder().encode(json);864 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);865 866 const target = Math.max(bucket, minBytes);867 const padLen = target - encoded.length;868 return json + " ".repeat(padLen);869}870871872873874875876877878879880881const CSPRNG_CHUNK = 65_536;882883function fillRandom(buf: Uint8Array): Uint8Array {884 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {885 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);886 const chunk = new Uint8Array(end - offset);887 crypto.getRandomValues(chunk);888 buf.set(chunk, offset);889 }890 return buf;891}892893function generateNoiseBlob(ciphertextBytes: number): StoredVault {894 const salt = crypto.getRandomValues(new Uint8Array(16));895 const nonce = crypto.getRandomValues(new Uint8Array(12));896 const cipher = fillRandom(new Uint8Array(ciphertextBytes));897 const tag = crypto.getRandomValues(new Uint8Array(16));898 return {899 version: 1,900 salt: hex(salt),901 nonce: hex(nonce),902 ciphertext: hex(cipher),903 tag: hex(tag),904 };905}906907908909910911type VaultPayload = {912 entries: VaultEntry[];913 duressConfigured?: boolean;914};915916type DecryptedPayload = {917 entries: VaultEntry[];918 duressConfigured: boolean;919 920921922923924925926 migratedFromLegacy: boolean;927};928929function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {930 const envelope: VaultPayload = { entries, duressConfigured: duress };931 return padPlaintext(JSON.stringify(envelope), minBytes);932}933934function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {935 if ("created" in raw || "updated" in raw) return true;936 const flat = raw as Partial<VaultEntry>;937 if (flat.type === "passkey") return true;938 939 if (flat.rpId !== undefined && flat.passkey === undefined) return true;940 return false;941}942943function decodePayload(plain: string): DecryptedPayload {944 const parsed = JSON.parse(plain) as945 | Array<VaultEntry | LegacyVaultEntry>946 | VaultPayload;947948 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];949 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);950951 const normalized = rawEntries.map(normalizeEntry);952 const { merged } = mergePasskeyOnlyIntoLogins(normalized);953954 if (Array.isArray(parsed)) {955 return {956 entries: normalized,957 duressConfigured: false,958 migratedFromLegacy: true, 959 };960 }961962 return {963 entries: normalized,964 duressConfigured: parsed.duressConfigured === true,965 migratedFromLegacy: flatMigrationNeeded || merged,966 };967}968969970971972973974975976977978979980981982983984985986987988989990991992function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {993 let merged = false;994 const now = Date.now();995996 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);997 const byRpId = new Map<string, VaultEntry[]>();998 for (const e of candidates) {999 const rpId = e.passkey!.rpId;1000 const list = byRpId.get(rpId);1001 if (list) list.push(e);1002 else byRpId.set(rpId, [e]);1003 }10041005 for (const [rpId, group] of byRpId) {1006 if (group.length === 0) continue;1007 group.sort((a, b) => {1008 const aT =1009 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;1010 const bT =1011 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;1012 return bT - aT;1013 });1014 const winner = group[0]!;10151016 1017 1018 1019 const loginTarget = entries.find((e) => {1020 if (e.deletedAt || e.passkey || e.type !== "login") return false;1021 if (e.url && extractDomain(e.url) === rpId) return true;1022 for (const extra of e.additionalUrls ?? []) {1023 if (extractDomain(extra) === rpId) return true;1024 }1025 return false;1026 });10271028 if (loginTarget) {1029 loginTarget.passkey = winner.passkey;1030 1031 1032 1033 if (!loginTarget.totp && winner.totp) {1034 loginTarget.totp = winner.totp;1035 loginTarget.totpAlgorithm = winner.totpAlgorithm;1036 loginTarget.totpDigits = winner.totpDigits;1037 loginTarget.totpPeriod = winner.totpPeriod;1038 }1039 loginTarget.modifiedAt = now;1040 for (const dup of group) {1041 if (!dup.deletedAt) {1042 dup.deletedAt = now;1043 dup.modifiedAt = now;1044 }1045 }1046 merged = true;1047 } else if (group.length > 1) {1048 for (let i = 1; i < group.length; i += 1) {1049 const dup = group[i]!;1050 if (!dup.deletedAt) {1051 dup.deletedAt = now;1052 dup.modifiedAt = now;1053 }1054 }1055 merged = true;1056 }1057 }10581059 return { merged };1060}10611062106310641065106610671068function hasValidVaultSchema(v: unknown): v is StoredVault {1069 if (typeof v !== "object" || v === null) return false;1070 const r = v as Record<string, unknown>;1071 return (1072 r.version === 1 &&1073 typeof r.salt === "string" &&1074 typeof r.nonce === "string" &&1075 typeof r.ciphertext === "string" &&1076 typeof r.tag === "string"1077 );1078}10791080export type TryDecryptResult =1081 | { status: "ok"; payload: DecryptedPayload }1082 | { status: "wrong_key" }1083 | { status: "corrupted"; reason: string };1084108510861087108810891090export async function tryDecryptSlot(1091 vault: StoredVault,1092 key: Uint8Array,1093 aadTag?: string,1094): Promise<DecryptedPayload | null> {1095 const res = await tryDecryptSlotResult(vault, key, aadTag);1096 return res.status === "ok" ? res.payload : null;1097}10981099export async function tryDecryptSlotResult(1100 vault: StoredVault,1101 key: Uint8Array,1102 aadTag?: string,1103): Promise<TryDecryptResult> {1104 if (!hasValidVaultSchema(vault)) {1105 return { status: "corrupted", reason: "invalid_slot_schema" };1106 }1107 let plain: string;1108 try {1109 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1110 } catch {1111 return { status: "wrong_key" };1112 }1113 try {1114 const parsed = JSON.parse(plain) as unknown;1115 if (typeof parsed !== "object" || parsed === null) {1116 return { status: "corrupted", reason: "invalid_payload_shape" };1117 }1118 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1119 return { status: "corrupted", reason: "invalid_payload_shape" };1120 }1121 return { status: "ok", payload: decodePayload(plain) };1122 } catch {1123 return { status: "corrupted", reason: "invalid_payload_json" };1124 }1125}112611271128112911301131function buildDomainIndexSync(all: VaultEntry[]): void {1132 domainIndex = new Map();11331134 for (const entry of all) {1135 if (entry.deletedAt) continue;1136 if (entry.type === "note" || entry.type === "card") continue;11371138 1139 1140 1141 1142 const domains = new Set<string>();1143 const primary = extractDomain(entry.url ?? "");1144 if (primary) domains.add(primary);1145 for (const extra of entry.additionalUrls ?? []) {1146 const d = extractDomain(extra);1147 if (d) domains.add(d);1148 }1149 if (domains.size === 0) continue;11501151 for (const domain of domains) {1152 const bucket = domainIndex.get(domain) ?? [];1153 bucket.push(entry);1154 domainIndex.set(domain, bucket);1155 }1156 }1157}11581159export async function createVault(password: string): Promise<void> {1160 await ensureMigrated();11611162 const salt = generateSalt();1163 derivedKey = await deriveKey(password, salt);1164 entries = [];1165 vaultMode = "real";1166 duressConfigured = false;1167 buildDomainIndexSync(entries);11681169 1170 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1171 await generateAndWrapRootKey();1172 rootKey = unhex(rawRootKey);11731174 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1175 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");11761177 1178 1179 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);11801181 const slotA: StoredVault = {1182 version: 1,1183 salt: hex(salt),1184 nonce,1185 ciphertext,1186 tag,1187 recovery_salt: recoverySalt,1188 recovery_wrapped_root_key: wrappedRootKey,1189 recovery_nonce: wrappedNonce,1190 recovery_tag: wrappedTag,1191 ...pwRoot,1192 };1193 await writeSlot(SLOT_A, slotA);11941195 1196 1197 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1198 await stampLocalVaultMutation();11991200 touchAutoLock();1201}1202120312041205120612071208export async function createVaultFromRootKey(1209 password: string,1210 rootKeyHex: string,1211): Promise<{ vaultId: string }> {1212 if (typeof password !== "string" || password.length === 0) {1213 throw new Error("ADOPT_INVALID_PASSWORD");1214 }1215 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1216 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1217 }12181219 const rootKeyBytes = unhex(rootKeyHex);1220 if (rootKeyBytes.length !== 32) {1221 rootKeyBytes.fill(0);1222 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1223 }12241225 let nextDerivedKey: Uint8Array | null = null;1226 let sessionRootKey: Uint8Array | null = null;1227 try {1228 await ensureMigrated();1229 if ((await loadSlot(SLOT_A)) !== null) {1230 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1231 }12321233 const nextEntries: VaultEntry[] = [];1234 const nextDuressConfigured = false;1235 const vaultId = await computeVaultId(rootKeyBytes);1236 const salt = generateSalt();1237 nextDerivedKey = await deriveKey(password, salt);1238 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);12391240 sessionRootKey = new Uint8Array(rootKeyBytes);1241 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1242 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1243 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1244 const slotA: StoredVault = {1245 version: 1,1246 salt: hex(salt),1247 nonce,1248 ciphertext,1249 tag,1250 recovery_salt: recovery.recoverySalt,1251 recovery_wrapped_root_key: recovery.wrappedRootKey,1252 recovery_nonce: recovery.wrappedNonce,1253 recovery_tag: recovery.wrappedTag,1254 ...pwRoot,1255 };1256 const slotB = generateNoiseBlob(ciphertext.length / 2);1257 await commitCreatedVaultSlots(slotA, slotB);12581259 const previousDerivedKey = derivedKey;1260 const previousRootKey = rootKey;1261 derivedKey = nextDerivedKey;1262 rootKey = sessionRootKey;1263 nextDerivedKey = null;1264 sessionRootKey = null;1265 entries = nextEntries;1266 vaultMode = "real";1267 duressConfigured = nextDuressConfigured;1268 buildDomainIndexSync(entries);1269 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1270 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1271 touchAutoLock();12721273 return { vaultId };1274 } finally {1275 rootKeyBytes.fill(0);1276 if (nextDerivedKey) zeroKey(nextDerivedKey);1277 if (sessionRootKey) sessionRootKey.fill(0);1278 }1279}12801281export async function hasVault(): Promise<boolean> {1282 await ensureMigrated();1283 return (await loadSlot(SLOT_A)) !== null;1284}1285128612871288128912901291export async function isDuressEnabled(): Promise<boolean> {1292 return vaultMode === "real" && duressConfigured;1293}12941295export async function setupDuress(duressPassword: string): Promise<void> {1296 if (!derivedKey || vaultMode !== "real") {1297 throw new Error("vault must be unlocked in real mode");1298 }1299 await ensureMigrated();13001301 1302 1303 1304 const slotA = await loadSlot(SLOT_A);1305 if (!slotA) throw new Error("missing slot A");13061307 const decoySalt = generateSalt();1308 const decoyKey = await deriveKey(duressPassword, decoySalt);1309 try {1310 1311 1312 1313 1314 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1315 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1316 await writeSlot(SLOT_B, {1317 version: 1,1318 salt: hex(decoySalt),1319 nonce,1320 ciphertext,1321 tag,1322 });1323 } finally {1324 zeroKey(decoyKey);1325 }13261327 1328 1329 duressConfigured = true;1330 await persistSlotA();1331}13321333export async function disableDuress(): Promise<void> {1334 if (!derivedKey || vaultMode !== "real") {1335 throw new Error("vault must be unlocked in real mode");1336 }1337 await ensureMigrated();13381339 1340 1341 1342 const slotA = await loadSlot(SLOT_A);1343 if (slotA) {1344 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1345 }13461347 duressConfigured = false;1348 await persistSlotA();1349}13501351135213531354135513561357export async function verifyMainPassword(password: string): Promise<boolean> {1358 await ensureMigrated();1359 const slotA = await loadSlot(SLOT_A);1360 if (!slotA) return false;1361 const key = await deriveKey(password, unhex(slotA.salt));1362 try {1363 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1364 } finally {1365 zeroKey(key);1366 }1367}13681369export async function unlock(password: string): Promise<boolean> {1370 1371 1372 1373 if (await attemptUnlockWithDerive(password, deriveKey)) return true;13741375 const nfc = password.normalize("NFC");1376 if (password === nfc) return false; 13771378 if (await attemptUnlockWithDerive(password, deriveKeyRaw)) {1379 1380 1381 1382 if (vaultMode === "real") {1383 try {1384 const newSalt = generateSalt();1385 const newKey = await deriveKey(password, newSalt);1386 await changePasswordWithNewKey(newSalt, newKey);1387 } catch (healErr) {1388 console.warn("[Peach] LV-1 NFC heal failed:", healErr);1389 }1390 }1391 return true;1392 }1393 return false;1394}13951396async function attemptUnlockWithDerive(1397 password: string,1398 derive: typeof deriveKey,1399): Promise<boolean> {1400 await ensureMigrated();14011402 1403 1404 1405 1406 1407 const [slotA, slotB] = await Promise.all([1408 loadSlot(SLOT_A),1409 loadSlot(SLOT_B),1410 ]);14111412 1413 const [keyA, keyB] = await Promise.all([1414 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1415 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1416 ]);14171418 1419 1420 1421 const [resultA, resultB] = await Promise.all([1422 keyA && slotA1423 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1424 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1425 keyB && slotB1426 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1427 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1428 ]);1429 const payloadA = resultA.status === "ok" ? resultA.payload : null;1430 const payloadB = resultB.status === "ok" ? resultB.payload : null;14311432 if (payloadA && keyA) {1433 derivedKey = keyA;1434 void saveSessionKey(keyA, "real");1435 vaultMode = "real";1436 entries = payloadA.entries;1437 duressConfigured = payloadA.duressConfigured;1438 buildDomainIndexSync(entries);1439 await loadSettings();1440 touchAutoLock();1441 if (keyB) zeroKey(keyB);14421443 1444 if (slotA) {1445 const needsRecoverySetup =1446 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1447 if (!needsRecoverySetup) {1448 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1449 }1450 1451 1452 1453 1454 if (needsRecoverySetup || !rootKey) {1455 try {1456 await migrateToRecovery();1457 if (needsRecoverySetup) {1458 1459 1460 1461 await chrome.storage.local.set({ peach_recovery_pending: '1' });1462 }1463 } catch (migrationErr) {1464 console.warn('[Peach] recovery migration failed:', migrationErr);1465 }1466 }1467 }14681469 1470 1471 1472 if (payloadA.migratedFromLegacy) {1473 await persistSlotA();1474 }1475 await lockedSyncUnlockHook?.().catch((error) => {1476 console.warn("[Peach sync] locked inbox drain failed:", error);1477 });1478 return true;1479 }14801481 if (payloadB && keyB) {1482 derivedKey = keyB;1483 void saveSessionKey(keyB, "decoy");1484 vaultMode = "decoy";1485 entries = payloadB.entries;1486 duressConfigured = false;1487 buildDomainIndexSync(entries);1488 await loadSettings();1489 touchAutoLock();1490 if (keyA) zeroKey(keyA);1491 return true;1492 }14931494 if (keyA) zeroKey(keyA);1495 if (keyB) zeroKey(keyB);1496 1497 1498 if (resultA.status === "corrupted") {1499 lastUnlockFailureReason = resultA.reason;1500 } else if (resultB.status === "corrupted") {1501 lastUnlockFailureReason = resultB.reason;1502 }1503 return false;1504}15051506150715081509151015111512const SESSION_KEY_STORAGE = "peach_session_dk";15131514async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1515 if (!chrome.storage.session) return;1516 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1517}15181519async function clearSessionKey(): Promise<void> {1520 if (!chrome.storage.session) return;1521 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1522}152315241525152615271528152915301531export async function restoreFromSession(): Promise<boolean> {1532 if (derivedKey !== null) return true;1533 if (!chrome.storage.session) return false;1534 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1535 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1536 if (!entry?.keyHex) return false;15371538 const key = unhex(entry.keyHex);1539 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1540 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1541 const slot = await loadSlot(slotKey);1542 if (!slot) { await clearSessionKey(); return false; }15431544 const result = await tryDecryptSlotResult(slot, key, aadTag);1545 if (result.status !== "ok") { await clearSessionKey(); return false; }15461547 derivedKey = key;1548 vaultMode = entry.mode;1549 entries = result.payload.entries;1550 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1551 buildDomainIndexSync(entries);1552 await loadSettings();1553 touchAutoLock();15541555 1556 1557 1558 1559 1560 1561 1562 1563 1564 if (entry.mode === "real") {1565 try {1566 rootKey = await deriveRootKeyFromDerivedKey(key, slot);1567 } catch {1568 1569 }1570 }15711572 await lockedSyncUnlockHook?.().catch((error) => {1573 console.warn("[Peach sync] locked inbox drain failed:", error);1574 });15751576 return true;1577}15781579export function lock(): void {1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 void clearSessionKey();1590 if (derivedKey) {1591 zeroKey(derivedKey);1592 derivedKey = null;1593 }1594 if (rootKey) {1595 rootKey.fill(0);1596 rootKey = null;1597 }15981599 entries = [];1600 domainIndex = new Map();1601 vaultMode = "real";1602 duressConfigured = false;1603 clearAutoLock();1604}16051606export function isUnlocked(): boolean {1607 return derivedKey !== null;1608}16091610161116121613export async function unlockWithKey(1614 masterKeyHex: string,1615): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1616 try {1617 const masterKeyBytes = unhex(masterKeyHex);1618 const stored = await loadSlot(SLOT_A);1619 if (!stored) return { ok: false, error: "invalid_key" };1620 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1621 if (!payload) return { ok: false, error: "invalid_key" };16221623 derivedKey = masterKeyBytes;1624 vaultMode = "real";1625 entries = payload.entries;1626 duressConfigured = payload.duressConfigured;1627 buildDomainIndexSync(entries);1628 await loadSettings();1629 touchAutoLock();16301631 1632 1633 1634 1635 1636 if (stored.recovery_salt && stored.recovery_wrapped_root_key) {1637 try {1638 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1639 } catch { }1640 }1641 return { ok: true, entries };1642 } catch {1643 return { ok: false, error: "invalid_key" };1644 }1645}164616471648164916501651165216531654export async function exportDuressSlotForAdoption(): Promise<string | null> {1655 if (!(await isDuressEnabled())) return null;1656 await ensureMigrated();1657 const slotB = await loadSlot(SLOT_B);1658 if (!slotB) return null;1659 return JSON.stringify(slotB);1660}16611662166316641665166616671668export async function installAdoptedDuressSlot(json: string): Promise<void> {1669 let parsed: StoredVault;1670 try {1671 const raw = JSON.parse(json) as unknown;1672 if (!hasValidVaultSchema(raw)) {1673 throw new Error("invalid_duress_slot_shape");1674 }1675 parsed = raw;1676 } catch (err) {1677 if (err instanceof SyntaxError) {1678 throw new Error("invalid_duress_slot_shape");1679 }1680 throw err;1681 }1682 await writeSlot(SLOT_B, parsed);1683}16841685168616871688168916901691169216931694169516961697169816991700170117021703export async function unlockFromCodex(params: {1704 slotA: string; 1705 slotB: string | null; 1706 timestamp: number;1707 masterPassphrase: string;1708}): Promise<1709 | { ok: true; entryCount: number }1710 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1711> {1712 let parsedSlotA: StoredVault;1713 let parsedSlotB: StoredVault | null = null;1714 try {1715 const rawA = JSON.parse(params.slotA) as unknown;1716 if (!hasValidVaultSchema(rawA)) {1717 return { ok: false, reason: "invalid_slot_format" };1718 }1719 parsedSlotA = rawA;1720 if (params.slotB !== null) {1721 const rawB = JSON.parse(params.slotB) as unknown;1722 if (!hasValidVaultSchema(rawB)) {1723 return { ok: false, reason: "invalid_slot_format" };1724 }1725 parsedSlotB = rawB;1726 }1727 } catch {1728 return { ok: false, reason: "invalid_slot_format" };1729 }17301731 1732 1733 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1734 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1735 if (!trialPayload) {1736 zeroKey(trialKey);1737 return { ok: false, reason: "wrong_passphrase" };1738 }1739 1740 1741 zeroKey(trialKey);17421743 1744 1745 await takeVaultSnapshot("restore");17461747 await writeSlot(SLOT_A, parsedSlotA);1748 if (parsedSlotB) {1749 await writeSlot(SLOT_B, parsedSlotB);1750 } else {1751 1752 1753 1754 await writeSlot(1755 SLOT_B,1756 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1757 );1758 }1759 await chrome.storage.local.set({1760 peach_last_modified_at: String(params.timestamp),1761 });17621763 1764 1765 const ok = await unlock(params.masterPassphrase);1766 if (!ok) {1767 1768 1769 1770 return { ok: false, reason: "wrong_passphrase" };1771 }1772 return { ok: true, entryCount: getEntries().length };1773}17741775177617771778export function getDerivedKeyHex(): string {1779 if (!derivedKey) throw new Error("vault_not_unlocked");1780 return hex(derivedKey);1781}17821783178417851786178717881789179017911792179317941795179617971798export function getRootKeyHex(): string | null {1799 return rootKey ? hex(rootKey) : null;1800}18011802export function getEntries(): VaultEntry[] {1803 if (!derivedKey) throw new Error("vault_locked");1804 return entries.filter((entry) => !entry.deletedAt);1805}18061807const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;18081809export function getIdentities(): SavedIdentity[] {1810 if (!derivedKey) throw new Error("vault_locked");1811 const active = entries.filter((e) => !e.deletedAt && e.type === "login");18121813 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1814 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();18151816 for (const entry of active) {1817 const domain = extractDomain(entry.url ?? "") ?? "";1818 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;18191820 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1821 const existing = map.get(v);1822 if (!existing || modifiedAt > existing.lastUsed) {1823 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1824 } else {1825 map.set(v, { ...existing, count: existing.count + 1 });1826 }1827 }18281829 const uVal = entry.username?.trim();1830 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);18311832 1833 1834 const eVal = entry.email?.trim();1835 if (eVal) tally(emailMap, eVal);1836 }18371838 const toIdentity = (type: "email" | "username") =>1839 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1840 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1841 });18421843 return [1844 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1845 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1846 ];1847}18481849export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1850 if (!derivedKey) throw new Error("vault_locked");18511852 const now = Date.now();1853 const added: VaultEntry[] = [];1854 for (const draft of drafts) {1855 try {1856 const sanitized = sanitizeEntryDraft(draft);1857 const nextEntry: VaultEntry = {1858 ...sanitized,1859 id: crypto.randomUUID(),1860 createdAt: now,1861 modifiedAt: monotonicEntryTimestamp(),1862 };1863 entries.push(nextEntry);1864 added.push(nextEntry);1865 } catch {1866 1867 }1868 }18691870 if (added.length > 0) {1871 await persistEntries();1872 }18731874 return added;1875}187618771878187918801881188218831884export async function restoreEntries(1885 incoming: VaultEntry[],1886 mode: "replace" | "merge"1887): Promise<{ added: number; updated: number; skipped: number }> {1888 if (!derivedKey) throw new Error("vault_locked");18891890 1891 1892 1893 if (mode === "replace" && incoming.length === 0) {1894 throw new Error("refuse to replace with empty entry set");1895 }18961897 let added = 0;1898 let updated = 0;1899 let skipped = 0;19001901 if (mode === "replace") {1902 entries = [];1903 for (const entry of incoming) {1904 const normalized = sanitizeRestoredEntry(entry);1905 if (!normalized) {1906 skipped += 1;1907 continue;1908 }1909 entries.push(normalized);1910 added += 1;1911 }1912 } else {1913 const byId = new Map<string, VaultEntry>();1914 for (const e of entries) byId.set(e.id, e);19151916 for (const entry of incoming) {1917 const normalized = sanitizeRestoredEntry(entry);1918 if (!normalized) {1919 skipped += 1;1920 continue;1921 }1922 const existing = byId.get(normalized.id);1923 if (!existing) {1924 entries.push(normalized);1925 added += 1;1926 } else if (normalized.modifiedAt > existing.modifiedAt) {1927 const idx = entries.findIndex((e) => e.id === existing.id);1928 if (idx !== -1) {1929 entries[idx] = normalized;1930 updated += 1;1931 }1932 } else {1933 skipped += 1;1934 }1935 }1936 }19371938 await persistEntries("import");1939 return { added, updated, skipped };1940}194119421943194419451946194719481949function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1950 if (!entry || typeof entry !== "object") return null;1951 const title = typeof entry.title === "string" ? entry.title.trim() : "";1952 if (!title) return null;1953 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1954 const now = Date.now();1955 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1956 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;19571958 1959 1960 1961 1962 const normalized = normalizeEntry({1963 ...entry,1964 id,1965 title,1966 type: entry.type ?? "login",1967 createdAt,1968 modifiedAt,1969 });19701971 if (normalized.passkey) {1972 normalized.passkey = sanitizePasskey(normalized.passkey);1973 }1974 if (normalized.canary) {1975 normalized.canary = sanitizeCanary(normalized.canary);1976 }1977 1978 normalized.tags = sanitizeTags(normalized.tags);19791980 1981 1982 if (1983 normalized.type === "login" &&1984 !normalized.passkey &&1985 typeof normalized.password !== "string"1986 ) {1987 return null;1988 }1989 if (entry.passkey && !normalized.passkey) {1990 1991 1992 return null;1993 }19941995 return normalized;1996}1997199819992000200120022003function sanitizePasskey(pk: unknown): PasskeyData | undefined {2004 if (!pk || typeof pk !== "object") return undefined;2005 const r = pk as Record<string, unknown>;2006 if (2007 typeof r.rpId !== "string" || !r.rpId ||2008 typeof r.credentialId !== "string" || !r.credentialId ||2009 typeof r.publicKeyCose !== "string" ||2010 typeof r.userHandle !== "string" ||2011 typeof r.userName !== "string"2012 ) {2013 return undefined;2014 }2015 return {2016 rpId: r.rpId,2017 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2018 credentialId: r.credentialId,2019 publicKeyCose: r.publicKeyCose,2020 privateKeyJwk:2021 r.privateKeyJwk && typeof r.privateKeyJwk === "object"2022 ? (r.privateKeyJwk as JsonWebKey)2023 : undefined,2024 userHandle: r.userHandle,2025 userName: r.userName,2026 userDisplayName:2027 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2028 algorithm: r.algorithm === -8 ? -8 : -7,2029 signCount: typeof r.signCount === "number" ? r.signCount : 0,2030 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2031 backupEligible: r.backupEligible === true,2032 backupState: r.backupState === true,2033 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2034 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2035 transports: Array.isArray(r.transports)2036 ? r.transports.filter((t): t is string => typeof t === "string")2037 : undefined,2038 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2039 };2040}20412042204320442045204620472048204920502051function sanitizeCanary(c: unknown): Canary | undefined {2052 if (!c || typeof c !== "object") return undefined;2053 const r = c as Record<string, unknown>;2054 if (typeof r.webhookUrl !== "string") return undefined;2055 let url: URL;2056 try {2057 url = new URL(r.webhookUrl);2058 } catch {2059 return undefined;2060 }2061 if (url.protocol !== "https:") return undefined;2062 return {2063 webhookUrl: url.toString(),2064 label: typeof r.label === "string" ? r.label : undefined,2065 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2066 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2067 };2068}20692070207120722073207420752076function sanitizeTags(tags: unknown): string[] | undefined {2077 if (!Array.isArray(tags)) return undefined;2078 const out: string[] = [];2079 for (const t of tags) {2080 if (typeof t !== "string") continue;2081 const trimmed = t.trim();2082 if (!trimmed) continue;2083 out.push(trimmed.slice(0, 256));2084 if (out.length >= 255) break;2085 }2086 return out.length > 0 ? out : undefined;2087}208820892090209120922093export async function clearVaultEntries(2094 confirmation: "REPLACE_ALL"2095): Promise<void> {2096 if (confirmation !== "REPLACE_ALL") {2097 throw new Error("clearVaultEntries requires explicit confirmation");2098 }2099 if (!derivedKey) throw new Error("vault_locked");2100 for (const entry of entries) {2101 if (!entry.deletedAt) {2102 const stamp = monotonicEntryTimestamp(entry.modifiedAt);2103 entry.deletedAt = stamp;2104 entry.modifiedAt = stamp;2105 }2106 }2107 await persistEntries();2108}21092110export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2111 if (!derivedKey) throw new Error("vault_locked");21122113 const sanitized = sanitizeEntryDraft(entry);2114 const now = Date.now();2115 const stamp = monotonicEntryTimestamp();2116 const nextEntry: VaultEntry = {2117 ...sanitized,2118 id: crypto.randomUUID(),2119 createdAt: now,2120 modifiedAt: stamp,2121 2122 2123 passwordChangedAt: sanitized.password ? stamp : undefined,2124 };21252126 2127 2128 2129 const rollback = entries;2130 entries = [...entries, nextEntry];2131 try {2132 await persistEntries();2133 } catch (err) {2134 entries = rollback;2135 throw err;2136 }21372138 2139 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2140 checkBreach(nextEntry.password).then(async (count) => {2141 if (count > 0) {2142 const idx = entries.findIndex((e) => e.id === nextEntry.id);2143 if (idx !== -1) {2144 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2145 await persistEntries();2146 }2147 }2148 });2149 }21502151 fireSyncTrigger("vault_change");21522153 return nextEntry;2154}21552156215721582159216021612162export async function consolidatePasskeyEntries(): Promise<void> {2163 if (!derivedKey) return;2164 const { merged } = mergePasskeyOnlyIntoLogins(entries);2165 if (merged) {2166 await persistEntries();2167 }2168}21692170export async function updateEntry(2171 id: string,2172 patch: Partial<VaultEntry>2173): Promise<void> {2174 if (!derivedKey) throw new Error("vault_locked");21752176 const idx = entries.findIndex((entry) => entry.id === id);2177 if (idx === -1 || entries[idx].deletedAt) {2178 throw new Error("entry_not_found");2179 }21802181 const current = entries[idx];2182 const sanitizedPatch = sanitizeEntryPatch(patch);2183 2184 2185 2186 const passwordChanged =2187 "password" in patch &&2188 typeof sanitizedPatch.password === "string" &&2189 sanitizedPatch.password !== current.password;2190 const stamp = monotonicEntryTimestamp(current.modifiedAt);2191 2192 2193 2194 2195 2196 2197 2198 const priorPassword = current.password;2199 const shouldCapture =2200 passwordChanged &&2201 typeof priorPassword === "string" &&2202 priorPassword.length > 0 &&2203 current.passwordHistory?.[0]?.password !== priorPassword;2204 const nextHistory = shouldCapture2205 ? [2206 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2207 ...(current.passwordHistory ?? []),2208 ].slice(0, PASSWORD_HISTORY_MAX)2209 : current.passwordHistory;2210 entries[idx] = {2211 ...current,2212 ...sanitizedPatch,2213 modifiedAt: stamp,2214 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2215 };2216221722182219 2220 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2221 entries[idx] = current;2222 throw new Error("card number is required for card entries");2223 }22242225 2226 2227 2228 2229 try {2230 await persistEntries();2231 } catch (err) {2232 entries[idx] = current;2233 throw err;2234 }22352236 2237 2238 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2239 entries[idx] = { ...entries[idx], breach: undefined };2240 await persistEntries();2241 const password = entries[idx].password;2242 if (password) {2243 checkBreach(password).then(async (count) => {2244 const i = entries.findIndex((e) => e.id === id);2245 if (i !== -1 && count > 0) {2246 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2247 await persistEntries();2248 }2249 });2250 }2251 }22522253 fireSyncTrigger("vault_change");2254}22552256export async function deleteEntry(id: string): Promise<void> {2257 if (!derivedKey) throw new Error("vault_locked");22582259 const entry = entries.find((item) => item.id === id);2260 if (!entry || entry.deletedAt) {2261 throw new Error("entry_not_found");2262 }22632264 2265 2266 2267 const previousModifiedAt = entry.modifiedAt;2268 const stamp = monotonicEntryTimestamp(entry.modifiedAt);2269 entry.deletedAt = stamp;2270 entry.modifiedAt = stamp;2271 2272 2273 try {2274 await persistEntries();2275 } catch (err) {2276 delete entry.deletedAt;2277 entry.modifiedAt = previousModifiedAt;2278 throw err;2279 }2280 fireSyncTrigger("vault_change");2281}22822283export function getDeletedEntries(): VaultEntry[] {2284 if (!derivedKey) return [];2285 return entries.filter((e) => !!e.deletedAt);2286}2287228822892290229122922293export async function getVaultVersion(): Promise<number> {2294 try {2295 const raw = await chrome.storage.local.get("peach_last_modified_at");2296 const v = (raw as Record<string, unknown>).peach_last_modified_at;2297 if (typeof v === "number") return v;2298 if (typeof v === "string" && v !== "") {2299 const n = Number.parseInt(v, 10);2300 return Number.isFinite(n) ? n : 0;2301 }2302 return 0;2303 } catch {2304 return 0;2305 }2306}230723082309231023112312231323142315231623172318export async function addEntryDirect(entry: VaultEntry): Promise<void> {2319 if (!derivedKey) throw new Error("vault_locked");2320 entries = [...entries, entry];2321 await persistEntries("sync_received");2322}23232324export async function updateEntryDirect(patch: VaultEntry): Promise<void> {2325 if (!derivedKey) throw new Error("vault_locked");2326 const idx = entries.findIndex((e) => e.id === patch.id);2327 if (idx === -1) throw new Error("entry_not_found");2328 entries[idx] = { ...entries[idx], ...patch };2329 await persistEntries("sync_received");2330}23312332export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {2333 if (!derivedKey) throw new Error("vault_locked");2334 const idx = entries.findIndex((e) => e.id === id);2335 if (idx === -1) throw new Error("entry_not_found");2336 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };2337 await persistEntries("sync_received");2338}2339234023412342234323442345234623472348234923502351export async function applyMergedRemoteEntries(2352 remoteEntries: VaultEntry[],2353 remoteTombstones: Record<string, number>,2354 _context: { deviceName?: string } = {},2355): Promise<{ received: number; applied: number; conflicts: number }> {2356 2357 2358 2359 2360 2361 2362 if (!derivedKey) throw new Error("vault_locked");23632364 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2365 let applied = 0;2366 let conflicts = 0;23672368 const byId = new Map<string, VaultEntry>();2369 for (const e of entries) byId.set(e.id, e);23702371 const mergeNow = Date.now();23722373 for (const remote of remoteEntries) {2374 const sanitized = sanitizeRestoredEntry(remote);2375 if (!sanitized) continue;2376 2377 2378 2379 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2380 if (typeof sanitized.deletedAt === "number") {2381 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2382 }2383 const tombstoneTs = remoteTombstones[sanitized.id];2384 if (tombstoneTs !== undefined && clampSyncTimestamp(tombstoneTs, mergeNow) >= sanitized.modifiedAt) {2385 continue;2386 }2387 const local = byId.get(sanitized.id);2388 if (!local) {2389 entries.push(sanitized);2390 byId.set(sanitized.id, sanitized);2391 applied += 1;2392 } else if (sanitized.modifiedAt > local.modifiedAt) {2393 const idx = entries.findIndex((e) => e.id === local.id);2394 if (idx !== -1) {2395 entries[idx] = sanitized;2396 byId.set(sanitized.id, sanitized);2397 applied += 1;2398 if (!local.deletedAt) conflicts += 1;2399 }2400 }2401 }24022403 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2404 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt)) continue;2405 2406 const deletedAt = clampSyncTimestamp(rawDeletedAt, mergeNow);2407 const local = byId.get(id);2408 if (!local) continue;2409 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2410 if (deletedAt < local.modifiedAt) continue;2411 const idx = entries.findIndex((e) => e.id === id);2412 if (idx !== -1) {2413 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2414 applied += 1;2415 }2416 }24172418 if (applied > 0) {2419 await persistEntries("sync_received");2420 buildDomainIndexSync(entries);2421 }2422 return { received, applied, conflicts };2423}24242425export async function restoreEntry(id: string): Promise<void> {2426 if (!derivedKey) throw new Error("vault_locked");2427 const entry = entries.find((e) => e.id === id && e.deletedAt);2428 if (!entry) throw new Error("deleted entry not found");2429 delete entry.deletedAt;2430 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);2431 await persistEntries();2432 buildDomainIndexSync(entries);2433}24342435export async function purgeEntry(id: string): Promise<void> {2436 if (!derivedKey) throw new Error("vault_locked");2437 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2438 if (idx === -1) throw new Error("deleted entry not found");2439 entries.splice(idx, 1);2440 await persistEntries();2441}244224432444export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2445 if (!derivedKey || retentionMs === 0) return 0;2446 const cutoff = Date.now() - retentionMs;2447 const before = entries.length;2448 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2449 const purged = before - entries.length;2450 if (purged > 0) await persistEntries();2451 return purged;2452}24532454export function queryDomain(domain: string): VaultEntry[] {2455 return domainIndex.get(domain) ?? [];2456}24572458export function getEntryById(id: string): VaultEntry | undefined {2459 return entries.find((entry) => entry.id === id && !entry.deletedAt);2460}24612462async function persistSlotA(2463 reason: VaultSnapshot["reason"] = "local_save",2464): Promise<StoredVault> {2465 if (!derivedKey) throw new Error("vault_locked");24662467 const slotA = await loadSlot(SLOT_A);2468 if (!slotA) throw new Error("missing slot A");24692470 2471 2472 await takeVaultSnapshot(reason);24732474 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2475 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");24762477 2478 const nextSlotA: StoredVault = {2479 ...slotA,2480 nonce,2481 ciphertext,2482 tag,2483 2484 };2485 await writeSlot(SLOT_A, nextSlotA);24862487 2488 2489 2490 2491 2492 2493 try {2494 2495 2496 2497 if (!duressConfigured) {2498 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2499 }2500 2501 2502 2503 2504 2505 if (reason !== "sync_received") {2506 await stampLocalVaultMutation();2507 }2508 } catch (err) {2509 2510 2511 await writeSlot(SLOT_A, slotA).catch(() => {});2512 throw err;2513 }25142515 return nextSlotA;2516}25172518async function persistSlotB(): Promise<void> {2519 if (!derivedKey) throw new Error("vault_locked");25202521 const slotB = await loadSlot(SLOT_B);2522 if (!slotB) throw new Error("missing slot B");25232524 2525 2526 2527 2528 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2529 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");25302531 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2532 2533 2534 2535 try {2536 await stampLocalVaultMutation();2537 } catch (err) {2538 await writeSlot(SLOT_B, slotB).catch(() => {});2539 throw err;2540 }2541}25422543async function persistEntries(2544 reason: VaultSnapshot["reason"] = "local_save",2545): Promise<void> {2546 if (!derivedKey) throw new Error("vault_locked");25472548 if (vaultMode === "decoy") {2549 await persistSlotB();2550 } else {2551 await persistSlotA(reason);2552 }2553 buildDomainIndexSync(entries);2554 await refreshLockedSyncState();2555}25562557function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2558 if (!Array.isArray(values)) return undefined;2559 const seen = new Set<string>();2560 const cleaned: string[] = [];2561 for (const value of values) {2562 const trimmed = typeof value === "string" ? value.trim() : "";2563 if (!trimmed) continue;2564 const key = trimmed.toLowerCase();2565 if (seen.has(key)) continue;2566 seen.add(key);2567 cleaned.push(trimmed);2568 }2569 return cleaned.length > 0 ? cleaned : undefined;2570}25712572function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2573 const title = entry.title.trim();2574 2575 2576 2577 let type: VaultEntry["type"] = entry.type ?? "login";2578 if (type === "passkey") type = "login";25792580 if (!title) throw new Error("title is required");25812582 if (entry.passkey) {2583 const p = entry.passkey;2584 if (2585 !p.rpId ||2586 !p.credentialId ||2587 !p.publicKeyCose ||2588 !p.userHandle ||2589 !p.userName ||2590 (p.algorithm !== -7 && p.algorithm !== -8)2591 ) {2592 throw new Error("invalid passkey credential");2593 }2594 }2595 if (type === "card") {2596 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2597 throw new Error("cardholder name and card number are required");2598 }2599 }26002601 return {2602 type,2603 title,2604 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2605 email: type === "login" ? entry.email?.trim() || undefined : undefined,2606 password: type === "login" ? entry.password : undefined,2607 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2608 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2609 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2610 2611 2612 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2613 totpDigits: type === "login" ? entry.totpDigits : undefined,2614 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2615 notes: entry.notes?.trim() || undefined,2616 breach: type === "login" ? entry.breach : undefined,2617 2618 2619 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2620 tags: sanitizeTags(entry.tags),2621 passkey: entry.passkey,2622 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2623 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2624 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2625 expYear: type === "card" ? entry.expYear?.trim() : undefined,2626 cvc: type === "card" ? entry.cvc?.trim() : undefined,2627 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2628 network: type === "card" ? entry.network : undefined,2629 last4: type === "card" ? entry.last4?.trim() : undefined,2630 };2631}26322633function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2634 const nextPatch: Partial<VaultEntry> = {};26352636 if ("type" in patch) {2637 nextPatch.type = patch.type;2638 }26392640 if ("title" in patch) {2641 const title = patch.title?.trim() ?? "";2642 if (!title) throw new Error("title is required");2643 nextPatch.title = title;2644 }26452646 if ("url" in patch) {2647 nextPatch.url = patch.url?.trim() ?? "";2648 }26492650 if ("additionalUrls" in patch) {2651 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2652 const seen = new Set<string>();2653 const cleaned: string[] = [];2654 for (const value of incoming) {2655 const trimmed = typeof value === "string" ? value.trim() : "";2656 if (!trimmed) continue;2657 const key = trimmed.toLowerCase();2658 if (seen.has(key)) continue;2659 seen.add(key);2660 cleaned.push(trimmed);2661 }2662 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2663 }26642665 if ("username" in patch) {2666 nextPatch.username = patch.username?.trim() ?? "";2667 }26682669 if ("email" in patch) {2670 nextPatch.email = patch.email?.trim() || undefined;2671 }26722673 if ("password" in patch) {2674 const password = patch.password ?? "";2675 2676 nextPatch.password = password || undefined;2677 }26782679 if ("totp" in patch) {2680 nextPatch.totp = patch.totp?.trim() || undefined;2681 }26822683 if ("notes" in patch) {2684 nextPatch.notes = patch.notes?.trim() || undefined;2685 }26862687 if ("breach" in patch) {2688 nextPatch.breach = patch.breach;2689 }26902691 if ("canary" in patch) {2692 2693 2694 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2695 }26962697 if ("passkey" in patch) {2698 2699 2700 2701 if (patch.passkey === undefined || patch.passkey === null) {2702 nextPatch.passkey = undefined;2703 } else {2704 const sanitized = sanitizePasskey(patch.passkey);2705 if (!sanitized) throw new Error("invalid passkey credential");2706 nextPatch.passkey = sanitized;2707 }2708 }27092710 if ("tags" in patch) {2711 2712 nextPatch.tags = sanitizeTags(patch.tags);2713 }27142715 if ("cardholderName" in patch) {2716 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2717 }2718 if ("cardNumber" in patch) {2719 const n = patch.cardNumber ?? "";2720 nextPatch.cardNumber = n || undefined;2721 }2722 if ("expMonth" in patch) {2723 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2724 }2725 if ("expYear" in patch) {2726 nextPatch.expYear = patch.expYear?.trim() || undefined;2727 }2728 if ("cvc" in patch) {2729 nextPatch.cvc = patch.cvc?.trim() || undefined;2730 }2731 if ("cardNickname" in patch) {2732 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2733 }2734 if ("network" in patch) {2735 nextPatch.network = patch.network;2736 }2737 if ("last4" in patch) {2738 nextPatch.last4 = patch.last4?.trim() || undefined;2739 }27402741 return nextPatch;2742}27432744function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2745 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2746 const modifiedAt =2747 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;27482749 const base: VaultEntry = {2750 id: entry.id,2751 type: "type" in entry ? entry.type : "login",2752 title: entry.title,2753 username: entry.username,2754 email: "email" in entry ? entry.email : undefined,2755 password: entry.password,2756 url: entry.url,2757 additionalUrls:2758 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2759 ? entry.additionalUrls2760 : undefined,2761 totp: entry.totp,2762 2763 totpAlgorithm: entry.totpAlgorithm,2764 totpDigits: entry.totpDigits,2765 totpPeriod: entry.totpPeriod,2766 notes: entry.notes,2767 breach: entry.breach,2768 2769 2770 2771 passwordChangedAt:2772 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2773 2774 2775 2776 passwordHistory:2777 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2778 ? entry.passwordHistory2779 : undefined,2780 canary: "canary" in entry ? entry.canary : undefined,2781 passkey: "passkey" in entry ? entry.passkey : undefined,2782 createdAt,2783 modifiedAt,2784 deletedAt: entry.deletedAt,2785 tags: "tags" in entry ? entry.tags : undefined,2786 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2787 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2788 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2789 expYear: "expYear" in entry ? entry.expYear : undefined,2790 cvc: "cvc" in entry ? entry.cvc : undefined,2791 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2792 network: "network" in entry ? entry.network : undefined,2793 last4: "last4" in entry ? entry.last4 : undefined,2794 };27952796 return migrateLegacyPasskey(base, entry);2797}279827992800280128022803280428052806function migrateLegacyPasskey(2807 base: VaultEntry,2808 raw: VaultEntry | LegacyVaultEntry2809): VaultEntry {2810 if (base.passkey) return base;2811 const flat = raw as Partial<VaultEntry>;2812 const isPasskeyType = base.type === "passkey";2813 const hasFlatPasskeyFields =2814 typeof flat.rpId === "string" &&2815 typeof flat.credentialId === "string" &&2816 typeof flat.publicKeyCose === "string" &&2817 typeof flat.userHandle === "string" &&2818 typeof flat.userName === "string";2819 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2820 if (!hasFlatPasskeyFields) return base;28212822 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2823 base.passkey = {2824 rpId: flat.rpId!,2825 rpName: flat.rpName ?? flat.rpId!,2826 credentialId: flat.credentialId!,2827 publicKeyCose: flat.publicKeyCose!,2828 privateKeyJwk: flat.privateKeyJwk,2829 userHandle: flat.userHandle!,2830 userName: flat.userName!,2831 userDisplayName: flat.userDisplayName ?? flat.userName!,2832 algorithm,2833 signCount: flat.signCount ?? 0,2834 aaguid: flat.aaguid ?? "",2835 backupEligible: flat.backupEligible === true,2836 backupState: flat.backupState === true,2837 lastUsedAt: flat.lastUsedAt,2838 browser: flat.browser,2839 transports: flat.transports,2840 createdAt: base.createdAt,2841 };2842 2843 2844 2845 if (isPasskeyType) base.type = "login";2846 if (!base.username) base.username = flat.userName;2847 if (!base.url) base.url = `https://${flat.rpId}`;2848 return base;2849}2850285128522853285428552856function entryDupeKey(e: VaultEntry): string {2857 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2858 2859 2860 2861 2862 if (e.passkey) {2863 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2864 }2865 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2866}286728682869export function countDuplicates(): number {2870 const best = new Map<string, VaultEntry>();2871 let dupeCount = 0;2872 for (const e of entries.filter((e) => !e.deletedAt)) {2873 const key = entryDupeKey(e);2874 const existing = best.get(key);2875 if (!existing) {2876 best.set(key, e);2877 } else {2878 dupeCount++;2879 2880 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2881 best.set(key, e);2882 }2883 }2884 }2885 return dupeCount;2886}288728882889289028912892export async function deduplicateEntries(): Promise<number> {2893 if (!derivedKey) throw new Error("vault_locked");2894 2895 2896 if (vaultMode === "decoy") {2897 throw new Error("deduplicate disabled in decoy mode");2898 }28992900 const best = new Map<string, VaultEntry>();2901 for (const e of entries.filter((en) => !en.deletedAt)) {2902 const key = entryDupeKey(e);2903 const existing = best.get(key);2904 if (!existing) {2905 best.set(key, e);2906 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2907 best.set(key, e);2908 }2909 }29102911 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2912 let removed = 0;2913 entries = entries.map((e) => {2914 if (!e.deletedAt && !keepIds.has(e.id)) {2915 removed++;2916 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2917 }2918 return e;2919 });29202921 if (removed > 0) await persistEntries();2922 return removed;2923}292429252926292729282929293029312932const HISTORY_KEY = "peach_vault_history";2933const HISTORY_MAX = 5;293429352936293729382939function bucketEntryCount(n: number): number {2940 if (n <= 0) return 0;2941 if (n <= 10) return 10;2942 if (n <= 25) return 25;2943 if (n <= 50) return 50;2944 if (n <= 100) return 100;2945 if (n <= 250) return 250;2946 if (n <= 500) return 500;2947 return Math.ceil(n / 500) * 500;2948}29492950295129522953295429552956export async function takeVaultSnapshot(2957 reason: VaultSnapshot["reason"],2958): Promise<void> {2959 try {2960 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2961 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2962 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];29632964 2965 2966 2967 if (!slotA) return;29682969 const vault_a = JSON.stringify(slotA);2970 const vault_b = JSON.stringify(slotB ?? slotA);29712972 2973 2974 2975 2976 2977 2978 let entryCount: number | null = null;2979 if (derivedKey && vaultMode === "real") {2980 try {2981 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);2982 } catch {2983 entryCount = null;2984 }2985 }29862987 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2988 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2989 ? existingRaw2990 : { snapshots: [] };29912992 2993 2994 2995 const latest = history.snapshots[0];2996 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2997 return;2998 }29993000 const snapshot: VaultSnapshot = {3001 id: crypto.randomUUID(),3002 savedAt: Date.now(),3003 reason,3004 vault_a,3005 vault_b,3006 entryCount,3007 };30083009 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3010 await chrome.storage.local.set({ [HISTORY_KEY]: history });3011 } catch (err) {3012 3013 3014 3015 3016 console.warn('[Peach] takeVaultSnapshot failed:', err);3017 if (String(err).includes('QUOTA') || String(err).includes('quota')) {3018 try {3019 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3020 } catch {3021 3022 }3023 }3024 }3025}302630273028export async function clearVaultHistory(): Promise<void> {3029 try {3030 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3031 } catch {3032 3033 }3034}303530363037export async function getVaultHistory(): Promise<VaultSnapshot[]> {3038 const raw = await chrome.storage.local.get(HISTORY_KEY);3039 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3040 if (!history || !Array.isArray(history.snapshots)) return [];3041 return history.snapshots;3042}30433044304530463047304830493050export async function restoreVaultSnapshot(3051 snapshotId: string,3052): Promise<{ ok: true } | { ok: false; error: string }> {3053 const history = await getVaultHistory();3054 const snapshot = history.find((s) => s.id === snapshotId);3055 if (!snapshot) return { ok: false, error: "snapshot_not_found" };30563057 3058 3059 3060 await takeVaultSnapshot("restore");30613062 let restoredA: StoredVault;3063 let restoredB: StoredVault;3064 try {3065 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3066 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3067 } catch {3068 return { ok: false, error: "snapshot_corrupt" };3069 }30703071 await writeSlot(SLOT_A, restoredA);3072 await writeSlot(SLOT_B, restoredB);3073 await stampLocalVaultMutation();30743075 3076 3077 3078 lock();30793080 return { ok: true };3081}308230833084308530863087308830893090309130923093309430953096export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3097 if (!derivedKey || vaultMode !== "real") {3098 return { ok: false, error: "vault_locked" };3099 }3100 const slotA = await loadSlot(SLOT_A);3101 if (!slotA) return { ok: false, error: "missing_slot_a" };31023103 3104 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3105 await generateAndWrapRootKey();31063107 3108 const newRootKey = unhex(rawRootKey);3109 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);31103111 3112 3113 3114 3115 await takeVaultSnapshot("local_save");3116 const nextSlotA: StoredVault = {3117 ...slotA,3118 recovery_salt: recoverySalt,3119 recovery_wrapped_root_key: wrappedRootKey,3120 recovery_nonce: wrappedNonce,3121 recovery_tag: wrappedTag,3122 ...pwRoot,3123 };3124 await writeSlot(SLOT_A, nextSlotA);31253126 3127 if (rootKey) zeroKey(rootKey);3128 rootKey = newRootKey;31293130 3131 await clearPairedDevices();31323133 3134 await setRecoveryConfirmPending(); 31353136 await stampLocalVaultMutation();3137 return { ok: true };3138}31393140314131423143314431453146export async function changePassword(3147 currentPassword: string,3148 newPassword: string3149): Promise<boolean> {3150 if (!derivedKey || vaultMode !== "real") return false;31513152 3153 const valid = await verifyMainPassword(currentPassword);3154 if (!valid) return false;31553156 const slotA = await loadSlot(SLOT_A);3157 if (!slotA) return false;3158 if (!rootKey) return false;31593160 3161 const newSalt = generateSalt();3162 const newKey = await deriveKey(newPassword, newSalt);31633164 3165 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3166 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");31673168 3169 3170 3171 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);31723173 const finalSlotA: StoredVault = {3174 ...slotA,3175 salt: hex(newSalt),3176 nonce,3177 ciphertext,3178 tag,3179 ...pwRoot,3180 };31813182 3183 3184 3185 await takeVaultSnapshot("local_save");31863187 3188 3189 3190 3191 await writeSlot(SLOT_A, finalSlotA);31923193 3194 3195 3196 zeroKey(derivedKey);3197 derivedKey = newKey;31983199 3200 3201 3202 3203 void saveSessionKey(newKey, "real");32043205 3206 3207 3208 if (!duressConfigured) {3209 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3210 }3211 await clearPinUnlockStorage();3212 await stampLocalVaultMutation();32133214 return true;3215}