123456789101112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import { clampSyncTimestamp } from "../shared/sync-limits";15import { computeVaultId } from "../shared/sync-identity";161718import { triggerSyncIfReachable } from "./sync-trigger";19import { clearPairedDevices } from "./paired-devices";20import {21 rootKeyToWords,22 wordsToRootKeyMaterial,23 deriveRecoveryKey,24} from "../shared/recovery-words";25import type {26 AccentColorName,27 Canary,28 ManualSummonRecord,29 PasskeyData,30 SavedIdentity,31 StoredVault,32 VaultEntry,33 VaultHistory,34 VaultSnapshot,35} from "../shared/types";36import { decrypt, encrypt, zeroKey } from "./aes";37import { clearAutoLock, touchAutoLock } from "./alarms";38import {39 clearRecoveryConfirmPending,40 setRecoveryConfirmPending,41} from "./recovery-pending";4243type EntryDraft = Omit<44 VaultEntry,45 "id" | "createdAt" | "modifiedAt" | "deletedAt"46>;474849505152const SLOT_A = "peach_vault_a";53const SLOT_B = "peach_vault_b";54555657const PAD_BLOCK = 1024;5859606162const PASSWORD_HISTORY_MAX = 50;6364656667686970717273const DURESS_SLOT_SIZE = 262_144;7475let derivedKey: Uint8Array | null = null;7677787980let rootKey: Uint8Array | null = null;81let entries: VaultEntry[] = [];82838485let tombstones = new Map<string, number>();86let domainIndex: Map<string, VaultEntry[]> = new Map();87let vaultMode: "real" | "decoy" = "real";88899091let duressConfigured = false;9293949596979899100101102let lastUnlockFailureReason: string | null = null;103104export function consumeLastUnlockFailureReason(): string | null {105 const r = lastUnlockFailureReason;106 lastUnlockFailureReason = null;107 return r;108}109110111112113114export type TextSize = "small" | "medium" | "large";115116export interface VaultSettings {117 accentColor?: AccentColorName;118 theme?: "dark" | "light";119 autoLockMinutes?: number;120 clipboardClearSeconds?: number;121 lockOnClose?: boolean;122 breachMonitoring?: boolean;123 passkeyProviderEnabled?: boolean;124 trashRetentionDays?: number;125 manualSummonDomains?: Record<string, ManualSummonRecord>;126 manualSummonSalt?: string;127 declinedCardFingerprints?: string[];128 cardFingerprintSalt?: string;129130 131 metadataVersion?: 1;132 133134135136137 textSize?: TextSize;138 139140141142143 reduceMotion?: boolean;144 145 pinEnabled?: boolean;146 147 pinPreferred?: boolean;148 149150151152153154155 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;156}157158interface StoredPinWrap {159 version: 1;160 salt: string;161 nonce: string;162 ciphertext: string;163 tag: string;164}165166const PIN_WRAP_STORAGE = "peach_pin_wrap";167const PIN_ATTEMPTS_STORAGE = "peach_pin_attempts";168const PIN_MIN_LENGTH = 4;169const PIN_MAX_LENGTH = 12;170const PIN_MAX_ATTEMPTS = 3;171172let settings: VaultSettings = {};173const CARD_DECLINE_MAX = 256;174const METADATA_SALT_BYTES = 16;175176function randomMetadataSalt(): string {177 return Array.from(crypto.getRandomValues(new Uint8Array(METADATA_SALT_BYTES)))178 .map((byte) => byte.toString(16).padStart(2, "0"))179 .join("");180}181182async function metadataDigest(value: string, salt: string): Promise<string> {183 const bytes = new TextEncoder().encode(`${salt}:${value}`);184 const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));185 return Array.from(digest.slice(0, 16)).map((byte) => byte.toString(16).padStart(2, "0")).join("");186}187188let inMemoryPinWrap: StoredPinWrap | null = null;189let inMemoryPinFailedAttempts = 0;190let sessionPinStorageFailed = false;191192export function getSettings(): VaultSettings {193 return settings;194}195196export async function ensureSettingsLoaded(): Promise<VaultSettings> {197 await loadSettings();198 return settings;199}200201export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {202 await loadSettings();203 204 205 206 207 const safe = Object.fromEntries(208 Object.entries(patch).filter(209 ([k]) => !["__proto__", "constructor", "prototype"].includes(k),210 ),211 ) as Partial<VaultSettings>;212 settings = { ...settings, ...safe };213 await persistSettings();214}215216function isValidPin(pin: string): boolean {217 return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);218}219220221export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {222 if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;223 if (!(await verifyMainPassword(currentPassword))) return false;224225 const salt = generateSalt();226 const pinKey = await deriveKey(pin, salt);227 try {228 const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");229 await savePinWrap({ version: 1, salt: hex(salt), ...wrapped });230 await updateSettings({ pinEnabled: true });231 return true;232 } finally {233 zeroKey(pinKey);234 }235}236237async function clearPinUnlockStorage(): Promise<void> {238 inMemoryPinWrap = null;239 inMemoryPinFailedAttempts = 0;240 sessionPinStorageFailed = false;241 if (chrome.storage.session) {242 await chrome.storage.session.remove([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);243 }244 await updateSettings({ pinEnabled: false });245}246247248export async function disablePinUnlock(currentPassword: string): Promise<boolean> {249 if (!derivedKey || vaultMode !== "real") return false;250 if (!(await verifyMainPassword(currentPassword))) return false;251 await clearPinUnlockStorage();252 return true;253}254255256export async function unlockWithPin(pin: string): Promise<boolean> {257 if (!isValidPin(pin)) return false;258 await loadSettings();259 if (!settings.pinEnabled) return false;260261 const { stored, failedAttempts } = await loadPinState();262 if (failedAttempts >= PIN_MAX_ATTEMPTS) return false;263 if (264 !stored ||265 stored.version !== 1 ||266 !stored.salt ||267 !stored.nonce ||268 !stored.ciphertext ||269 !stored.tag270 ) return false;271272 const pinKey = await deriveKey(pin, unhex(stored.salt));273 let unwrappedKey: Uint8Array | null = null;274 try {275 const masterKeyHex = await decrypt(276 pinKey,277 stored.nonce,278 stored.ciphertext,279 stored.tag,280 "pin_wrap_v1",281 );282 if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;283 unwrappedKey = unhex(masterKeyHex);284 const result = await unlockWithKey(masterKeyHex);285 if (!result.ok) return false;286 await saveSessionKey(unwrappedKey, "real");287 await resetPinFailedAttempts();288 return true;289 } catch {290 await recordPinFailure(failedAttempts + 1);291 return false;292 } finally {293 zeroKey(pinKey);294 if (unwrappedKey) zeroKey(unwrappedKey);295 }296}297298async function savePinWrap(stored: StoredPinWrap): Promise<void> {299 inMemoryPinWrap = stored;300 inMemoryPinFailedAttempts = 0;301 sessionPinStorageFailed = !chrome.storage.session;302 if (!chrome.storage.session) return;303 try {304 await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 });305 sessionPinStorageFailed = false;306 } catch {307 308 sessionPinStorageFailed = true;309 }310}311312async function loadPinState(): Promise<{313 stored: StoredPinWrap | undefined;314 failedAttempts: number;315}> {316 if (!chrome.storage.session) {317 return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };318 }319 try {320 const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);321 return {322 stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ??323 (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined),324 failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number"325 ? result[PIN_ATTEMPTS_STORAGE]326 : 0,327 };328 } catch {329 return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };330 }331}332333async function recordPinFailure(failedAttempts: number): Promise<void> {334 inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS);335 if (!chrome.storage.session) return;336 try {337 await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts });338 } catch { }339}340341async function resetPinFailedAttempts(): Promise<void> {342 inMemoryPinFailedAttempts = 0;343 if (!chrome.storage.session) return;344 try {345 await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 });346 } catch { }347}348349export async function getPinUnlockStatus(): Promise<{350 configured: boolean;351 available: boolean;352 attemptsRemaining: number;353}> {354 await loadSettings();355 if (!settings.pinEnabled) {356 return { configured: false, available: false, attemptsRemaining: 0 };357 }358 const { stored, failedAttempts } = await loadPinState();359 const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS;360 return {361 configured: true,362 available,363 attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0,364 };365}366367async function persistSettings(): Promise<void> {368 try {369 const salt = settings.cardFingerprintSalt ?? randomMetadataSalt();370 await chrome.storage.local.set({371 peach_settings: {372 ...settings,373 manualSummonDomains: undefined,374 manualSummonSalt: undefined,375 cardFingerprintSalt: salt,376 declinedCardFingerprints: settings.declinedCardFingerprints ?? [],377 metadataVersion: 1,378 },379 });380 } catch (err) {381 throw mapStorageError(err);382 }383}384385function mapStorageError(err: unknown): Error {386 const message = err instanceof Error ? err.message : String(err);387 if (/QUOTA_BYTES|quota/i.test(message)) {388 return new Error("STORAGE_QUOTA_EXCEEDED");389 }390 return err instanceof Error ? err : new Error(message);391}392393async function loadSettings(): Promise<void> {394 const result = await chrome.storage.local.get("peach_settings");395 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};396}397398399400function fireSyncTrigger(reason: "vault_change"): void {401 void Promise.resolve()402 .then(() => triggerSyncIfReachable({ reason }))403 .catch(() => {404 405 });406}407408let lockedSyncRefreshHook: (() => Promise<void>) | null = null;409let lockedSyncUnlockHook: (() => Promise<void>) | null = null;410411export function setLockedSyncHooks(hooks: {412 refresh: () => Promise<void>;413 afterUnlock: () => Promise<void>;414}): void {415 lockedSyncRefreshHook = hooks.refresh;416 lockedSyncUnlockHook = hooks.afterUnlock;417}418419async function refreshLockedSyncState(): Promise<void> {420 if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;421 await lockedSyncRefreshHook().catch((error) => {422 console.warn("[Peach sync] locked spool refresh failed:", error);423 });424}425426427428429430export async function checkBreach(password: string): Promise<number> {431 432 433 const encoder = new TextEncoder();434 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));435 const hashHex = Array.from(new Uint8Array(hashBuffer))436 .map((b) => b.toString(16).padStart(2, "0"))437 .join("")438 .toUpperCase();439440 const prefix = hashHex.slice(0, 5);441 const suffix = hashHex.slice(5);442443 try {444 445 446 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {447 headers: { "Add-Padding": "true" },448 });449 if (!res.ok) return 0;450 const text = await res.text();451 const match = text.split("\r\n").find((l) => l.startsWith(suffix));452 return match ? parseInt(match.split(":")[1], 10) : 0;453 } catch {454 return 0; 455 }456}457458export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {459 const fingerprint = await cardFingerprint(last4, expMonth, expYear);460 await loadSettings();461 const salt = settings.cardFingerprintSalt ?? randomMetadataSalt();462 settings = {463 ...settings,464 cardFingerprintSalt: salt,465 declinedCardFingerprints: [466 ...(settings.declinedCardFingerprints ?? []),467 await metadataDigest(fingerprint, salt),468 ].slice(-CARD_DECLINE_MAX),469 };470 await persistSettings();471}472473export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {474 await loadSettings();475 const fingerprint = await cardFingerprint(last4, expMonth, expYear);476 const salt = settings.cardFingerprintSalt;477 if (!salt) return false;478 const candidate = await metadataDigest(fingerprint, salt);479 return (settings.declinedCardFingerprints ?? []).some((stored) => stored === fingerprint || stored === candidate);480}481482async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {483 const encoder = new TextEncoder();484 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));485 return Array.from(new Uint8Array(buf).slice(0, 8))486 .map((b) => b.toString(16).padStart(2, "0"))487 .join("");488}489490function hex(buf: Uint8Array): string {491 return Array.from(buf)492 .map((b) => b.toString(16).padStart(2, "0"))493 .join("");494}495496function unhex(s: string): Uint8Array {497 const out = new Uint8Array(s.length / 2);498 for (let i = 0; i < s.length; i += 2) {499 out[i / 2] = parseInt(s.slice(i, i + 2), 16);500 }501 return out;502}503504505506507508509510511512513514515516517518519520521522523async function generateAndWrapRootKey(): Promise<{524 rawRootKey: string; 525 recoverySalt: string;526 wrappedRootKey: string;527 wrappedNonce: string;528 wrappedTag: string;529}> {530 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));531532 533 const words = await rootKeyToWords(rootKeyBytes);534 const wordMaterial = await wordsToRootKeyMaterial(words);535 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));536 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);537538 539 540 const nonce = crypto.getRandomValues(new Uint8Array(12));541 const encoded = new TextEncoder().encode(hex(rootKeyBytes));542 const raw = await crypto.subtle.encrypt(543 {544 name: "AES-GCM",545 iv: nonce,546 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,547 tagLength: 128,548 },549 recoveryKey,550 encoded551 );552 553 const rawU8 = new Uint8Array(raw);554 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));555 const tag = hex(rawU8.slice(rawU8.length - 16));556557 return {558 rawRootKey: hex(rootKeyBytes),559 recoverySalt: hex(recoverySalt),560 wrappedRootKey: ciphertext,561 wrappedNonce: hex(nonce),562 wrappedTag: tag,563 };564}565566async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{567 recoverySalt: string;568 wrappedRootKey: string;569 wrappedNonce: string;570 wrappedTag: string;571}> {572 if (rootKeyBytes.length !== 32) {573 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");574 }575576 const words = await rootKeyToWords(rootKeyBytes);577 const wordMaterial = await wordsToRootKeyMaterial(words);578 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));579 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);580 const nonce = crypto.getRandomValues(new Uint8Array(12));581 const encoded = new TextEncoder().encode(hex(rootKeyBytes));582 const raw = await crypto.subtle.encrypt(583 {584 name: "AES-GCM",585 iv: nonce,586 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,587 tagLength: 128,588 },589 recoveryKey,590 encoded,591 );592 const rawU8 = new Uint8Array(raw);593 return {594 recoverySalt: hex(recoverySalt),595 wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),596 wrappedNonce: hex(nonce),597 wrappedTag: hex(rawU8.slice(rawU8.length - 16)),598 };599}600601602603604605606export async function unwrapRootKeyForRecovery(607 wrappedRootKey: string,608 wrappedNonce: string,609 wrappedTag: string,610 recoverySaltHex: string,611 words: string[]612): Promise<void> {613 const wordMaterial = await wordsToRootKeyMaterial(words);614 const recoverySalt = unhex(recoverySaltHex);615 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);616617 618 619 620 621 622 const nonceRaw = unhex(wrappedNonce);623 const ciphertextRaw = unhex(wrappedRootKey);624 const tagRaw = unhex(wrappedTag);625 626 627 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;628 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);629 combined.set(ciphertextRaw, 0);630 combined.set(tagRaw, ciphertextRaw.length);631 const raw = await crypto.subtle.decrypt(632 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },633 recoveryKey,634 combined635 );636 const decryptedHex = new TextDecoder().decode(raw);637 rootKey = unhex(decryptedHex);638}639640641642643644645646647648649export async function rewrapRootKeyForPassword(650 newDerivedKey: Uint8Array,651 _newSalt: Uint8Array652): Promise<void> {653 if (!rootKey) throw new Error("root key not set");654 const slotA = await loadSlot(SLOT_A);655 if (!slotA) throw new Error("missing slot A");656 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);657 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });658}659660661662663664665async function reencryptVaultForNewKey(666 newKey: Uint8Array,667 slotA: StoredVault668): Promise<void> {669 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);670 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");671 await writeSlot(SLOT_A, {672 ...slotA,673 nonce,674 ciphertext,675 tag,676 });677}678679680681682683684export async function changePasswordWithNewKey(685 newSalt: Uint8Array,686 newKey: Uint8Array687): Promise<void> {688 const slotA = await loadSlot(SLOT_A);689 if (!slotA) throw new Error("missing slot A");690 691 692 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });693 if (derivedKey) zeroKey(derivedKey);694 derivedKey = newKey;695 696 697 698 void saveSessionKey(newKey, "real");699 700 701 702 703 if (rootKey) {704 const slotAfter = await loadSlot(SLOT_A);705 if (slotAfter) {706 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);707 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });708 }709 }710 if (!duressConfigured) {711 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));712 }713 await clearPinUnlockStorage();714 await stampLocalVaultMutation();715}716717718719720721722export { deriveKey, generateSalt };723724export async function loadSlotA(): Promise<StoredVault | null> {725 return loadSlot(SLOT_A);726}727728729730731732733734735736737async function deriveRootKeyFromDerivedKey(738 derivedKeyBytes: Uint8Array,739 slotA: StoredVault,740): Promise<Uint8Array | null> {741 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {742 return null;743 }744 try {745 const rootHex = await decrypt(746 derivedKeyBytes,747 slotA.pw_root_nonce,748 slotA.pw_root_ciphertext,749 slotA.pw_root_tag,750 "pw_root_v1",751 );752 return unhex(rootHex);753 } catch {754 return null;755 }756}757758759760async function wrapRootKeyForDerivedKey(761 derivedKeyBytes: Uint8Array,762 rootKeyBytes: Uint8Array,763): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {764 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");765 return {766 pw_root_ciphertext: ciphertext,767 pw_root_nonce: nonce,768 pw_root_tag: tag,769 };770}771772773export async function getRecoveryWords(): Promise<string[]> {774 if (!rootKey) {775 throw new Error("recovery not set up");776 }777 return rootKeyToWords(rootKey);778}779780async function loadSlot(key: string): Promise<StoredVault | null> {781 const result = await chrome.storage.local.get(key);782 return (result as Record<string, StoredVault | undefined>)[key] ?? null;783}784785async function writeSlot(key: string, vault: StoredVault): Promise<void> {786 try {787 await chrome.storage.local.set({ [key]: vault });788 } catch (err) {789 throw mapStorageError(err);790 }791}792793794795796797798799800801802export async function rollbackAdoptedVault(): Promise<void> {803 lock();804 try {805 await chrome.storage.local.remove([806 SLOT_A,807 SLOT_B,808 "peach_last_modified_at",809 ]);810 } catch (err) {811 throw mapStorageError(err);812 }813}814815async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {816 const raw = await chrome.storage.local.get("peach_last_modified_at");817 const previous = Number(818 (raw as Record<string, unknown>).peach_last_modified_at,819 );820 const next = Math.max(821 Date.now(),822 Number.isFinite(previous) ? previous + 1 : 0,823 );824 try {825 await chrome.storage.local.set({826 [SLOT_A]: slotA,827 [SLOT_B]: slotB,828 peach_last_modified_at: String(next),829 });830 } catch (err) {831 throw mapStorageError(err);832 }833}834835836837838839840841842let lastEntryTimestamp = 0;843function monotonicEntryTimestamp(currentModifiedAt?: number): number {844 const base = Math.max(845 Date.now(),846 (currentModifiedAt ?? 0) + 1,847 lastEntryTimestamp + 1,848 );849 lastEntryTimestamp = base;850 return base;851}852853async function stampLocalVaultMutation(): Promise<void> {854 const raw = await chrome.storage.local.get("peach_last_modified_at");855 const previous = Number(856 (raw as Record<string, unknown>).peach_last_modified_at,857 );858 const next = Math.max(859 Date.now(),860 Number.isFinite(previous) ? previous + 1 : 0,861 );862 await chrome.storage.local.set({863 peach_last_modified_at: String(next),864 });865}866867868869870871872873874875876877function padPlaintext(json: string, minBytes = 0): string {878 const encoded = new TextEncoder().encode(json);879 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);880 881 const target = Math.max(bucket, minBytes);882 const padLen = target - encoded.length;883 return json + " ".repeat(padLen);884}885886887888889890891892893894895896const CSPRNG_CHUNK = 65_536;897898function fillRandom(buf: Uint8Array): Uint8Array {899 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {900 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);901 const chunk = new Uint8Array(end - offset);902 crypto.getRandomValues(chunk);903 buf.set(chunk, offset);904 }905 return buf;906}907908function generateNoiseBlob(ciphertextBytes: number): StoredVault {909 const salt = crypto.getRandomValues(new Uint8Array(16));910 const nonce = crypto.getRandomValues(new Uint8Array(12));911 const cipher = fillRandom(new Uint8Array(ciphertextBytes));912 const tag = crypto.getRandomValues(new Uint8Array(16));913 return {914 version: 1,915 salt: hex(salt),916 nonce: hex(nonce),917 ciphertext: hex(cipher),918 tag: hex(tag),919 };920}921922923924925926type VaultPayload = {927 entries: VaultEntry[];928 tombstones?: Record<string, number>;929 duressConfigured?: boolean;930};931932type DecryptedPayload = {933 entries: VaultEntry[];934 tombstones: Map<string, number>;935 duressConfigured: boolean;936};937938function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {939 const envelope: VaultPayload = {940 entries,941 tombstones: Object.fromEntries(tombstones),942 duressConfigured: duress,943 };944 return padPlaintext(JSON.stringify(envelope), minBytes);945}946947function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {948 const result = new Map<string, number>();949 if (raw && typeof raw === "object" && !Array.isArray(raw)) {950 for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {951 if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {952 result.set(id, timestamp);953 }954 }955 }956 957 958 for (const entry of allEntries) {959 if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {960 continue;961 }962 if ((result.get(entry.id) ?? 0) < entry.deletedAt) {963 result.set(entry.id, entry.deletedAt);964 }965 }966 return result;967}968969function reconcileTombstones(): void {970 const byId = new Map(entries.map((entry) => [entry.id, entry]));971 for (const [id] of tombstones) {972 if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);973 }974 for (const entry of entries) {975 if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {976 if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {977 tombstones.set(entry.id, entry.deletedAt);978 }979 }980 }981}982983function decodePayload(plain: string): DecryptedPayload {984 const parsed = JSON.parse(plain) as VaultPayload;985 if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {986 throw new Error("invalid vault payload");987 }988 const normalized = parsed.entries.map(normalizeEntry);989 return {990 entries: normalized,991 tombstones: decodeTombstones(parsed.tombstones, normalized),992 duressConfigured: parsed.duressConfigured === true,993 };994}99599699799899910001001100210031004100510061007100810091010101110121013101410151016function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {1017 let merged = false;1018 const now = Date.now();10191020 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);1021 const byRpId = new Map<string, VaultEntry[]>();1022 for (const e of candidates) {1023 const rpId = e.passkey!.rpId;1024 const list = byRpId.get(rpId);1025 if (list) list.push(e);1026 else byRpId.set(rpId, [e]);1027 }10281029 for (const [rpId, group] of byRpId) {1030 if (group.length === 0) continue;1031 group.sort((a, b) => {1032 const aT =1033 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;1034 const bT =1035 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;1036 return bT - aT;1037 });1038 const winner = group[0]!;10391040 1041 1042 1043 const loginTarget = entries.find((e) => {1044 if (e.deletedAt || e.passkey || e.type !== "login") return false;1045 if (e.url && extractDomain(e.url) === rpId) return true;1046 for (const extra of e.additionalUrls ?? []) {1047 if (extractDomain(extra) === rpId) return true;1048 }1049 return false;1050 });10511052 if (loginTarget) {1053 loginTarget.passkey = winner.passkey;1054 1055 1056 1057 if (!loginTarget.totp && winner.totp) {1058 loginTarget.totp = winner.totp;1059 loginTarget.totpAlgorithm = winner.totpAlgorithm;1060 loginTarget.totpDigits = winner.totpDigits;1061 loginTarget.totpPeriod = winner.totpPeriod;1062 }1063 loginTarget.modifiedAt = now;1064 for (const dup of group) {1065 if (!dup.deletedAt) {1066 dup.deletedAt = now;1067 dup.modifiedAt = now;1068 }1069 }1070 merged = true;1071 } else if (group.length > 1) {1072 for (let i = 1; i < group.length; i += 1) {1073 const dup = group[i]!;1074 if (!dup.deletedAt) {1075 dup.deletedAt = now;1076 dup.modifiedAt = now;1077 }1078 }1079 merged = true;1080 }1081 }10821083 return { merged };1084}10851086108710881089109010911092function hasValidVaultSchema(v: unknown): v is StoredVault {1093 if (typeof v !== "object" || v === null) return false;1094 const r = v as Record<string, unknown>;1095 return (1096 r.version === 1 &&1097 typeof r.salt === "string" &&1098 typeof r.nonce === "string" &&1099 typeof r.ciphertext === "string" &&1100 typeof r.tag === "string"1101 );1102}11031104export type TryDecryptResult =1105 | { status: "ok"; payload: DecryptedPayload }1106 | { status: "wrong_key" }1107 | { status: "corrupted"; reason: string };1108110911101111111211131114export async function tryDecryptSlot(1115 vault: StoredVault,1116 key: Uint8Array,1117 aadTag?: string,1118): Promise<DecryptedPayload | null> {1119 const res = await tryDecryptSlotResult(vault, key, aadTag);1120 return res.status === "ok" ? res.payload : null;1121}11221123export async function tryDecryptSlotResult(1124 vault: StoredVault,1125 key: Uint8Array,1126 aadTag?: string,1127): Promise<TryDecryptResult> {1128 if (!hasValidVaultSchema(vault)) {1129 return { status: "corrupted", reason: "invalid_slot_schema" };1130 }1131 let plain: string;1132 try {1133 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1134 } catch {1135 return { status: "wrong_key" };1136 }1137 try {1138 const parsed = JSON.parse(plain) as unknown;1139 if (typeof parsed !== "object" || parsed === null) {1140 return { status: "corrupted", reason: "invalid_payload_shape" };1141 }1142 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1143 return { status: "corrupted", reason: "invalid_payload_shape" };1144 }1145 return { status: "ok", payload: decodePayload(plain) };1146 } catch {1147 return { status: "corrupted", reason: "invalid_payload_json" };1148 }1149}115011511152115311541155function buildDomainIndexSync(all: VaultEntry[]): void {1156 domainIndex = new Map();11571158 for (const entry of all) {1159 if (entry.deletedAt) continue;1160 if (entry.type === "note" || entry.type === "card") continue;11611162 1163 1164 1165 1166 const domains = new Set<string>();1167 const primary = extractDomain(entry.url ?? "");1168 if (primary) domains.add(primary);1169 for (const extra of entry.additionalUrls ?? []) {1170 const d = extractDomain(extra);1171 if (d) domains.add(d);1172 }1173 if (domains.size === 0) continue;11741175 for (const domain of domains) {1176 const bucket = domainIndex.get(domain) ?? [];1177 bucket.push(entry);1178 domainIndex.set(domain, bucket);1179 }1180 }1181}11821183export async function createVault(password: string): Promise<void> {1184 const salt = generateSalt();1185 derivedKey = await deriveKey(password, salt);1186 entries = [];1187 tombstones = new Map();1188 vaultMode = "real";1189 duressConfigured = false;1190 buildDomainIndexSync(entries);11911192 1193 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1194 await generateAndWrapRootKey();1195 rootKey = unhex(rawRootKey);11961197 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1198 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");11991200 1201 1202 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);12031204 const slotA: StoredVault = {1205 version: 1,1206 salt: hex(salt),1207 nonce,1208 ciphertext,1209 tag,1210 recovery_salt: recoverySalt,1211 recovery_wrapped_root_key: wrappedRootKey,1212 recovery_nonce: wrappedNonce,1213 recovery_tag: wrappedTag,1214 ...pwRoot,1215 };1216 await writeSlot(SLOT_A, slotA);12171218 1219 1220 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1221 await stampLocalVaultMutation();12221223 touchAutoLock();1224}1225122612271228122912301231export async function createVaultFromRootKey(1232 password: string,1233 rootKeyHex: string,1234): Promise<{ vaultId: string }> {1235 if (typeof password !== "string" || password.length === 0) {1236 throw new Error("ADOPT_INVALID_PASSWORD");1237 }1238 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1239 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1240 }12411242 const rootKeyBytes = unhex(rootKeyHex);1243 if (rootKeyBytes.length !== 32) {1244 rootKeyBytes.fill(0);1245 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1246 }12471248 let nextDerivedKey: Uint8Array | null = null;1249 let sessionRootKey: Uint8Array | null = null;1250 try {1251 if ((await loadSlot(SLOT_A)) !== null) {1252 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1253 }12541255 const nextEntries: VaultEntry[] = [];1256 const nextDuressConfigured = false;1257 const vaultId = await computeVaultId(rootKeyBytes);1258 const salt = generateSalt();1259 nextDerivedKey = await deriveKey(password, salt);1260 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);12611262 sessionRootKey = new Uint8Array(rootKeyBytes);1263 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1264 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1265 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1266 const slotA: StoredVault = {1267 version: 1,1268 salt: hex(salt),1269 nonce,1270 ciphertext,1271 tag,1272 recovery_salt: recovery.recoverySalt,1273 recovery_wrapped_root_key: recovery.wrappedRootKey,1274 recovery_nonce: recovery.wrappedNonce,1275 recovery_tag: recovery.wrappedTag,1276 ...pwRoot,1277 };1278 const slotB = generateNoiseBlob(ciphertext.length / 2);1279 await commitCreatedVaultSlots(slotA, slotB);12801281 const previousDerivedKey = derivedKey;1282 const previousRootKey = rootKey;1283 derivedKey = nextDerivedKey;1284 rootKey = sessionRootKey;1285 nextDerivedKey = null;1286 sessionRootKey = null;1287 entries = nextEntries;1288 tombstones = new Map();1289 vaultMode = "real";1290 duressConfigured = nextDuressConfigured;1291 buildDomainIndexSync(entries);1292 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1293 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1294 touchAutoLock();12951296 return { vaultId };1297 } finally {1298 rootKeyBytes.fill(0);1299 if (nextDerivedKey) zeroKey(nextDerivedKey);1300 if (sessionRootKey) sessionRootKey.fill(0);1301 }1302}13031304export async function hasVault(): Promise<boolean> {1305 return (await loadSlot(SLOT_A)) !== null;1306}1307130813091310131113121313export async function isDuressEnabled(): Promise<boolean> {1314 return vaultMode === "real" && duressConfigured;1315}13161317export async function setupDuress(duressPassword: string): Promise<void> {1318 if (!derivedKey || vaultMode !== "real") {1319 throw new Error("vault must be unlocked in real mode");1320 }1321 1322 1323 1324 const slotA = await loadSlot(SLOT_A);1325 if (!slotA) throw new Error("missing slot A");13261327 const decoySalt = generateSalt();1328 const decoyKey = await deriveKey(duressPassword, decoySalt);1329 try {1330 1331 1332 1333 1334 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1335 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1336 await writeSlot(SLOT_B, {1337 version: 1,1338 salt: hex(decoySalt),1339 nonce,1340 ciphertext,1341 tag,1342 });1343 } finally {1344 zeroKey(decoyKey);1345 }13461347 1348 1349 duressConfigured = true;1350 await persistSlotA();1351}13521353export async function disableDuress(): Promise<void> {1354 if (!derivedKey || vaultMode !== "real") {1355 throw new Error("vault must be unlocked in real mode");1356 }1357 1358 1359 1360 const slotA = await loadSlot(SLOT_A);1361 if (slotA) {1362 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1363 }13641365 duressConfigured = false;1366 await persistSlotA();1367}13681369137013711372137313741375export async function verifyMainPassword(password: string): Promise<boolean> {1376 const slotA = await loadSlot(SLOT_A);1377 if (!slotA) return false;1378 const key = await deriveKey(password, unhex(slotA.salt));1379 try {1380 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1381 } finally {1382 zeroKey(key);1383 }1384}13851386export async function unlock(password: string): Promise<boolean> {1387 return attemptUnlockWithDerive(password, deriveKey);1388}13891390async function attemptUnlockWithDerive(1391 password: string,1392 derive: typeof deriveKey,1393): Promise<boolean> {1394 1395 1396 1397 1398 1399 1400 const [slotA, slotB] = await Promise.all([1401 loadSlot(SLOT_A),1402 loadSlot(SLOT_B),1403 ]);14041405 1406 const [keyA, keyB] = await Promise.all([1407 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1408 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1409 ]);14101411 1412 1413 1414 const [resultA, resultB] = await Promise.all([1415 keyA && slotA1416 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1417 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1418 keyB && slotB1419 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1420 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1421 ]);1422 const payloadA = resultA.status === "ok" ? resultA.payload : null;1423 const payloadB = resultB.status === "ok" ? resultB.payload : null;14241425 if (payloadA && keyA) {1426 derivedKey = keyA;1427 void saveSessionKey(keyA, "real");1428 vaultMode = "real";1429 entries = payloadA.entries;1430 tombstones = payloadA.tombstones;1431 duressConfigured = payloadA.duressConfigured;1432 buildDomainIndexSync(entries);1433 await loadSettings();1434 touchAutoLock();1435 if (keyB) zeroKey(keyB);14361437 1438 1439 rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1440 if (!rootKey) {1441 lock();1442 return false;1443 }14441445 await lockedSyncUnlockHook?.().catch((error) => {1446 console.warn("[Peach sync] locked inbox drain failed:", error);1447 });1448 await resetPinFailedAttempts();1449 return true;1450 }14511452 if (payloadB && keyB) {1453 derivedKey = keyB;1454 void saveSessionKey(keyB, "decoy");1455 vaultMode = "decoy";1456 entries = payloadB.entries;1457 tombstones = payloadB.tombstones;1458 duressConfigured = false;1459 buildDomainIndexSync(entries);1460 await loadSettings();1461 touchAutoLock();1462 if (keyA) zeroKey(keyA);1463 return true;1464 }14651466 if (keyA) zeroKey(keyA);1467 if (keyB) zeroKey(keyB);1468 1469 1470 if (resultA.status === "corrupted") {1471 lastUnlockFailureReason = resultA.reason;1472 } else if (resultB.status === "corrupted") {1473 lastUnlockFailureReason = resultB.reason;1474 }1475 return false;1476}14771478147914801481148214831484const SESSION_KEY_STORAGE = "peach_session_dk";14851486async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1487 if (!chrome.storage.session) return;1488 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1489}14901491async function clearSessionKey(): Promise<void> {1492 if (!chrome.storage.session) return;1493 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1494}149514961497149814991500150115021503export async function restoreFromSession(): Promise<boolean> {1504 if (derivedKey !== null) return true;1505 if (!chrome.storage.session) return false;1506 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1507 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1508 if (!entry?.keyHex) return false;15091510 const key = unhex(entry.keyHex);1511 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1512 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1513 const slot = await loadSlot(slotKey);1514 if (!slot) { await clearSessionKey(); return false; }15151516 const result = await tryDecryptSlotResult(slot, key, aadTag);1517 if (result.status !== "ok") { await clearSessionKey(); return false; }15181519 derivedKey = key;1520 vaultMode = entry.mode;1521 entries = result.payload.entries;1522 tombstones = result.payload.tombstones;1523 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1524 buildDomainIndexSync(entries);1525 await loadSettings();1526 touchAutoLock();15271528 1529 1530 if (entry.mode === "real") {1531 rootKey = await deriveRootKeyFromDerivedKey(key, slot);1532 if (!rootKey) {1533 lock();1534 return false;1535 }1536 }15371538 await lockedSyncUnlockHook?.().catch((error) => {1539 console.warn("[Peach sync] locked inbox drain failed:", error);1540 });15411542 return true;1543}15441545export function lock(): void {1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 void clearSessionKey();1556 void clearRecoveryConfirmPending();1557 if (derivedKey) {1558 zeroKey(derivedKey);1559 derivedKey = null;1560 }1561 if (rootKey) {1562 rootKey.fill(0);1563 rootKey = null;1564 }15651566 entries = [];1567 tombstones = new Map();1568 domainIndex = new Map();1569 vaultMode = "real";1570 duressConfigured = false;1571 clearAutoLock();1572}15731574export function isUnlocked(): boolean {1575 return derivedKey !== null;1576}15771578157915801581export async function unlockWithKey(1582 masterKeyHex: string,1583): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1584 try {1585 const masterKeyBytes = unhex(masterKeyHex);1586 const stored = await loadSlot(SLOT_A);1587 if (!stored) return { ok: false, error: "invalid_key" };1588 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1589 if (!payload) return { ok: false, error: "invalid_key" };15901591 derivedKey = masterKeyBytes;1592 vaultMode = "real";1593 entries = payload.entries;1594 tombstones = payload.tombstones;1595 duressConfigured = payload.duressConfigured;1596 buildDomainIndexSync(entries);1597 await loadSettings();1598 touchAutoLock();15991600 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1601 if (!rootKey) {1602 lock();1603 return { ok: false, error: "invalid_key" };1604 }1605 return { ok: true, entries };1606 } catch {1607 return { ok: false, error: "invalid_key" };1608 }1609}161016111612161316141615161616171618export async function exportDuressSlotForAdoption(): Promise<string | null> {1619 if (!(await isDuressEnabled())) return null;1620 const slotB = await loadSlot(SLOT_B);1621 if (!slotB) return null;1622 return JSON.stringify(slotB);1623}16241625162616271628162916301631export async function installAdoptedDuressSlot(json: string): Promise<void> {1632 let parsed: StoredVault;1633 try {1634 const raw = JSON.parse(json) as unknown;1635 if (!hasValidVaultSchema(raw)) {1636 throw new Error("invalid_duress_slot_shape");1637 }1638 parsed = raw;1639 } catch (err) {1640 if (err instanceof SyntaxError) {1641 throw new Error("invalid_duress_slot_shape");1642 }1643 throw err;1644 }1645 await writeSlot(SLOT_B, parsed);1646}16471648164916501651165216531654165516561657165816591660166116621663166416651666export async function unlockFromCodex(params: {1667 slotA: string; 1668 slotB: string | null; 1669 timestamp: number;1670 masterPassphrase: string;1671}): Promise<1672 | { ok: true; entryCount: number }1673 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1674> {1675 let parsedSlotA: StoredVault;1676 let parsedSlotB: StoredVault | null = null;1677 try {1678 const rawA = JSON.parse(params.slotA) as unknown;1679 if (!hasValidVaultSchema(rawA)) {1680 return { ok: false, reason: "invalid_slot_format" };1681 }1682 parsedSlotA = rawA;1683 if (params.slotB !== null) {1684 const rawB = JSON.parse(params.slotB) as unknown;1685 if (!hasValidVaultSchema(rawB)) {1686 return { ok: false, reason: "invalid_slot_format" };1687 }1688 parsedSlotB = rawB;1689 }1690 } catch {1691 return { ok: false, reason: "invalid_slot_format" };1692 }16931694 1695 1696 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1697 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1698 if (!trialPayload) {1699 zeroKey(trialKey);1700 return { ok: false, reason: "wrong_passphrase" };1701 }1702 1703 1704 zeroKey(trialKey);17051706 1707 1708 await takeVaultSnapshot("restore");17091710 await writeSlot(SLOT_A, parsedSlotA);1711 if (parsedSlotB) {1712 await writeSlot(SLOT_B, parsedSlotB);1713 } else {1714 1715 1716 1717 await writeSlot(1718 SLOT_B,1719 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1720 );1721 }1722 await chrome.storage.local.set({1723 peach_last_modified_at: String(params.timestamp),1724 });17251726 1727 1728 const ok = await unlock(params.masterPassphrase);1729 if (!ok) {1730 1731 1732 1733 return { ok: false, reason: "wrong_passphrase" };1734 }1735 return { ok: true, entryCount: getEntries().length };1736}17371738173917401741export function getDerivedKeyHex(): string {1742 if (!derivedKey) throw new Error("vault_not_unlocked");1743 return hex(derivedKey);1744}17451746174717481749175017511752175317541755175617571758175917601761export function getRootKeyHex(): string | null {1762 return rootKey ? hex(rootKey) : null;1763}17641765export function getEntries(): VaultEntry[] {1766 if (!derivedKey) throw new Error("vault_locked");1767 return entries.filter((entry) => !entry.deletedAt);1768}17691770const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;17711772export function getIdentities(): SavedIdentity[] {1773 if (!derivedKey) throw new Error("vault_locked");1774 const active = entries.filter((e) => !e.deletedAt && e.type === "login");17751776 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1777 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();17781779 for (const entry of active) {1780 const domain = extractDomain(entry.url ?? "") ?? "";1781 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;17821783 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1784 const existing = map.get(v);1785 if (!existing || modifiedAt > existing.lastUsed) {1786 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1787 } else {1788 map.set(v, { ...existing, count: existing.count + 1 });1789 }1790 }17911792 const uVal = entry.username?.trim();1793 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);17941795 1796 1797 const eVal = entry.email?.trim();1798 if (eVal) tally(emailMap, eVal);1799 }18001801 const toIdentity = (type: "email" | "username") =>1802 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1803 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1804 });18051806 return [1807 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1808 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1809 ];1810}18111812export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1813 if (!derivedKey) throw new Error("vault_locked");18141815 const now = Date.now();1816 const added: VaultEntry[] = [];1817 for (const draft of drafts) {1818 try {1819 const sanitized = sanitizeEntryDraft(draft);1820 const nextEntry: VaultEntry = {1821 ...sanitized,1822 id: crypto.randomUUID(),1823 createdAt: now,1824 modifiedAt: monotonicEntryTimestamp(),1825 };1826 entries.push(nextEntry);1827 added.push(nextEntry);1828 } catch {1829 1830 }1831 }18321833 if (added.length > 0) {1834 await persistEntries();1835 }18361837 return added;1838}183918401841184218431844184518461847export async function restoreEntries(1848 incoming: VaultEntry[],1849 mode: "replace" | "merge"1850): Promise<{ added: number; updated: number; skipped: number }> {1851 if (!derivedKey) throw new Error("vault_locked");18521853 1854 1855 1856 if (mode === "replace" && incoming.length === 0) {1857 throw new Error("refuse to replace with empty entry set");1858 }18591860 let added = 0;1861 let updated = 0;1862 let skipped = 0;18631864 if (mode === "replace") {1865 entries = [];1866 for (const entry of incoming) {1867 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));1868 if (!normalized) {1869 skipped += 1;1870 continue;1871 }1872 entries.push(normalized);1873 if (normalized.deletedAt) {1874 tombstones.set(1875 normalized.id,1876 Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),1877 );1878 } else {1879 tombstones.delete(normalized.id);1880 }1881 added += 1;1882 }1883 } else {1884 const byId = new Map<string, VaultEntry>();1885 for (const e of entries) byId.set(e.id, e);18861887 for (const entry of incoming) {1888 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));1889 if (!normalized) {1890 skipped += 1;1891 continue;1892 }1893 const existing = byId.get(normalized.id);1894 if (!existing) {1895 entries.push(normalized);1896 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1897 else tombstones.delete(normalized.id);1898 added += 1;1899 } else if (normalized.modifiedAt > existing.modifiedAt) {1900 const idx = entries.findIndex((e) => e.id === existing.id);1901 if (idx !== -1) {1902 entries[idx] = normalized;1903 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1904 else tombstones.delete(normalized.id);1905 updated += 1;1906 }1907 } else {1908 skipped += 1;1909 }1910 }1911 }19121913 await persistEntries("import");1914 return { added, updated, skipped };1915}191619171918191919201921192219231924192519261927192819291930193119321933const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;19341935function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {1936 if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;1937 return { ...entry, id: crypto.randomUUID() };1938}193919401941194219431944194519461947function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1948 if (!entry || typeof entry !== "object") return null;1949 const title = typeof entry.title === "string" ? entry.title.trim() : "";1950 if (!title) return null;1951 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1952 const now = Date.now();1953 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1954 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;19551956 1957 1958 const normalized = normalizeEntry({1959 ...entry,1960 id,1961 title,1962 type: entry.type ?? "login",1963 createdAt,1964 modifiedAt,1965 });19661967 if (normalized.passkey) {1968 normalized.passkey = sanitizePasskey(normalized.passkey);1969 }1970 if (normalized.canary) {1971 normalized.canary = sanitizeCanary(normalized.canary);1972 }1973 1974 normalized.tags = sanitizeTags(normalized.tags);19751976 1977 1978 if (1979 normalized.type === "login" &&1980 !normalized.passkey &&1981 typeof normalized.password !== "string"1982 ) {1983 return null;1984 }1985 if (entry.passkey && !normalized.passkey) {1986 1987 1988 return null;1989 }19901991 return normalized;1992}1993199419951996199719981999function sanitizePasskey(pk: unknown): PasskeyData | undefined {2000 if (!pk || typeof pk !== "object") return undefined;2001 const r = pk as Record<string, unknown>;2002 if (2003 typeof r.rpId !== "string" || !r.rpId ||2004 typeof r.credentialId !== "string" || !r.credentialId ||2005 typeof r.publicKeyCose !== "string" ||2006 typeof r.userHandle !== "string" ||2007 typeof r.userName !== "string"2008 ) {2009 return undefined;2010 }2011 return {2012 rpId: r.rpId,2013 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2014 credentialId: r.credentialId,2015 publicKeyCose: r.publicKeyCose,2016 privateKeyJwk:2017 r.privateKeyJwk && typeof r.privateKeyJwk === "object"2018 ? (r.privateKeyJwk as JsonWebKey)2019 : undefined,2020 userHandle: r.userHandle,2021 userName: r.userName,2022 userDisplayName:2023 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2024 algorithm: r.algorithm === -8 ? -8 : -7,2025 signCount: typeof r.signCount === "number" ? r.signCount : 0,2026 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2027 backupEligible: r.backupEligible === true,2028 backupState: r.backupState === true,2029 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2030 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2031 transports: Array.isArray(r.transports)2032 ? r.transports.filter((t): t is string => typeof t === "string")2033 : undefined,2034 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2035 };2036}20372038203920402041204220432044204520462047function sanitizeCanary(c: unknown): Canary | undefined {2048 if (!c || typeof c !== "object") return undefined;2049 const r = c as Record<string, unknown>;2050 if (typeof r.webhookUrl !== "string") return undefined;2051 let url: URL;2052 try {2053 url = new URL(r.webhookUrl);2054 } catch {2055 return undefined;2056 }2057 if (url.protocol !== "https:") return undefined;2058 return {2059 webhookUrl: url.toString(),2060 label: typeof r.label === "string" ? r.label : undefined,2061 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2062 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2063 };2064}20652066206720682069207020712072function sanitizeTags(tags: unknown): string[] | undefined {2073 if (!Array.isArray(tags)) return undefined;2074 const out: string[] = [];2075 for (const t of tags) {2076 if (typeof t !== "string") continue;2077 const trimmed = t.trim();2078 if (!trimmed) continue;2079 out.push(trimmed.slice(0, 256));2080 if (out.length >= 255) break;2081 }2082 return out.length > 0 ? out : undefined;2083}208420852086208720882089export async function clearVaultEntries(2090 confirmation: "REPLACE_ALL"2091): Promise<void> {2092 if (confirmation !== "REPLACE_ALL") {2093 throw new Error("clearVaultEntries requires explicit confirmation");2094 }2095 if (!derivedKey) throw new Error("vault_locked");2096 for (const entry of entries) {2097 if (!entry.deletedAt) {2098 const stamp = monotonicEntryTimestamp(Math.max(2099 entry.modifiedAt,2100 tombstones.get(entry.id) ?? 0,2101 ));2102 entry.deletedAt = stamp;2103 entry.modifiedAt = stamp;2104 tombstones.set(entry.id, 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 previousTombstone = tombstones.get(id);2269 const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2270 entry.deletedAt = stamp;2271 entry.modifiedAt = stamp;2272 tombstones.set(id, stamp);2273 2274 2275 try {2276 await persistEntries();2277 } catch (err) {2278 delete entry.deletedAt;2279 entry.modifiedAt = previousModifiedAt;2280 if (previousTombstone === undefined) tombstones.delete(id);2281 else tombstones.set(id, previousTombstone);2282 throw err;2283 }2284 fireSyncTrigger("vault_change");2285}22862287export function getDeletedEntries(): VaultEntry[] {2288 if (!derivedKey) return [];2289 return entries.filter((e) => !!e.deletedAt);2290}229122922293export function getTombstoneManifest(): Record<string, number> {2294 if (!derivedKey) return {};2295 return Object.fromEntries(tombstones);2296}2297229822992300230123022303export async function getVaultVersion(): Promise<number> {2304 try {2305 const raw = await chrome.storage.local.get("peach_last_modified_at");2306 const v = (raw as Record<string, unknown>).peach_last_modified_at;2307 if (typeof v === "number") return v;2308 if (typeof v === "string" && v !== "") {2309 const n = Number.parseInt(v, 10);2310 return Number.isFinite(n) ? n : 0;2311 }2312 return 0;2313 } catch {2314 return 0;2315 }2316}2317231823192320232123222323232423252326232723282329export async function applyMergedRemoteEntries(2330 remoteEntries: VaultEntry[],2331 remoteTombstones: Record<string, number>,2332 _context: { deviceName?: string } = {},2333): Promise<{ received: number; applied: number; conflicts: number }> {2334 2335 2336 2337 2338 2339 2340 if (!derivedKey) throw new Error("vault_locked");23412342 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2343 let applied = 0;2344 let conflicts = 0;23452346 const byId = new Map<string, VaultEntry>();2347 for (const e of entries) byId.set(e.id, e);23482349 const mergeNow = Date.now();2350 const remoteTombstoneTimes = new Map<string, number>();2351 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2352 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2353 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2354 }2355 }23562357 for (const remote of remoteEntries) {2358 const sanitized = sanitizeRestoredEntry(remote);2359 if (!sanitized) continue;2360 2361 2362 2363 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2364 if (typeof sanitized.deletedAt === "number") {2365 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2366 }2367 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2368 const localTombstoneTs = tombstones.get(sanitized.id);2369 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2370 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2371 continue;2372 }2373 const local = byId.get(sanitized.id);2374 if (!local) {2375 entries.push(sanitized);2376 byId.set(sanitized.id, sanitized);2377 tombstones.delete(sanitized.id);2378 applied += 1;2379 } else if (sanitized.modifiedAt > local.modifiedAt) {2380 const idx = entries.findIndex((e) => e.id === local.id);2381 if (idx !== -1) {2382 entries[idx] = sanitized;2383 byId.set(sanitized.id, sanitized);2384 tombstones.delete(sanitized.id);2385 applied += 1;2386 if (!local.deletedAt) conflicts += 1;2387 }2388 }2389 }23902391 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2392 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2393 2394 const deletedAt = remoteTombstoneTimes.get(id)!;2395 const knownTombstone = tombstones.get(id);2396 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2397 const local = byId.get(id);2398 if (!local) {2399 tombstones.set(id, deletedAt);2400 applied += 1;2401 continue;2402 }2403 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2404 if (deletedAt < local.modifiedAt) continue;2405 const idx = entries.findIndex((e) => e.id === id);2406 if (idx !== -1) {2407 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2408 tombstones.set(id, deletedAt);2409 applied += 1;2410 }2411 }24122413 if (applied > 0) {2414 await persistEntries("sync_received");2415 buildDomainIndexSync(entries);2416 }2417 return { received, applied, conflicts };2418}24192420export async function restoreEntry(id: string): Promise<void> {2421 if (!derivedKey) throw new Error("vault_locked");2422 const entry = entries.find((e) => e.id === id && e.deletedAt);2423 if (!entry) throw new Error("deleted entry not found");2424 const previousDeletedAt = entry.deletedAt;2425 const previousModifiedAt = entry.modifiedAt;2426 const previousTombstone = tombstones.get(id);2427 delete entry.deletedAt;2428 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2429 entry.modifiedAt,2430 previousTombstone ?? 0,2431 ));2432 tombstones.delete(id);2433 try {2434 await persistEntries();2435 } catch (err) {2436 entry.deletedAt = previousDeletedAt;2437 entry.modifiedAt = previousModifiedAt;2438 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2439 throw err;2440 }2441 buildDomainIndexSync(entries);2442}24432444export async function purgeEntry(id: string): Promise<void> {2445 if (!derivedKey) throw new Error("vault_locked");2446 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2447 if (idx === -1) throw new Error("deleted entry not found");2448 const entry = entries[idx]!;2449 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2450 entries.splice(idx, 1);2451 await persistEntries();2452}245324542455export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2456 if (!derivedKey || retentionMs === 0) return 0;2457 const cutoff = Date.now() - retentionMs;2458 const before = entries.length;2459 for (const entry of entries) {2460 if (entry.deletedAt && entry.deletedAt < cutoff) {2461 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2462 }2463 }2464 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2465 const purged = before - entries.length;2466 if (purged > 0) await persistEntries();2467 return purged;2468}24692470export function queryDomain(domain: string): VaultEntry[] {2471 return domainIndex.get(domain) ?? [];2472}24732474export function getEntryById(id: string): VaultEntry | undefined {2475 return entries.find((entry) => entry.id === id && !entry.deletedAt);2476}24772478async function persistSlotA(2479 reason: VaultSnapshot["reason"] = "local_save",2480): Promise<StoredVault> {2481 if (!derivedKey) throw new Error("vault_locked");24822483 const slotA = await loadSlot(SLOT_A);2484 if (!slotA) throw new Error("missing slot A");24852486 2487 2488 await takeVaultSnapshot(reason);24892490 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2491 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");24922493 2494 const nextSlotA: StoredVault = {2495 ...slotA,2496 nonce,2497 ciphertext,2498 tag,2499 2500 };2501 await writeSlot(SLOT_A, nextSlotA);25022503 2504 2505 2506 2507 2508 2509 try {2510 2511 2512 2513 if (!duressConfigured) {2514 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2515 }2516 2517 2518 2519 2520 2521 if (reason !== "sync_received") {2522 await stampLocalVaultMutation();2523 }2524 } catch (err) {2525 2526 2527 await writeSlot(SLOT_A, slotA).catch(() => {});2528 throw err;2529 }25302531 return nextSlotA;2532}25332534async function persistSlotB(): Promise<void> {2535 if (!derivedKey) throw new Error("vault_locked");25362537 const slotB = await loadSlot(SLOT_B);2538 if (!slotB) throw new Error("missing slot B");25392540 2541 2542 2543 2544 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2545 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");25462547 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2548 2549 2550 2551 try {2552 await stampLocalVaultMutation();2553 } catch (err) {2554 await writeSlot(SLOT_B, slotB).catch(() => {});2555 throw err;2556 }2557}25582559async function persistEntries(2560 reason: VaultSnapshot["reason"] = "local_save",2561): Promise<void> {2562 if (!derivedKey) throw new Error("vault_locked");25632564 2565 2566 const previousTombstones = tombstones;2567 tombstones = new Map(tombstones);2568 reconcileTombstones();2569 try {2570 if (vaultMode === "decoy") {2571 await persistSlotB();2572 } else {2573 await persistSlotA(reason);2574 }2575 } catch (err) {2576 tombstones = previousTombstones;2577 throw err;2578 }2579 buildDomainIndexSync(entries);2580 await refreshLockedSyncState();2581}2582258325842585258625872588function allowHttpUrl(url: string | undefined): string {2589 const trimmed = url?.trim() ?? "";2590 if (!trimmed) return "";2591 try {2592 const parsed = new URL(trimmed);2593 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2594 } catch {2595 2596 }2597 return trimmed;2598}25992600function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2601 if (!Array.isArray(values)) return undefined;2602 const seen = new Set<string>();2603 const cleaned: string[] = [];2604 for (const value of values) {2605 const trimmed = typeof value === "string" ? value.trim() : "";2606 if (!trimmed) continue;2607 if (!allowHttpUrl(trimmed)) continue;2608 const key = trimmed.toLowerCase();2609 if (seen.has(key)) continue;2610 seen.add(key);2611 cleaned.push(trimmed);2612 }2613 return cleaned.length > 0 ? cleaned : undefined;2614}26152616function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2617 const title = entry.title.trim();2618 const type: VaultEntry["type"] = entry.type ?? "login";26192620 if (!title) throw new Error("title is required");26212622 if (entry.passkey) {2623 const p = entry.passkey;2624 if (2625 !p.rpId ||2626 !p.credentialId ||2627 !p.publicKeyCose ||2628 !p.userHandle ||2629 !p.userName ||2630 (p.algorithm !== -7 && p.algorithm !== -8)2631 ) {2632 throw new Error("invalid passkey credential");2633 }2634 }2635 if (type === "card") {2636 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2637 throw new Error("cardholder name and card number are required");2638 }2639 }26402641 return {2642 type,2643 title,2644 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2645 email: type === "login" ? entry.email?.trim() || undefined : undefined,2646 password: type === "login" ? entry.password : undefined,2647 url: type === "login" ? allowHttpUrl(entry.url) : undefined,2648 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2649 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2650 2651 2652 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2653 totpDigits: type === "login" ? entry.totpDigits : undefined,2654 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2655 notes: entry.notes?.trim() || undefined,2656 breach: type === "login" ? entry.breach : undefined,2657 2658 2659 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2660 tags: sanitizeTags(entry.tags),2661 passkey: entry.passkey,2662 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2663 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2664 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2665 expYear: type === "card" ? entry.expYear?.trim() : undefined,2666 cvc: type === "card" ? entry.cvc?.trim() : undefined,2667 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2668 network: type === "card" ? entry.network : undefined,2669 last4: type === "card" ? entry.last4?.trim() : undefined,2670 };2671}26722673function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2674 const nextPatch: Partial<VaultEntry> = {};26752676 if ("type" in patch) {2677 nextPatch.type = patch.type;2678 }26792680 if ("title" in patch) {2681 const title = patch.title?.trim() ?? "";2682 if (!title) throw new Error("title is required");2683 nextPatch.title = title;2684 }26852686 if ("url" in patch) {2687 nextPatch.url = allowHttpUrl(patch.url);2688 }26892690 if ("additionalUrls" in patch) {2691 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2692 const seen = new Set<string>();2693 const cleaned: string[] = [];2694 for (const value of incoming) {2695 const trimmed = typeof value === "string" ? value.trim() : "";2696 if (!trimmed || !allowHttpUrl(trimmed)) continue;2697 const key = trimmed.toLowerCase();2698 if (seen.has(key)) continue;2699 seen.add(key);2700 cleaned.push(trimmed);2701 }2702 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2703 }27042705 if ("username" in patch) {2706 nextPatch.username = patch.username?.trim() ?? "";2707 }27082709 if ("email" in patch) {2710 nextPatch.email = patch.email?.trim() || undefined;2711 }27122713 if ("password" in patch) {2714 const password = patch.password ?? "";2715 2716 nextPatch.password = password || undefined;2717 }27182719 if ("totp" in patch) {2720 nextPatch.totp = patch.totp?.trim() || undefined;2721 }27222723 if ("notes" in patch) {2724 nextPatch.notes = patch.notes?.trim() || undefined;2725 }27262727 if ("breach" in patch) {2728 nextPatch.breach = patch.breach;2729 }27302731 if ("canary" in patch) {2732 2733 2734 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2735 }27362737 if ("passkey" in patch) {2738 2739 2740 if (patch.passkey === undefined || patch.passkey === null) {2741 nextPatch.passkey = undefined;2742 } else {2743 const sanitized = sanitizePasskey(patch.passkey);2744 if (!sanitized) throw new Error("invalid passkey credential");2745 nextPatch.passkey = sanitized;2746 }2747 }27482749 if ("tags" in patch) {2750 2751 nextPatch.tags = sanitizeTags(patch.tags);2752 }27532754 if ("cardholderName" in patch) {2755 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2756 }2757 if ("cardNumber" in patch) {2758 const n = patch.cardNumber ?? "";2759 nextPatch.cardNumber = n || undefined;2760 }2761 if ("expMonth" in patch) {2762 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2763 }2764 if ("expYear" in patch) {2765 nextPatch.expYear = patch.expYear?.trim() || undefined;2766 }2767 if ("cvc" in patch) {2768 nextPatch.cvc = patch.cvc?.trim() || undefined;2769 }2770 if ("cardNickname" in patch) {2771 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2772 }2773 if ("network" in patch) {2774 nextPatch.network = patch.network;2775 }2776 if ("last4" in patch) {2777 nextPatch.last4 = patch.last4?.trim() || undefined;2778 }27792780 return nextPatch;2781}27822783function normalizeEntry(entry: VaultEntry): VaultEntry {2784 const base: VaultEntry = {2785 id: entry.id,2786 type: entry.type,2787 title: entry.title,2788 username: entry.username,2789 email: entry.email,2790 password: entry.password,2791 url: entry.url,2792 additionalUrls:2793 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2794 ? entry.additionalUrls2795 : undefined,2796 totp: entry.totp,2797 2798 totpAlgorithm: entry.totpAlgorithm,2799 totpDigits: entry.totpDigits,2800 totpPeriod: entry.totpPeriod,2801 notes: entry.notes,2802 breach: entry.breach,2803 2804 2805 2806 passwordChangedAt:2807 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2808 2809 2810 2811 passwordHistory:2812 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2813 ? entry.passwordHistory2814 : undefined,2815 canary: entry.canary,2816 passkey: entry.passkey,2817 createdAt: entry.createdAt,2818 modifiedAt: entry.modifiedAt,2819 deletedAt: entry.deletedAt,2820 tags: entry.tags,2821 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2822 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2823 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2824 expYear: "expYear" in entry ? entry.expYear : undefined,2825 cvc: "cvc" in entry ? entry.cvc : undefined,2826 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2827 network: "network" in entry ? entry.network : undefined,2828 last4: "last4" in entry ? entry.last4 : undefined,2829 };28302831 return base;2832}2833283428352836283728382839function entryDupeKey(e: VaultEntry): string {2840 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2841 2842 2843 2844 2845 if (e.passkey) {2846 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2847 }2848 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2849}285028512852export function countDuplicates(): number {2853 const best = new Map<string, VaultEntry>();2854 let dupeCount = 0;2855 for (const e of entries.filter((e) => !e.deletedAt)) {2856 const key = entryDupeKey(e);2857 const existing = best.get(key);2858 if (!existing) {2859 best.set(key, e);2860 } else {2861 dupeCount++;2862 2863 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2864 best.set(key, e);2865 }2866 }2867 }2868 return dupeCount;2869}287028712872287328742875export async function deduplicateEntries(): Promise<number> {2876 if (!derivedKey) throw new Error("vault_locked");2877 2878 2879 if (vaultMode === "decoy") {2880 throw new Error("deduplicate disabled in decoy mode");2881 }28822883 const best = new Map<string, VaultEntry>();2884 for (const e of entries.filter((en) => !en.deletedAt)) {2885 const key = entryDupeKey(e);2886 const existing = best.get(key);2887 if (!existing) {2888 best.set(key, e);2889 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2890 best.set(key, e);2891 }2892 }28932894 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2895 let removed = 0;2896 entries = entries.map((e) => {2897 if (!e.deletedAt && !keepIds.has(e.id)) {2898 removed++;2899 const deletedAt = monotonicEntryTimestamp(Math.max(2900 e.modifiedAt,2901 tombstones.get(e.id) ?? 0,2902 ));2903 tombstones.set(e.id, deletedAt);2904 return { ...e, deletedAt };2905 }2906 return e;2907 });29082909 if (removed > 0) await persistEntries();2910 return removed;2911}291229132914291529162917291829192920const HISTORY_KEY = "peach_vault_history";2921const HISTORY_MAX = 5;292229232924292529262927function bucketEntryCount(n: number): number {2928 if (n <= 0) return 0;2929 if (n <= 10) return 10;2930 if (n <= 25) return 25;2931 if (n <= 50) return 50;2932 if (n <= 100) return 100;2933 if (n <= 250) return 250;2934 if (n <= 500) return 500;2935 return Math.ceil(n / 500) * 500;2936}29372938293929402941294229432944export async function takeVaultSnapshot(2945 reason: VaultSnapshot["reason"],2946): Promise<void> {2947 try {2948 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2949 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2950 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];29512952 2953 2954 2955 if (!slotA) return;29562957 const vault_a = JSON.stringify(slotA);2958 const vault_b = JSON.stringify(slotB ?? slotA);29592960 2961 2962 2963 2964 2965 2966 let entryCount: number | null = null;2967 if (derivedKey && vaultMode === "real") {2968 try {2969 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);2970 } catch {2971 entryCount = null;2972 }2973 }29742975 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2976 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2977 ? existingRaw2978 : { snapshots: [] };29792980 2981 2982 2983 const latest = history.snapshots[0];2984 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2985 return;2986 }29872988 const snapshot: VaultSnapshot = {2989 id: crypto.randomUUID(),2990 savedAt: Date.now(),2991 reason,2992 vault_a,2993 vault_b,2994 entryCount,2995 };29962997 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2998 await chrome.storage.local.set({ [HISTORY_KEY]: history });2999 } catch (err) {3000 3001 3002 3003 3004 console.warn('[Peach] takeVaultSnapshot failed:', err);3005 if (String(err).includes('QUOTA') || String(err).includes('quota')) {3006 try {3007 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3008 } catch {3009 3010 }3011 }3012 }3013}301430153016export async function clearVaultHistory(): Promise<void> {3017 try {3018 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3019 } catch {3020 3021 }3022}302330243025export async function getVaultHistory(): Promise<VaultSnapshot[]> {3026 const raw = await chrome.storage.local.get(HISTORY_KEY);3027 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3028 if (!history || !Array.isArray(history.snapshots)) return [];3029 return history.snapshots;3030}30313032303330343035303630373038export async function restoreVaultSnapshot(3039 snapshotId: string,3040): Promise<{ ok: true } | { ok: false; error: string }> {3041 const history = await getVaultHistory();3042 const snapshot = history.find((s) => s.id === snapshotId);3043 if (!snapshot) return { ok: false, error: "snapshot_not_found" };30443045 3046 3047 3048 await takeVaultSnapshot("restore");30493050 let restoredA: StoredVault;3051 let restoredB: StoredVault;3052 try {3053 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3054 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3055 } catch {3056 return { ok: false, error: "snapshot_corrupt" };3057 }30583059 await writeSlot(SLOT_A, restoredA);3060 await writeSlot(SLOT_B, restoredB);3061 await stampLocalVaultMutation();30623063 3064 3065 3066 lock();30673068 return { ok: true };3069}307030713072307330743075307630773078307930803081308230833084export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3085 if (!derivedKey || vaultMode !== "real") {3086 return { ok: false, error: "vault_locked" };3087 }3088 const slotA = await loadSlot(SLOT_A);3089 if (!slotA) return { ok: false, error: "missing_slot_a" };30903091 3092 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3093 await generateAndWrapRootKey();30943095 3096 const newRootKey = unhex(rawRootKey);3097 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);30983099 3100 3101 3102 3103 await takeVaultSnapshot("local_save");3104 const nextSlotA: StoredVault = {3105 ...slotA,3106 recovery_salt: recoverySalt,3107 recovery_wrapped_root_key: wrappedRootKey,3108 recovery_nonce: wrappedNonce,3109 recovery_tag: wrappedTag,3110 ...pwRoot,3111 };3112 await writeSlot(SLOT_A, nextSlotA);31133114 3115 if (rootKey) zeroKey(rootKey);3116 rootKey = newRootKey;31173118 3119 await clearPairedDevices();31203121 3122 await setRecoveryConfirmPending(); 31233124 await stampLocalVaultMutation();3125 return { ok: true };3126}31273128312931303131313231333134export async function changePassword(3135 currentPassword: string,3136 newPassword: string3137): Promise<boolean> {3138 if (!derivedKey || vaultMode !== "real") return false;31393140 3141 const valid = await verifyMainPassword(currentPassword);3142 if (!valid) return false;31433144 const slotA = await loadSlot(SLOT_A);3145 if (!slotA) return false;3146 if (!rootKey) return false;31473148 3149 const newSalt = generateSalt();3150 const newKey = await deriveKey(newPassword, newSalt);31513152 3153 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3154 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");31553156 3157 3158 3159 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);31603161 const finalSlotA: StoredVault = {3162 ...slotA,3163 salt: hex(newSalt),3164 nonce,3165 ciphertext,3166 tag,3167 ...pwRoot,3168 };31693170 3171 3172 3173 await takeVaultSnapshot("local_save");31743175 3176 3177 3178 3179 await writeSlot(SLOT_A, finalSlotA);31803181 3182 3183 3184 zeroKey(derivedKey);3185 derivedKey = newKey;31863187 3188 3189 3190 3191 void saveSessionKey(newKey, "real");31923193 3194 3195 3196 if (!duressConfigured) {3197 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3198 }3199 await clearPinUnlockStorage();3200 await stampLocalVaultMutation();32013202 return true;3203}