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}2307230823092310231123122313231423152316231723182319export async function applyMergedRemoteEntries(2320 remoteEntries: VaultEntry[],2321 remoteTombstones: Record<string, number>,2322 _context: { deviceName?: string } = {},2323): Promise<{ received: number; applied: number; conflicts: number }> {2324 2325 2326 2327 2328 2329 2330 if (!derivedKey) throw new Error("vault_locked");23312332 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2333 let applied = 0;2334 let conflicts = 0;23352336 const byId = new Map<string, VaultEntry>();2337 for (const e of entries) byId.set(e.id, e);23382339 const mergeNow = Date.now();23402341 for (const remote of remoteEntries) {2342 const sanitized = sanitizeRestoredEntry(remote);2343 if (!sanitized) continue;2344 2345 2346 2347 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2348 if (typeof sanitized.deletedAt === "number") {2349 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2350 }2351 const tombstoneTs = remoteTombstones[sanitized.id];2352 if (tombstoneTs !== undefined && clampSyncTimestamp(tombstoneTs, mergeNow) >= sanitized.modifiedAt) {2353 continue;2354 }2355 const local = byId.get(sanitized.id);2356 if (!local) {2357 entries.push(sanitized);2358 byId.set(sanitized.id, sanitized);2359 applied += 1;2360 } else if (sanitized.modifiedAt > local.modifiedAt) {2361 const idx = entries.findIndex((e) => e.id === local.id);2362 if (idx !== -1) {2363 entries[idx] = sanitized;2364 byId.set(sanitized.id, sanitized);2365 applied += 1;2366 if (!local.deletedAt) conflicts += 1;2367 }2368 }2369 }23702371 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2372 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt)) continue;2373 2374 const deletedAt = clampSyncTimestamp(rawDeletedAt, mergeNow);2375 const local = byId.get(id);2376 if (!local) continue;2377 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2378 if (deletedAt < local.modifiedAt) continue;2379 const idx = entries.findIndex((e) => e.id === id);2380 if (idx !== -1) {2381 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2382 applied += 1;2383 }2384 }23852386 if (applied > 0) {2387 await persistEntries("sync_received");2388 buildDomainIndexSync(entries);2389 }2390 return { received, applied, conflicts };2391}23922393export async function restoreEntry(id: string): Promise<void> {2394 if (!derivedKey) throw new Error("vault_locked");2395 const entry = entries.find((e) => e.id === id && e.deletedAt);2396 if (!entry) throw new Error("deleted entry not found");2397 delete entry.deletedAt;2398 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);2399 await persistEntries();2400 buildDomainIndexSync(entries);2401}24022403export async function purgeEntry(id: string): Promise<void> {2404 if (!derivedKey) throw new Error("vault_locked");2405 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2406 if (idx === -1) throw new Error("deleted entry not found");2407 entries.splice(idx, 1);2408 await persistEntries();2409}241024112412export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2413 if (!derivedKey || retentionMs === 0) return 0;2414 const cutoff = Date.now() - retentionMs;2415 const before = entries.length;2416 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2417 const purged = before - entries.length;2418 if (purged > 0) await persistEntries();2419 return purged;2420}24212422export function queryDomain(domain: string): VaultEntry[] {2423 return domainIndex.get(domain) ?? [];2424}24252426export function getEntryById(id: string): VaultEntry | undefined {2427 return entries.find((entry) => entry.id === id && !entry.deletedAt);2428}24292430async function persistSlotA(2431 reason: VaultSnapshot["reason"] = "local_save",2432): Promise<StoredVault> {2433 if (!derivedKey) throw new Error("vault_locked");24342435 const slotA = await loadSlot(SLOT_A);2436 if (!slotA) throw new Error("missing slot A");24372438 2439 2440 await takeVaultSnapshot(reason);24412442 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2443 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");24442445 2446 const nextSlotA: StoredVault = {2447 ...slotA,2448 nonce,2449 ciphertext,2450 tag,2451 2452 };2453 await writeSlot(SLOT_A, nextSlotA);24542455 2456 2457 2458 2459 2460 2461 try {2462 2463 2464 2465 if (!duressConfigured) {2466 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2467 }2468 2469 2470 2471 2472 2473 if (reason !== "sync_received") {2474 await stampLocalVaultMutation();2475 }2476 } catch (err) {2477 2478 2479 await writeSlot(SLOT_A, slotA).catch(() => {});2480 throw err;2481 }24822483 return nextSlotA;2484}24852486async function persistSlotB(): Promise<void> {2487 if (!derivedKey) throw new Error("vault_locked");24882489 const slotB = await loadSlot(SLOT_B);2490 if (!slotB) throw new Error("missing slot B");24912492 2493 2494 2495 2496 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2497 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");24982499 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2500 2501 2502 2503 try {2504 await stampLocalVaultMutation();2505 } catch (err) {2506 await writeSlot(SLOT_B, slotB).catch(() => {});2507 throw err;2508 }2509}25102511async function persistEntries(2512 reason: VaultSnapshot["reason"] = "local_save",2513): Promise<void> {2514 if (!derivedKey) throw new Error("vault_locked");25152516 if (vaultMode === "decoy") {2517 await persistSlotB();2518 } else {2519 await persistSlotA(reason);2520 }2521 buildDomainIndexSync(entries);2522 await refreshLockedSyncState();2523}25242525function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2526 if (!Array.isArray(values)) return undefined;2527 const seen = new Set<string>();2528 const cleaned: string[] = [];2529 for (const value of values) {2530 const trimmed = typeof value === "string" ? value.trim() : "";2531 if (!trimmed) continue;2532 const key = trimmed.toLowerCase();2533 if (seen.has(key)) continue;2534 seen.add(key);2535 cleaned.push(trimmed);2536 }2537 return cleaned.length > 0 ? cleaned : undefined;2538}25392540function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2541 const title = entry.title.trim();2542 2543 2544 2545 let type: VaultEntry["type"] = entry.type ?? "login";2546 if (type === "passkey") type = "login";25472548 if (!title) throw new Error("title is required");25492550 if (entry.passkey) {2551 const p = entry.passkey;2552 if (2553 !p.rpId ||2554 !p.credentialId ||2555 !p.publicKeyCose ||2556 !p.userHandle ||2557 !p.userName ||2558 (p.algorithm !== -7 && p.algorithm !== -8)2559 ) {2560 throw new Error("invalid passkey credential");2561 }2562 }2563 if (type === "card") {2564 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2565 throw new Error("cardholder name and card number are required");2566 }2567 }25682569 return {2570 type,2571 title,2572 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2573 email: type === "login" ? entry.email?.trim() || undefined : undefined,2574 password: type === "login" ? entry.password : undefined,2575 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2576 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2577 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2578 2579 2580 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2581 totpDigits: type === "login" ? entry.totpDigits : undefined,2582 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2583 notes: entry.notes?.trim() || undefined,2584 breach: type === "login" ? entry.breach : undefined,2585 2586 2587 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2588 tags: sanitizeTags(entry.tags),2589 passkey: entry.passkey,2590 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2591 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2592 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2593 expYear: type === "card" ? entry.expYear?.trim() : undefined,2594 cvc: type === "card" ? entry.cvc?.trim() : undefined,2595 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2596 network: type === "card" ? entry.network : undefined,2597 last4: type === "card" ? entry.last4?.trim() : undefined,2598 };2599}26002601function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2602 const nextPatch: Partial<VaultEntry> = {};26032604 if ("type" in patch) {2605 nextPatch.type = patch.type;2606 }26072608 if ("title" in patch) {2609 const title = patch.title?.trim() ?? "";2610 if (!title) throw new Error("title is required");2611 nextPatch.title = title;2612 }26132614 if ("url" in patch) {2615 nextPatch.url = patch.url?.trim() ?? "";2616 }26172618 if ("additionalUrls" in patch) {2619 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2620 const seen = new Set<string>();2621 const cleaned: string[] = [];2622 for (const value of incoming) {2623 const trimmed = typeof value === "string" ? value.trim() : "";2624 if (!trimmed) continue;2625 const key = trimmed.toLowerCase();2626 if (seen.has(key)) continue;2627 seen.add(key);2628 cleaned.push(trimmed);2629 }2630 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2631 }26322633 if ("username" in patch) {2634 nextPatch.username = patch.username?.trim() ?? "";2635 }26362637 if ("email" in patch) {2638 nextPatch.email = patch.email?.trim() || undefined;2639 }26402641 if ("password" in patch) {2642 const password = patch.password ?? "";2643 2644 nextPatch.password = password || undefined;2645 }26462647 if ("totp" in patch) {2648 nextPatch.totp = patch.totp?.trim() || undefined;2649 }26502651 if ("notes" in patch) {2652 nextPatch.notes = patch.notes?.trim() || undefined;2653 }26542655 if ("breach" in patch) {2656 nextPatch.breach = patch.breach;2657 }26582659 if ("canary" in patch) {2660 2661 2662 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2663 }26642665 if ("passkey" in patch) {2666 2667 2668 2669 if (patch.passkey === undefined || patch.passkey === null) {2670 nextPatch.passkey = undefined;2671 } else {2672 const sanitized = sanitizePasskey(patch.passkey);2673 if (!sanitized) throw new Error("invalid passkey credential");2674 nextPatch.passkey = sanitized;2675 }2676 }26772678 if ("tags" in patch) {2679 2680 nextPatch.tags = sanitizeTags(patch.tags);2681 }26822683 if ("cardholderName" in patch) {2684 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2685 }2686 if ("cardNumber" in patch) {2687 const n = patch.cardNumber ?? "";2688 nextPatch.cardNumber = n || undefined;2689 }2690 if ("expMonth" in patch) {2691 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2692 }2693 if ("expYear" in patch) {2694 nextPatch.expYear = patch.expYear?.trim() || undefined;2695 }2696 if ("cvc" in patch) {2697 nextPatch.cvc = patch.cvc?.trim() || undefined;2698 }2699 if ("cardNickname" in patch) {2700 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2701 }2702 if ("network" in patch) {2703 nextPatch.network = patch.network;2704 }2705 if ("last4" in patch) {2706 nextPatch.last4 = patch.last4?.trim() || undefined;2707 }27082709 return nextPatch;2710}27112712function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2713 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2714 const modifiedAt =2715 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;27162717 const base: VaultEntry = {2718 id: entry.id,2719 type: "type" in entry ? entry.type : "login",2720 title: entry.title,2721 username: entry.username,2722 email: "email" in entry ? entry.email : undefined,2723 password: entry.password,2724 url: entry.url,2725 additionalUrls:2726 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2727 ? entry.additionalUrls2728 : undefined,2729 totp: entry.totp,2730 2731 totpAlgorithm: entry.totpAlgorithm,2732 totpDigits: entry.totpDigits,2733 totpPeriod: entry.totpPeriod,2734 notes: entry.notes,2735 breach: entry.breach,2736 2737 2738 2739 passwordChangedAt:2740 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2741 2742 2743 2744 passwordHistory:2745 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2746 ? entry.passwordHistory2747 : undefined,2748 canary: "canary" in entry ? entry.canary : undefined,2749 passkey: "passkey" in entry ? entry.passkey : undefined,2750 createdAt,2751 modifiedAt,2752 deletedAt: entry.deletedAt,2753 tags: "tags" in entry ? entry.tags : undefined,2754 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2755 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2756 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2757 expYear: "expYear" in entry ? entry.expYear : undefined,2758 cvc: "cvc" in entry ? entry.cvc : undefined,2759 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2760 network: "network" in entry ? entry.network : undefined,2761 last4: "last4" in entry ? entry.last4 : undefined,2762 };27632764 return migrateLegacyPasskey(base, entry);2765}276627672768276927702771277227732774function migrateLegacyPasskey(2775 base: VaultEntry,2776 raw: VaultEntry | LegacyVaultEntry2777): VaultEntry {2778 if (base.passkey) return base;2779 const flat = raw as Partial<VaultEntry>;2780 const isPasskeyType = base.type === "passkey";2781 const hasFlatPasskeyFields =2782 typeof flat.rpId === "string" &&2783 typeof flat.credentialId === "string" &&2784 typeof flat.publicKeyCose === "string" &&2785 typeof flat.userHandle === "string" &&2786 typeof flat.userName === "string";2787 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2788 if (!hasFlatPasskeyFields) return base;27892790 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2791 base.passkey = {2792 rpId: flat.rpId!,2793 rpName: flat.rpName ?? flat.rpId!,2794 credentialId: flat.credentialId!,2795 publicKeyCose: flat.publicKeyCose!,2796 privateKeyJwk: flat.privateKeyJwk,2797 userHandle: flat.userHandle!,2798 userName: flat.userName!,2799 userDisplayName: flat.userDisplayName ?? flat.userName!,2800 algorithm,2801 signCount: flat.signCount ?? 0,2802 aaguid: flat.aaguid ?? "",2803 backupEligible: flat.backupEligible === true,2804 backupState: flat.backupState === true,2805 lastUsedAt: flat.lastUsedAt,2806 browser: flat.browser,2807 transports: flat.transports,2808 createdAt: base.createdAt,2809 };2810 2811 2812 2813 if (isPasskeyType) base.type = "login";2814 if (!base.username) base.username = flat.userName;2815 if (!base.url) base.url = `https://${flat.rpId}`;2816 return base;2817}2818281928202821282228232824function entryDupeKey(e: VaultEntry): string {2825 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2826 2827 2828 2829 2830 if (e.passkey) {2831 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2832 }2833 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2834}283528362837export function countDuplicates(): number {2838 const best = new Map<string, VaultEntry>();2839 let dupeCount = 0;2840 for (const e of entries.filter((e) => !e.deletedAt)) {2841 const key = entryDupeKey(e);2842 const existing = best.get(key);2843 if (!existing) {2844 best.set(key, e);2845 } else {2846 dupeCount++;2847 2848 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2849 best.set(key, e);2850 }2851 }2852 }2853 return dupeCount;2854}285528562857285828592860export async function deduplicateEntries(): Promise<number> {2861 if (!derivedKey) throw new Error("vault_locked");2862 2863 2864 if (vaultMode === "decoy") {2865 throw new Error("deduplicate disabled in decoy mode");2866 }28672868 const best = new Map<string, VaultEntry>();2869 for (const e of entries.filter((en) => !en.deletedAt)) {2870 const key = entryDupeKey(e);2871 const existing = best.get(key);2872 if (!existing) {2873 best.set(key, e);2874 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2875 best.set(key, e);2876 }2877 }28782879 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2880 let removed = 0;2881 entries = entries.map((e) => {2882 if (!e.deletedAt && !keepIds.has(e.id)) {2883 removed++;2884 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2885 }2886 return e;2887 });28882889 if (removed > 0) await persistEntries();2890 return removed;2891}289228932894289528962897289828992900const HISTORY_KEY = "peach_vault_history";2901const HISTORY_MAX = 5;290229032904290529062907function bucketEntryCount(n: number): number {2908 if (n <= 0) return 0;2909 if (n <= 10) return 10;2910 if (n <= 25) return 25;2911 if (n <= 50) return 50;2912 if (n <= 100) return 100;2913 if (n <= 250) return 250;2914 if (n <= 500) return 500;2915 return Math.ceil(n / 500) * 500;2916}29172918291929202921292229232924export async function takeVaultSnapshot(2925 reason: VaultSnapshot["reason"],2926): Promise<void> {2927 try {2928 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2929 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2930 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];29312932 2933 2934 2935 if (!slotA) return;29362937 const vault_a = JSON.stringify(slotA);2938 const vault_b = JSON.stringify(slotB ?? slotA);29392940 2941 2942 2943 2944 2945 2946 let entryCount: number | null = null;2947 if (derivedKey && vaultMode === "real") {2948 try {2949 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);2950 } catch {2951 entryCount = null;2952 }2953 }29542955 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2956 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2957 ? existingRaw2958 : { snapshots: [] };29592960 2961 2962 2963 const latest = history.snapshots[0];2964 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2965 return;2966 }29672968 const snapshot: VaultSnapshot = {2969 id: crypto.randomUUID(),2970 savedAt: Date.now(),2971 reason,2972 vault_a,2973 vault_b,2974 entryCount,2975 };29762977 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2978 await chrome.storage.local.set({ [HISTORY_KEY]: history });2979 } catch (err) {2980 2981 2982 2983 2984 console.warn('[Peach] takeVaultSnapshot failed:', err);2985 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2986 try {2987 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2988 } catch {2989 2990 }2991 }2992 }2993}299429952996export async function clearVaultHistory(): Promise<void> {2997 try {2998 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2999 } catch {3000 3001 }3002}300330043005export async function getVaultHistory(): Promise<VaultSnapshot[]> {3006 const raw = await chrome.storage.local.get(HISTORY_KEY);3007 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3008 if (!history || !Array.isArray(history.snapshots)) return [];3009 return history.snapshots;3010}30113012301330143015301630173018export async function restoreVaultSnapshot(3019 snapshotId: string,3020): Promise<{ ok: true } | { ok: false; error: string }> {3021 const history = await getVaultHistory();3022 const snapshot = history.find((s) => s.id === snapshotId);3023 if (!snapshot) return { ok: false, error: "snapshot_not_found" };30243025 3026 3027 3028 await takeVaultSnapshot("restore");30293030 let restoredA: StoredVault;3031 let restoredB: StoredVault;3032 try {3033 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3034 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3035 } catch {3036 return { ok: false, error: "snapshot_corrupt" };3037 }30383039 await writeSlot(SLOT_A, restoredA);3040 await writeSlot(SLOT_B, restoredB);3041 await stampLocalVaultMutation();30423043 3044 3045 3046 lock();30473048 return { ok: true };3049}305030513052305330543055305630573058305930603061306230633064export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3065 if (!derivedKey || vaultMode !== "real") {3066 return { ok: false, error: "vault_locked" };3067 }3068 const slotA = await loadSlot(SLOT_A);3069 if (!slotA) return { ok: false, error: "missing_slot_a" };30703071 3072 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3073 await generateAndWrapRootKey();30743075 3076 const newRootKey = unhex(rawRootKey);3077 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);30783079 3080 3081 3082 3083 await takeVaultSnapshot("local_save");3084 const nextSlotA: StoredVault = {3085 ...slotA,3086 recovery_salt: recoverySalt,3087 recovery_wrapped_root_key: wrappedRootKey,3088 recovery_nonce: wrappedNonce,3089 recovery_tag: wrappedTag,3090 ...pwRoot,3091 };3092 await writeSlot(SLOT_A, nextSlotA);30933094 3095 if (rootKey) zeroKey(rootKey);3096 rootKey = newRootKey;30973098 3099 await clearPairedDevices();31003101 3102 await setRecoveryConfirmPending(); 31033104 await stampLocalVaultMutation();3105 return { ok: true };3106}31073108310931103111311231133114export async function changePassword(3115 currentPassword: string,3116 newPassword: string3117): Promise<boolean> {3118 if (!derivedKey || vaultMode !== "real") return false;31193120 3121 const valid = await verifyMainPassword(currentPassword);3122 if (!valid) return false;31233124 const slotA = await loadSlot(SLOT_A);3125 if (!slotA) return false;3126 if (!rootKey) return false;31273128 3129 const newSalt = generateSalt();3130 const newKey = await deriveKey(newPassword, newSalt);31313132 3133 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3134 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");31353136 3137 3138 3139 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);31403141 const finalSlotA: StoredVault = {3142 ...slotA,3143 salt: hex(newSalt),3144 nonce,3145 ciphertext,3146 tag,3147 ...pwRoot,3148 };31493150 3151 3152 3153 await takeVaultSnapshot("local_save");31543155 3156 3157 3158 3159 await writeSlot(SLOT_A, finalSlotA);31603161 3162 3163 3164 zeroKey(derivedKey);3165 derivedKey = newKey;31663167 3168 3169 3170 3171 void saveSessionKey(newKey, "real");31723173 3174 3175 3176 if (!duressConfigured) {3177 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3178 }3179 await clearPinUnlockStorage();3180 await stampLocalVaultMutation();31813182 return true;3183}