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";42import {43 clearPairSecrets,44 dropSessionCache,45 reconcilePairSecrets,46 setSessionAllowed,47} from "./pair-secrets";4849type EntryDraft = Omit<50 VaultEntry,51 "id" | "createdAt" | "modifiedAt" | "deletedAt"52>;535455565758const SLOT_A = "peach_vault_a";59const SLOT_B = "peach_vault_b";60616263const PAD_BLOCK = 1024;6465666768const PASSWORD_HISTORY_MAX = 50;6970717273747576777879const DURESS_SLOT_SIZE = 262_144;8081let derivedKey: Uint8Array | null = null;8283848586let rootKey: Uint8Array | null = null;87let entries: VaultEntry[] = [];88899091let tombstones = new Map<string, number>();92let domainIndex: Map<string, VaultEntry[]> = new Map();93let vaultMode: "real" | "decoy" = "real";94959697let duressConfigured = false;9899100101102103104105106107108let lastUnlockFailureReason: string | null = null;109110export function consumeLastUnlockFailureReason(): string | null {111 const r = lastUnlockFailureReason;112 lastUnlockFailureReason = null;113 return r;114}115116117118119120export type TextSize = "small" | "medium" | "large";121122export interface VaultSettings {123 accentColor?: AccentColorName;124 theme?: "dark" | "light";125 autoLockMinutes?: number;126 clipboardClearSeconds?: number;127 lockOnClose?: boolean;128 breachMonitoring?: boolean;129 passkeyProviderEnabled?: boolean;130 trashRetentionDays?: number;131 manualSummonDomains?: Record<string, ManualSummonRecord>;132 manualSummonSalt?: string;133 declinedCardFingerprints?: string[];134135 136 metadataVersion?: 1;137 138139140141142 textSize?: TextSize;143 144145146147148 reduceMotion?: boolean;149 150 pinEnabled?: boolean;151 152 pinPreferred?: boolean;153 154155156157158159160 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;161}162163interface StoredPinWrap {164 version: 1;165 salt: string;166 nonce: string;167 ciphertext: string;168 tag: string;169}170171const PIN_WRAP_STORAGE = "peach_pin_wrap";172const PIN_ATTEMPTS_STORAGE = "peach_pin_attempts";173const PIN_MIN_LENGTH = 4;174const PIN_MAX_LENGTH = 12;175const PIN_MAX_ATTEMPTS = 3;176177let settings: VaultSettings = {};178const CARD_DECLINE_MAX = 256;179180const CARD_DECLINE_DIGEST_HEX = 32;181182async function metadataDigest(value: string, key: string): Promise<string> {183 const bytes = new TextEncoder().encode(`${key}:${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 resetPinFailedAttempts();287 return true;288 } catch {289 await recordPinFailure(failedAttempts + 1);290 return false;291 } finally {292 zeroKey(pinKey);293 if (unwrappedKey) zeroKey(unwrappedKey);294 }295}296297async function savePinWrap(stored: StoredPinWrap): Promise<void> {298 inMemoryPinWrap = stored;299 inMemoryPinFailedAttempts = 0;300 sessionPinStorageFailed = !chrome.storage.session;301 if (!chrome.storage.session) return;302 try {303 await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 });304 sessionPinStorageFailed = false;305 } catch {306 307 sessionPinStorageFailed = true;308 }309}310311async function loadPinState(): Promise<{312 stored: StoredPinWrap | undefined;313 failedAttempts: number;314}> {315 if (!chrome.storage.session) {316 return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };317 }318 try {319 const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);320 return {321 stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ??322 (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined),323 failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number"324 ? result[PIN_ATTEMPTS_STORAGE]325 : 0,326 };327 } catch {328 return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };329 }330}331332async function recordPinFailure(failedAttempts: number): Promise<void> {333 inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS);334 if (!chrome.storage.session) return;335 try {336 await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts });337 } catch { }338}339340async function resetPinFailedAttempts(): Promise<void> {341 inMemoryPinFailedAttempts = 0;342 if (!chrome.storage.session) return;343 try {344 await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 });345 } catch { }346}347348export async function getPinUnlockStatus(): Promise<{349 configured: boolean;350 available: boolean;351 attemptsRemaining: number;352}> {353 await loadSettings();354 if (!settings.pinEnabled) {355 return { configured: false, available: false, attemptsRemaining: 0 };356 }357 const { stored, failedAttempts } = await loadPinState();358 const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS;359 return {360 configured: true,361 available,362 attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0,363 };364}365366async function persistSettings(): Promise<void> {367 try {368 await chrome.storage.local.set({369 peach_settings: {370 ...settings,371 manualSummonDomains: undefined,372 manualSummonSalt: undefined,373 cardFingerprintSalt: undefined,374 declinedCardFingerprints: settings.declinedCardFingerprints ?? [],375 metadataVersion: 1,376 },377 });378 } catch (err) {379 throw mapStorageError(err);380 }381}382383function mapStorageError(err: unknown): Error {384 const message = err instanceof Error ? err.message : String(err);385 if (/QUOTA_BYTES|quota/i.test(message)) {386 return new Error("STORAGE_QUOTA_EXCEEDED");387 }388 return err instanceof Error ? err : new Error(message);389}390391async function loadSettings(): Promise<void> {392 const result = await chrome.storage.local.get("peach_settings");393 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};394}395396397398function fireSyncTrigger(reason: "vault_change"): void {399 void Promise.resolve()400 .then(() => triggerSyncIfReachable({ reason }))401 .catch(() => {402 403 });404}405406let lockedSyncRefreshHook: (() => Promise<void>) | null = null;407let lockedSyncUnlockHook: (() => Promise<void>) | null = null;408409export function setLockedSyncHooks(hooks: {410 refresh: () => Promise<void>;411 afterUnlock: () => Promise<void>;412}): void {413 lockedSyncRefreshHook = hooks.refresh;414 lockedSyncUnlockHook = hooks.afterUnlock;415}416417async function refreshLockedSyncState(): Promise<void> {418 if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;419 await lockedSyncRefreshHook().catch((error) => {420 console.warn("[Peach sync] locked spool refresh failed:", error);421 });422}423424425426427428export async function checkBreach(password: string): Promise<number> {429 430 431 const encoder = new TextEncoder();432 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));433 const hashHex = Array.from(new Uint8Array(hashBuffer))434 .map((b) => b.toString(16).padStart(2, "0"))435 .join("")436 .toUpperCase();437438 const prefix = hashHex.slice(0, 5);439 const suffix = hashHex.slice(5);440441 try {442 443 444 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {445 headers: { "Add-Padding": "true" },446 });447 if (!res.ok) return 0;448 const text = await res.text();449 const match = text.split("\r\n").find((l) => l.startsWith(suffix));450 return match ? parseInt(match.split(":")[1], 10) : 0;451 } catch {452 return 0; 453 }454}455456export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {457 458 const key = await cardDeclineKey();459 if (!key) return;460 const fingerprint = await cardFingerprint(last4, expMonth, expYear);461 await loadSettings();462 settings = {463 ...settings,464 declinedCardFingerprints: [465 466 467 ...(settings.declinedCardFingerprints ?? []).filter(468 (stored) => stored.length === CARD_DECLINE_DIGEST_HEX,469 ),470 await metadataDigest(fingerprint, key),471 ].slice(-CARD_DECLINE_MAX),472 };473 await persistSettings();474}475476export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {477 await loadSettings();478 const key = await cardDeclineKey();479 if (!key) return false;480 const fingerprint = await cardFingerprint(last4, expMonth, expYear);481 const candidate = await metadataDigest(fingerprint, key);482 return (settings.declinedCardFingerprints ?? []).some(483 (stored) => stored.length === CARD_DECLINE_DIGEST_HEX && stored === candidate,484 );485}486487488489490491492493494495496497498async function cardDeclineKey(): Promise<string | null> {499 const root = getRootKeyHex();500 if (!root) return null;501 const digest = new Uint8Array(502 await crypto.subtle.digest(503 "SHA-256",504 new TextEncoder().encode(`peach-card-decline-v1:${root}`),505 ),506 );507 return hex(digest);508}509510async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {511 const encoder = new TextEncoder();512 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));513 return Array.from(new Uint8Array(buf).slice(0, 8))514 .map((b) => b.toString(16).padStart(2, "0"))515 .join("");516}517518function hex(buf: Uint8Array): string {519 return Array.from(buf)520 .map((b) => b.toString(16).padStart(2, "0"))521 .join("");522}523524function unhex(s: string): Uint8Array {525 const out = new Uint8Array(s.length / 2);526 for (let i = 0; i < s.length; i += 2) {527 out[i / 2] = parseInt(s.slice(i, i + 2), 16);528 }529 return out;530}531532533534535536537538539540541542543544545546547548549550551async function generateAndWrapRootKey(): Promise<{552 rawRootKey: string; 553 recoverySalt: string;554 wrappedRootKey: string;555 wrappedNonce: string;556 wrappedTag: string;557}> {558 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));559560 561 const words = await rootKeyToWords(rootKeyBytes);562 const wordMaterial = await wordsToRootKeyMaterial(words);563 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));564 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);565566 567 568 const nonce = crypto.getRandomValues(new Uint8Array(12));569 const encoded = new TextEncoder().encode(hex(rootKeyBytes));570 const raw = await crypto.subtle.encrypt(571 {572 name: "AES-GCM",573 iv: nonce,574 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,575 tagLength: 128,576 },577 recoveryKey,578 encoded579 );580 581 const rawU8 = new Uint8Array(raw);582 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));583 const tag = hex(rawU8.slice(rawU8.length - 16));584585 return {586 rawRootKey: hex(rootKeyBytes),587 recoverySalt: hex(recoverySalt),588 wrappedRootKey: ciphertext,589 wrappedNonce: hex(nonce),590 wrappedTag: tag,591 };592}593594async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{595 recoverySalt: string;596 wrappedRootKey: string;597 wrappedNonce: string;598 wrappedTag: string;599}> {600 if (rootKeyBytes.length !== 32) {601 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");602 }603604 const words = await rootKeyToWords(rootKeyBytes);605 const wordMaterial = await wordsToRootKeyMaterial(words);606 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));607 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);608 const nonce = crypto.getRandomValues(new Uint8Array(12));609 const encoded = new TextEncoder().encode(hex(rootKeyBytes));610 const raw = await crypto.subtle.encrypt(611 {612 name: "AES-GCM",613 iv: nonce,614 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,615 tagLength: 128,616 },617 recoveryKey,618 encoded,619 );620 const rawU8 = new Uint8Array(raw);621 return {622 recoverySalt: hex(recoverySalt),623 wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),624 wrappedNonce: hex(nonce),625 wrappedTag: hex(rawU8.slice(rawU8.length - 16)),626 };627}628629630631632633634export async function unwrapRootKeyForRecovery(635 wrappedRootKey: string,636 wrappedNonce: string,637 wrappedTag: string,638 recoverySaltHex: string,639 words: string[]640): Promise<void> {641 const wordMaterial = await wordsToRootKeyMaterial(words);642 const recoverySalt = unhex(recoverySaltHex);643 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);644645 646 647 648 649 650 const nonceRaw = unhex(wrappedNonce);651 const ciphertextRaw = unhex(wrappedRootKey);652 const tagRaw = unhex(wrappedTag);653 654 655 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;656 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);657 combined.set(ciphertextRaw, 0);658 combined.set(tagRaw, ciphertextRaw.length);659 const raw = await crypto.subtle.decrypt(660 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },661 recoveryKey,662 combined663 );664 const decryptedHex = new TextDecoder().decode(raw);665 rootKey = unhex(decryptedHex);666}667668669670671672673674675676677export async function rewrapRootKeyForPassword(678 newDerivedKey: Uint8Array,679): Promise<void> {680 if (!rootKey) throw new Error("root key not set");681 const slotA = await loadSlot(SLOT_A);682 if (!slotA) throw new Error("missing slot A");683 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);684 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });685}686687688689690691692async function reencryptVaultForNewKey(693 newKey: Uint8Array,694 slotA: StoredVault695): Promise<void> {696 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);697 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");698 await writeSlot(SLOT_A, {699 ...slotA,700 nonce,701 ciphertext,702 tag,703 });704}705706707708709710711export async function changePasswordWithNewKey(712 newSalt: Uint8Array,713 newKey: Uint8Array714): Promise<void> {715 const epoch = vaultEpoch;716 const throwIfStale = () => {717 if (epoch === vaultEpoch) return;718 zeroKey(newKey);719 throw new Error("vault_locked");720 };721 const slotA = await loadSlot(SLOT_A);722 throwIfStale();723 if (!slotA) throw new Error("missing slot A");724 725 726 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });727 throwIfStale();728 if (derivedKey) zeroKey(derivedKey);729 derivedKey = newKey;730 731 732 733 await saveSessionKey(newKey, "real", epoch);734 throwIfStale();735 736 737 738 739 if (rootKey) {740 const slotAfter = await loadSlot(SLOT_A);741 throwIfStale();742 if (slotAfter) {743 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);744 throwIfStale();745 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });746 throwIfStale();747 }748 }749 if (!duressConfigured) {750 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));751 throwIfStale();752 }753 await clearPinUnlockStorage();754 throwIfStale();755 await stampLocalVaultMutation();756 throwIfStale();757}758759760761762763764export { deriveKey, generateSalt };765766export async function loadSlotA(): Promise<StoredVault | null> {767 return loadSlot(SLOT_A);768}769770771772773774775776777778779async function deriveRootKeyFromDerivedKey(780 derivedKeyBytes: Uint8Array,781 slotA: StoredVault,782): Promise<Uint8Array | null> {783 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {784 return null;785 }786 try {787 const rootHex = await decrypt(788 derivedKeyBytes,789 slotA.pw_root_nonce,790 slotA.pw_root_ciphertext,791 slotA.pw_root_tag,792 "pw_root_v1",793 );794 return unhex(rootHex);795 } catch {796 return null;797 }798}799800801802async function wrapRootKeyForDerivedKey(803 derivedKeyBytes: Uint8Array,804 rootKeyBytes: Uint8Array,805): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {806 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");807 return {808 pw_root_ciphertext: ciphertext,809 pw_root_nonce: nonce,810 pw_root_tag: tag,811 };812}813814815export async function getRecoveryWords(): Promise<string[]> {816 if (!rootKey) {817 throw new Error("recovery not set up");818 }819 return rootKeyToWords(rootKey);820}821822async function loadSlot(key: string): Promise<StoredVault | null> {823 const result = await chrome.storage.local.get(key);824 return (result as Record<string, StoredVault | undefined>)[key] ?? null;825}826827async function writeSlot(key: string, vault: StoredVault): Promise<void> {828 try {829 await chrome.storage.local.set({ [key]: vault });830 } catch (err) {831 throw mapStorageError(err);832 }833}834835836837838839840841842843844export async function rollbackAdoptedVault(): Promise<void> {845 await lock();846 try {847 await chrome.storage.local.remove([848 SLOT_A,849 SLOT_B,850 "peach_last_modified_at",851 ]);852 } catch (err) {853 throw mapStorageError(err);854 }855}856857async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {858 const raw = await chrome.storage.local.get("peach_last_modified_at");859 const previous = Number(860 (raw as Record<string, unknown>).peach_last_modified_at,861 );862 const next = Math.max(863 Date.now(),864 Number.isFinite(previous) ? previous + 1 : 0,865 );866 try {867 await chrome.storage.local.set({868 [SLOT_A]: slotA,869 [SLOT_B]: slotB,870 peach_last_modified_at: String(next),871 });872 } catch (err) {873 throw mapStorageError(err);874 }875}876877878879880881882883884let lastEntryTimestamp = 0;885function monotonicEntryTimestamp(currentModifiedAt?: number): number {886 const base = Math.max(887 Date.now(),888 (currentModifiedAt ?? 0) + 1,889 lastEntryTimestamp + 1,890 );891 lastEntryTimestamp = base;892 return base;893}894895async function stampLocalVaultMutation(): Promise<void> {896 const raw = await chrome.storage.local.get("peach_last_modified_at");897 const previous = Number(898 (raw as Record<string, unknown>).peach_last_modified_at,899 );900 const next = Math.max(901 Date.now(),902 Number.isFinite(previous) ? previous + 1 : 0,903 );904 await chrome.storage.local.set({905 peach_last_modified_at: String(next),906 });907}908909910911912913914915916917918919function padPlaintext(json: string, minBytes = 0): string {920 const encoded = new TextEncoder().encode(json);921 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);922 923 const target = Math.max(bucket, minBytes);924 const padLen = target - encoded.length;925 return json + " ".repeat(padLen);926}927928929930931932933934935936937938const CSPRNG_CHUNK = 65_536;939940function fillRandom(buf: Uint8Array): Uint8Array {941 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {942 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);943 const chunk = new Uint8Array(end - offset);944 crypto.getRandomValues(chunk);945 buf.set(chunk, offset);946 }947 return buf;948}949950function generateNoiseBlob(ciphertextBytes: number): StoredVault {951 const salt = crypto.getRandomValues(new Uint8Array(16));952 const nonce = crypto.getRandomValues(new Uint8Array(12));953 const cipher = fillRandom(new Uint8Array(ciphertextBytes));954 const tag = crypto.getRandomValues(new Uint8Array(16));955 return {956 version: 1,957 salt: hex(salt),958 nonce: hex(nonce),959 ciphertext: hex(cipher),960 tag: hex(tag),961 };962}963964965966967968type VaultPayload = {969 entries: VaultEntry[];970 tombstones?: Record<string, number>;971 duressConfigured?: boolean;972};973974type DecryptedPayload = {975 entries: VaultEntry[];976 tombstones: Map<string, number>;977 duressConfigured: boolean;978};979980function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {981 const envelope: VaultPayload = {982 entries,983 tombstones: Object.fromEntries(tombstones),984 duressConfigured: duress,985 };986 return padPlaintext(JSON.stringify(envelope), minBytes);987}988989function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {990 const result = new Map<string, number>();991 if (raw && typeof raw === "object" && !Array.isArray(raw)) {992 for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {993 if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {994 result.set(id, timestamp);995 }996 }997 }998 999 1000 for (const entry of allEntries) {1001 if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {1002 continue;1003 }1004 if ((result.get(entry.id) ?? 0) < entry.deletedAt) {1005 result.set(entry.id, entry.deletedAt);1006 }1007 }1008 return result;1009}10101011function reconcileTombstones(): void {1012 const byId = new Map(entries.map((entry) => [entry.id, entry]));1013 for (const [id] of tombstones) {1014 if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);1015 }1016 for (const entry of entries) {1017 if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {1018 if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {1019 tombstones.set(entry.id, entry.deletedAt);1020 }1021 }1022 }1023}10241025function decodePayload(plain: string): DecryptedPayload {1026 const parsed = JSON.parse(plain) as VaultPayload;1027 if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {1028 throw new Error("invalid vault payload");1029 }1030 const normalized = parsed.entries.map(normalizeEntry);1031 return {1032 entries: normalized,1033 tombstones: decodeTombstones(parsed.tombstones, normalized),1034 duressConfigured: parsed.duressConfigured === true,1035 };1036}1037103810391040104110421043104410451046104710481049105010511052105310541055105610571058function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {1059 let merged = false;1060 const now = Date.now();10611062 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);1063 const byRpId = new Map<string, VaultEntry[]>();1064 for (const e of candidates) {1065 const rpId = e.passkey!.rpId;1066 const list = byRpId.get(rpId);1067 if (list) list.push(e);1068 else byRpId.set(rpId, [e]);1069 }10701071 for (const [rpId, group] of byRpId) {1072 if (group.length === 0) continue;1073 group.sort((a, b) => {1074 const aT =1075 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;1076 const bT =1077 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;1078 return bT - aT;1079 });1080 const winner = group[0]!;10811082 1083 1084 1085 const loginTarget = entries.find((e) => {1086 if (e.deletedAt || e.passkey || e.type !== "login") return false;1087 if (e.url && extractDomain(e.url) === rpId) return true;1088 for (const extra of e.additionalUrls ?? []) {1089 if (extractDomain(extra) === rpId) return true;1090 }1091 return false;1092 });10931094 if (loginTarget) {1095 loginTarget.passkey = winner.passkey;1096 1097 1098 1099 if (!loginTarget.totp && winner.totp) {1100 loginTarget.totp = winner.totp;1101 loginTarget.totpAlgorithm = winner.totpAlgorithm;1102 loginTarget.totpDigits = winner.totpDigits;1103 loginTarget.totpPeriod = winner.totpPeriod;1104 }1105 loginTarget.modifiedAt = now;1106 for (const dup of group) {1107 if (!dup.deletedAt) {1108 dup.deletedAt = now;1109 dup.modifiedAt = now;1110 }1111 }1112 merged = true;1113 } else if (group.length > 1) {1114 for (let i = 1; i < group.length; i += 1) {1115 const dup = group[i]!;1116 if (!dup.deletedAt) {1117 dup.deletedAt = now;1118 dup.modifiedAt = now;1119 }1120 }1121 merged = true;1122 }1123 }11241125 return { merged };1126}11271128112911301131113211331134function hasValidVaultSchema(v: unknown): v is StoredVault {1135 if (typeof v !== "object" || v === null) return false;1136 const r = v as Record<string, unknown>;1137 return (1138 r.version === 1 &&1139 typeof r.salt === "string" &&1140 typeof r.nonce === "string" &&1141 typeof r.ciphertext === "string" &&1142 typeof r.tag === "string"1143 );1144}11451146export type TryDecryptResult =1147 | { status: "ok"; payload: DecryptedPayload }1148 | { status: "wrong_key" }1149 | { status: "corrupted"; reason: string };1150115111521153115411551156export async function tryDecryptSlot(1157 vault: StoredVault,1158 key: Uint8Array,1159 aadTag?: string,1160): Promise<DecryptedPayload | null> {1161 const res = await tryDecryptSlotResult(vault, key, aadTag);1162 return res.status === "ok" ? res.payload : null;1163}11641165export async function tryDecryptSlotResult(1166 vault: StoredVault,1167 key: Uint8Array,1168 aadTag?: string,1169): Promise<TryDecryptResult> {1170 if (!hasValidVaultSchema(vault)) {1171 return { status: "corrupted", reason: "invalid_slot_schema" };1172 }1173 let plain: string;1174 try {1175 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1176 } catch {1177 return { status: "wrong_key" };1178 }1179 try {1180 const parsed = JSON.parse(plain) as unknown;1181 if (typeof parsed !== "object" || parsed === null) {1182 return { status: "corrupted", reason: "invalid_payload_shape" };1183 }1184 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1185 return { status: "corrupted", reason: "invalid_payload_shape" };1186 }1187 return { status: "ok", payload: decodePayload(plain) };1188 } catch {1189 return { status: "corrupted", reason: "invalid_payload_json" };1190 }1191}119211931194119511961197function buildDomainIndexSync(all: VaultEntry[]): void {1198 domainIndex = new Map();11991200 for (const entry of all) {1201 if (entry.deletedAt) continue;1202 if (entry.type === "note" || entry.type === "card") continue;12031204 1205 1206 1207 1208 const domains = new Set<string>();1209 const primary = extractDomain(entry.url ?? "");1210 if (primary) domains.add(primary);1211 for (const extra of entry.additionalUrls ?? []) {1212 const d = extractDomain(extra);1213 if (d) domains.add(d);1214 }1215 if (domains.size === 0) continue;12161217 for (const domain of domains) {1218 const bucket = domainIndex.get(domain) ?? [];1219 bucket.push(entry);1220 domainIndex.set(domain, bucket);1221 }1222 }1223}12241225export async function createVault(password: string): Promise<void> {1226 const salt = generateSalt();1227 derivedKey = await deriveKey(password, salt);1228 entries = [];1229 tombstones = new Map();1230 vaultMode = "real";1231 duressConfigured = false;1232 buildDomainIndexSync(entries);12331234 1235 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1236 await generateAndWrapRootKey();1237 rootKey = unhex(rawRootKey);12381239 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1240 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");12411242 1243 1244 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);12451246 const slotA: StoredVault = {1247 version: 1,1248 salt: hex(salt),1249 nonce,1250 ciphertext,1251 tag,1252 recovery_salt: recoverySalt,1253 recovery_wrapped_root_key: wrappedRootKey,1254 recovery_nonce: wrappedNonce,1255 recovery_tag: wrappedTag,1256 ...pwRoot,1257 };1258 await writeSlot(SLOT_A, slotA);12591260 1261 1262 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1263 await stampLocalVaultMutation();12641265 touchAutoLock();1266}1267126812691270127112721273export async function createVaultFromRootKey(1274 password: string,1275 rootKeyHex: string,1276): Promise<{ vaultId: string }> {1277 if (typeof password !== "string" || password.length === 0) {1278 throw new Error("ADOPT_INVALID_PASSWORD");1279 }1280 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1281 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1282 }12831284 const rootKeyBytes = unhex(rootKeyHex);1285 if (rootKeyBytes.length !== 32) {1286 rootKeyBytes.fill(0);1287 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1288 }12891290 let nextDerivedKey: Uint8Array | null = null;1291 let sessionRootKey: Uint8Array | null = null;1292 try {1293 if ((await loadSlot(SLOT_A)) !== null) {1294 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1295 }12961297 const nextEntries: VaultEntry[] = [];1298 const nextDuressConfigured = false;1299 const vaultId = await computeVaultId(rootKeyBytes);1300 const salt = generateSalt();1301 nextDerivedKey = await deriveKey(password, salt);1302 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);13031304 sessionRootKey = new Uint8Array(rootKeyBytes);1305 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1306 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1307 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1308 const slotA: StoredVault = {1309 version: 1,1310 salt: hex(salt),1311 nonce,1312 ciphertext,1313 tag,1314 recovery_salt: recovery.recoverySalt,1315 recovery_wrapped_root_key: recovery.wrappedRootKey,1316 recovery_nonce: recovery.wrappedNonce,1317 recovery_tag: recovery.wrappedTag,1318 ...pwRoot,1319 };1320 const slotB = generateNoiseBlob(ciphertext.length / 2);1321 await commitCreatedVaultSlots(slotA, slotB);13221323 const previousDerivedKey = derivedKey;1324 const previousRootKey = rootKey;1325 derivedKey = nextDerivedKey;1326 rootKey = sessionRootKey;1327 nextDerivedKey = null;1328 sessionRootKey = null;1329 entries = nextEntries;1330 tombstones = new Map();1331 vaultMode = "real";1332 duressConfigured = nextDuressConfigured;1333 buildDomainIndexSync(entries);1334 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1335 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1336 touchAutoLock();13371338 return { vaultId };1339 } finally {1340 rootKeyBytes.fill(0);1341 if (nextDerivedKey) zeroKey(nextDerivedKey);1342 if (sessionRootKey) sessionRootKey.fill(0);1343 }1344}13451346export async function hasVault(): Promise<boolean> {1347 return (await loadSlot(SLOT_A)) !== null;1348}1349135013511352135313541355export async function isDuressEnabled(): Promise<boolean> {1356 return vaultMode === "real" && duressConfigured;1357}13581359export async function setupDuress(duressPassword: string): Promise<void> {1360 if (!derivedKey || vaultMode !== "real") {1361 throw new Error("vault must be unlocked in real mode");1362 }1363 1364 1365 1366 const slotA = await loadSlot(SLOT_A);1367 if (!slotA) throw new Error("missing slot A");13681369 const decoySalt = generateSalt();1370 const decoyKey = await deriveKey(duressPassword, decoySalt);1371 try {1372 1373 1374 1375 1376 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1377 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1378 await writeSlot(SLOT_B, {1379 version: 1,1380 salt: hex(decoySalt),1381 nonce,1382 ciphertext,1383 tag,1384 });1385 } finally {1386 zeroKey(decoyKey);1387 }13881389 1390 1391 duressConfigured = true;1392 await persistSlotA();1393}13941395export async function disableDuress(): Promise<void> {1396 if (!derivedKey || vaultMode !== "real") {1397 throw new Error("vault must be unlocked in real mode");1398 }1399 1400 1401 1402 const slotA = await loadSlot(SLOT_A);1403 if (slotA) {1404 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1405 }14061407 duressConfigured = false;1408 await persistSlotA();1409}14101411141214131414141514161417export async function verifyMainPassword(password: string): Promise<boolean> {1418 const slotA = await loadSlot(SLOT_A);1419 if (!slotA) return false;1420 const key = await deriveKey(password, unhex(slotA.salt));1421 try {1422 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1423 } finally {1424 zeroKey(key);1425 }1426}14271428export async function unlock(password: string): Promise<boolean> {1429 return attemptUnlockWithDerive(password, deriveKey);1430}14311432async function attemptUnlockWithDerive(1433 password: string,1434 derive: typeof deriveKey,1435): Promise<boolean> {1436 const epoch = vaultEpoch;1437 const stale = () => epoch !== vaultEpoch;1438 1439 1440 1441 1442 1443 1444 const [slotA, slotB] = await Promise.all([1445 loadSlot(SLOT_A),1446 loadSlot(SLOT_B),1447 ]);14481449 1450 const [keyA, keyB] = await Promise.all([1451 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1452 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1453 ]);14541455 1456 1457 1458 const [resultA, resultB] = await Promise.all([1459 keyA && slotA1460 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1461 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1462 keyB && slotB1463 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1464 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1465 ]);1466 const payloadA = resultA.status === "ok" ? resultA.payload : null;1467 const payloadB = resultB.status === "ok" ? resultB.payload : null;14681469 if (stale()) {1470 if (keyA) zeroKey(keyA);1471 if (keyB) zeroKey(keyB);1472 return false;1473 }14741475 if (payloadA && keyA) {1476 if (stale()) {1477 zeroKey(keyA);1478 if (keyB) zeroKey(keyB);1479 return false;1480 }1481 derivedKey = keyA;1482 vaultMode = "real";1483 entries = payloadA.entries;1484 tombstones = payloadA.tombstones;1485 duressConfigured = payloadA.duressConfigured;1486 buildDomainIndexSync(entries);1487 await loadSettings();1488 if (stale()) {1489 await lock();1490 return false;1491 }1492 touchAutoLock();1493 if (keyB) zeroKey(keyB);14941495 1496 1497 rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1498 if (!rootKey) {1499 await lock();1500 return false;1501 }15021503 1504 1505 await reconcilePairSecrets(rootKey);1506 if (stale()) {1507 await lock();1508 return false;1509 }1510 await lockedSyncUnlockHook?.().catch((error) => {1511 console.warn("[Peach sync] locked inbox drain failed:", error);1512 });1513 if (stale()) {1514 await lock();1515 return false;1516 }1517 await resetPinFailedAttempts();1518 if (stale()) {1519 await lock();1520 return false;1521 }1522 await saveSessionKey(keyA, "real", epoch);1523 if (stale()) return false;1524 return true;1525 }15261527 if (payloadB && keyB) {1528 if (stale()) {1529 zeroKey(keyB);1530 if (keyA) zeroKey(keyA);1531 return false;1532 }1533 derivedKey = keyB;1534 vaultMode = "decoy";1535 entries = payloadB.entries;1536 tombstones = payloadB.tombstones;1537 duressConfigured = false;1538 buildDomainIndexSync(entries);1539 await loadSettings();1540 touchAutoLock();1541 if (keyA) zeroKey(keyA);1542 1543 await dropSessionCache();1544 if (stale()) {1545 await lock();1546 return false;1547 }1548 await saveSessionKey(keyB, "decoy", epoch);1549 if (stale()) return false;1550 return true;1551 }15521553 if (keyA) zeroKey(keyA);1554 if (keyB) zeroKey(keyB);1555 1556 1557 if (resultA.status === "corrupted") {1558 lastUnlockFailureReason = resultA.reason;1559 } else if (resultB.status === "corrupted") {1560 lastUnlockFailureReason = resultB.reason;1561 }1562 return false;1563}15641565156615671568156915701571const SESSION_KEY_STORAGE = "peach_session_dk";1572157315741575let sessionKeyMutation = Promise.resolve();15761577function mutateSessionKey(mutation: () => Promise<void>): Promise<void> {1578 const task = sessionKeyMutation.then(mutation, mutation);1579 sessionKeyMutation = task.catch(() => undefined);1580 return task;1581}158215831584158515861587let sessionEscrowAvailable = true;15881589159015911592159315941595export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1596 setSessionAllowed(sessionEscrow);1597 if (sessionEscrow) return;1598 sessionEscrowAvailable = false;1599 await clearSessionKey();1600}16011602async function saveSessionKey(1603 key: Uint8Array,1604 mode: "real" | "decoy",1605 expectedEpoch = vaultEpoch,1606): Promise<void> {1607 const session = chrome.storage.session;1608 if (!session || !sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1609 const stored = { keyHex: hex(key), mode };1610 await mutateSessionKey(async () => {1611 if (!sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1612 await session.set({ [SESSION_KEY_STORAGE]: stored });1613 });1614}16151616async function clearSessionKey(): Promise<void> {1617 const session = chrome.storage.session;1618 if (!session) return;1619 await mutateSessionKey(() => session.remove(SESSION_KEY_STORAGE));1620}16211622162316241625162616271628let vaultEpoch = 0;1629163016311632let restoreInFlight: Promise<boolean> | null = null;163316341635163616371638163916401641export async function restoreFromSession(): Promise<boolean> {1642 if (derivedKey !== null) return true;1643 if (restoreInFlight) return restoreInFlight;1644 const task = restoreFromSessionInner();1645 restoreInFlight = task;1646 try {1647 return await task;1648 } finally {1649 restoreInFlight = null;1650 }1651}16521653async function restoreFromSessionInner(): Promise<boolean> {1654 const epoch = vaultEpoch;1655 const stale = () => epoch !== vaultEpoch;1656 if (derivedKey !== null) return true;1657 if (!sessionEscrowAvailable) return false;1658 if (!chrome.storage.session) return false;1659 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1660 if (stale()) return false;1661 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1662 if (!entry?.keyHex) return false;16631664 const key = unhex(entry.keyHex);1665 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1666 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1667 const slot = await loadSlot(slotKey);1668 if (stale()) { zeroKey(key); return false; }1669 if (!slot) { await clearSessionKey(); return false; }16701671 const result = await tryDecryptSlotResult(slot, key, aadTag);1672 if (stale()) { zeroKey(key); return false; }1673 if (result.status !== "ok") { await clearSessionKey(); return false; }16741675 1676 1677 const payload = result.payload;1678 await loadSettings();1679 if (stale()) { zeroKey(key); return false; }16801681 1682 1683 let freshRoot: Uint8Array | null = null;1684 if (entry.mode === "real") {1685 freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1686 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1687 if (!freshRoot) {1688 await lock();1689 return false;1690 }1691 }16921693 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1694 if (freshRoot) {1695 1696 await reconcilePairSecrets(freshRoot);1697 if (stale()) { zeroKey(key); zeroKey(freshRoot); return false; }1698 } else {1699 1700 await dropSessionCache();1701 if (stale()) { zeroKey(key); return false; }1702 }1703 derivedKey = key;1704 vaultMode = entry.mode;1705 entries = payload.entries;1706 tombstones = payload.tombstones;1707 duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1708 if (freshRoot) rootKey = freshRoot;1709 buildDomainIndexSync(entries);1710 touchAutoLock();17111712 await lockedSyncUnlockHook?.().catch((error) => {1713 console.warn("[Peach sync] locked inbox drain failed:", error);1714 });1715 if (stale()) return false;17161717 return true;1718}17191720export async function lock(): Promise<void> {1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 vaultEpoch++;1731 if (derivedKey) {1732 zeroKey(derivedKey);1733 derivedKey = null;1734 }1735 if (rootKey) {1736 rootKey.fill(0);1737 rootKey = null;1738 }17391740 entries = [];1741 tombstones = new Map();1742 domainIndex = new Map();1743 vaultMode = "real";1744 duressConfigured = false;1745 clearAutoLock();1746 await clearSessionKey();1747 await clearRecoveryConfirmPending();1748}17491750export function isUnlocked(): boolean {1751 return derivedKey !== null;1752}17531754175517561757export async function unlockWithKey(1758 masterKeyHex: string,1759): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1760 const epoch = vaultEpoch;1761 const stale = () => epoch !== vaultEpoch;1762 let masterKeyBytes: Uint8Array | null = null;1763 try {1764 masterKeyBytes = unhex(masterKeyHex);1765 const stored = await loadSlot(SLOT_A);1766 if (!stored) return { ok: false, error: "invalid_key" };1767 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1768 if (!payload || stale()) {1769 zeroKey(masterKeyBytes);1770 return { ok: false, error: "invalid_key" };1771 }17721773 derivedKey = masterKeyBytes;1774 vaultMode = "real";1775 entries = payload.entries;1776 tombstones = payload.tombstones;1777 duressConfigured = payload.duressConfigured;1778 buildDomainIndexSync(entries);1779 await loadSettings();1780 touchAutoLock();17811782 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1783 if (!rootKey) {1784 await lock();1785 return { ok: false, error: "invalid_key" };1786 }1787 await reconcilePairSecrets(rootKey);1788 if (stale()) {1789 await lock();1790 return { ok: false, error: "invalid_key" };1791 }1792 await saveSessionKey(masterKeyBytes, "real", epoch);1793 if (stale()) return { ok: false, error: "invalid_key" };1794 return { ok: true, entries };1795 } catch {1796 if (masterKeyBytes && derivedKey !== masterKeyBytes) zeroKey(masterKeyBytes);1797 return { ok: false, error: "invalid_key" };1798 }1799}180018011802180318041805180618071808export async function exportDuressSlotForAdoption(): Promise<string | null> {1809 if (!(await isDuressEnabled())) return null;1810 const slotB = await loadSlot(SLOT_B);1811 if (!slotB) return null;1812 return JSON.stringify(slotB);1813}18141815181618171818181918201821export async function installAdoptedDuressSlot(json: string): Promise<void> {1822 let parsed: StoredVault;1823 try {1824 const raw = JSON.parse(json) as unknown;1825 if (!hasValidVaultSchema(raw)) {1826 throw new Error("invalid_duress_slot_shape");1827 }1828 parsed = raw;1829 } catch (err) {1830 if (err instanceof SyntaxError) {1831 throw new Error("invalid_duress_slot_shape");1832 }1833 throw err;1834 }1835 await writeSlot(SLOT_B, parsed);1836}18371838183918401841184218431844184518461847184818491850185118521853185418551856export async function unlockFromCodex(params: {1857 slotA: string; 1858 slotB: string | null; 1859 timestamp: number;1860 masterPassphrase: string;1861}): Promise<1862 | { ok: true; entryCount: number }1863 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1864> {1865 let parsedSlotA: StoredVault;1866 let parsedSlotB: StoredVault | null = null;1867 try {1868 const rawA = JSON.parse(params.slotA) as unknown;1869 if (!hasValidVaultSchema(rawA)) {1870 return { ok: false, reason: "invalid_slot_format" };1871 }1872 parsedSlotA = rawA;1873 if (params.slotB !== null) {1874 const rawB = JSON.parse(params.slotB) as unknown;1875 if (!hasValidVaultSchema(rawB)) {1876 return { ok: false, reason: "invalid_slot_format" };1877 }1878 parsedSlotB = rawB;1879 }1880 } catch {1881 return { ok: false, reason: "invalid_slot_format" };1882 }18831884 1885 1886 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1887 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1888 if (!trialPayload) {1889 zeroKey(trialKey);1890 return { ok: false, reason: "wrong_passphrase" };1891 }1892 1893 1894 zeroKey(trialKey);18951896 1897 1898 await takeVaultSnapshot("restore");18991900 await writeSlot(SLOT_A, parsedSlotA);1901 if (parsedSlotB) {1902 await writeSlot(SLOT_B, parsedSlotB);1903 } else {1904 1905 1906 1907 await writeSlot(1908 SLOT_B,1909 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1910 );1911 }1912 await chrome.storage.local.set({1913 peach_last_modified_at: String(params.timestamp),1914 });19151916 1917 1918 const ok = await unlock(params.masterPassphrase);1919 if (!ok) {1920 1921 1922 1923 return { ok: false, reason: "wrong_passphrase" };1924 }1925 return { ok: true, entryCount: getEntries().length };1926}19271928192919301931export function getDerivedKeyHex(): string {1932 if (!derivedKey) throw new Error("vault_not_unlocked");1933 return hex(derivedKey);1934}19351936193719381939194019411942194319441945194619471948194919501951export function getRootKeyHex(): string | null {1952 return rootKey ? hex(rootKey) : null;1953}19541955export function getEntries(): VaultEntry[] {1956 if (!derivedKey) throw new Error("vault_locked");1957 return entries.filter((entry) => !entry.deletedAt);1958}19591960const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;19611962export function getIdentities(): SavedIdentity[] {1963 if (!derivedKey) throw new Error("vault_locked");1964 const active = entries.filter((e) => !e.deletedAt && e.type === "login");19651966 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1967 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();19681969 for (const entry of active) {1970 const domain = extractDomain(entry.url ?? "") ?? "";1971 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;19721973 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1974 const existing = map.get(v);1975 if (!existing || modifiedAt > existing.lastUsed) {1976 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1977 } else {1978 map.set(v, { ...existing, count: existing.count + 1 });1979 }1980 }19811982 const uVal = entry.username?.trim();1983 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);19841985 1986 1987 const eVal = entry.email?.trim();1988 if (eVal) tally(emailMap, eVal);1989 }19901991 const toIdentity = (type: "email" | "username") =>1992 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1993 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1994 });19951996 return [1997 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1998 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1999 ];2000}20012002export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {2003 if (!derivedKey) throw new Error("vault_locked");20042005 const now = Date.now();2006 const added: VaultEntry[] = [];2007 for (const draft of drafts) {2008 try {2009 const sanitized = sanitizeEntryDraft(draft);2010 const nextEntry: VaultEntry = {2011 ...sanitized,2012 id: crypto.randomUUID(),2013 createdAt: now,2014 modifiedAt: monotonicEntryTimestamp(),2015 };2016 entries.push(nextEntry);2017 added.push(nextEntry);2018 } catch {2019 2020 }2021 }20222023 if (added.length > 0) {2024 await persistEntries();2025 }20262027 return added;2028}202920302031203220332034203520362037export async function restoreEntries(2038 incoming: VaultEntry[],2039 mode: "replace" | "merge"2040): Promise<{ added: number; updated: number; skipped: number }> {2041 if (!derivedKey) throw new Error("vault_locked");20422043 2044 2045 2046 if (mode === "replace" && incoming.length === 0) {2047 throw new Error("refuse to replace with empty entry set");2048 }20492050 let added = 0;2051 let updated = 0;2052 let skipped = 0;20532054 if (mode === "replace") {2055 entries = [];2056 for (const entry of incoming) {2057 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2058 if (!normalized) {2059 skipped += 1;2060 continue;2061 }2062 entries.push(normalized);2063 if (normalized.deletedAt) {2064 tombstones.set(2065 normalized.id,2066 Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),2067 );2068 } else {2069 tombstones.delete(normalized.id);2070 }2071 added += 1;2072 }2073 } else {2074 const byId = new Map<string, VaultEntry>();2075 for (const e of entries) byId.set(e.id, e);20762077 for (const entry of incoming) {2078 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2079 if (!normalized) {2080 skipped += 1;2081 continue;2082 }2083 const existing = byId.get(normalized.id);2084 if (!existing) {2085 entries.push(normalized);2086 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2087 else tombstones.delete(normalized.id);2088 added += 1;2089 } else if (normalized.modifiedAt > existing.modifiedAt) {2090 const idx = entries.findIndex((e) => e.id === existing.id);2091 if (idx !== -1) {2092 entries[idx] = normalized;2093 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2094 else tombstones.delete(normalized.id);2095 updated += 1;2096 }2097 } else {2098 skipped += 1;2099 }2100 }2101 }21022103 await persistEntries("import");2104 return { added, updated, skipped };2105}210621072108210921102111211221132114211521162117211821192120212121222123const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;21242125function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {2126 if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2127 return { ...entry, id: crypto.randomUUID() };2128}212921302131213221332134213521362137function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2138 if (!entry || typeof entry !== "object") return null;2139 const title = typeof entry.title === "string" ? entry.title.trim() : "";2140 if (!title) return null;2141 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2142 const now = Date.now();2143 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2144 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;21452146 2147 2148 const normalized = normalizeEntry({2149 ...entry,2150 id,2151 title,2152 type: entry.type ?? "login",2153 createdAt,2154 modifiedAt,2155 });21562157 if (normalized.passkey) {2158 normalized.passkey = sanitizePasskey(normalized.passkey);2159 }2160 if (normalized.canary) {2161 normalized.canary = sanitizeCanary(normalized.canary);2162 }2163 2164 normalized.tags = sanitizeTags(normalized.tags);21652166 2167 2168 if (2169 normalized.type === "login" &&2170 !normalized.passkey &&2171 typeof normalized.password !== "string"2172 ) {2173 return null;2174 }2175 if (entry.passkey && !normalized.passkey) {2176 2177 2178 return null;2179 }21802181 return normalized;2182}2183218421852186218721882189function sanitizePasskey(pk: unknown): PasskeyData | undefined {2190 if (!pk || typeof pk !== "object") return undefined;2191 const r = pk as Record<string, unknown>;2192 if (2193 typeof r.rpId !== "string" || !r.rpId ||2194 typeof r.credentialId !== "string" || !r.credentialId ||2195 typeof r.publicKeyCose !== "string" ||2196 typeof r.userHandle !== "string" ||2197 typeof r.userName !== "string"2198 ) {2199 return undefined;2200 }2201 return {2202 rpId: r.rpId,2203 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2204 credentialId: r.credentialId,2205 publicKeyCose: r.publicKeyCose,2206 privateKeyJwk:2207 r.privateKeyJwk && typeof r.privateKeyJwk === "object"2208 ? (r.privateKeyJwk as JsonWebKey)2209 : undefined,2210 userHandle: r.userHandle,2211 userName: r.userName,2212 userDisplayName:2213 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2214 algorithm: r.algorithm === -8 ? -8 : -7,2215 signCount: typeof r.signCount === "number" ? r.signCount : 0,2216 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2217 backupEligible: r.backupEligible === true,2218 backupState: r.backupState === true,2219 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2220 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2221 transports: Array.isArray(r.transports)2222 ? r.transports.filter((t): t is string => typeof t === "string")2223 : undefined,2224 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2225 };2226}22272228222922302231223222332234223522362237function sanitizeCanary(c: unknown): Canary | undefined {2238 if (!c || typeof c !== "object") return undefined;2239 const r = c as Record<string, unknown>;2240 if (typeof r.webhookUrl !== "string") return undefined;2241 let url: URL;2242 try {2243 url = new URL(r.webhookUrl);2244 } catch {2245 return undefined;2246 }2247 if (url.protocol !== "https:") return undefined;2248 return {2249 webhookUrl: url.toString(),2250 label: typeof r.label === "string" ? r.label : undefined,2251 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2252 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2253 };2254}22552256225722582259226022612262function sanitizeTags(tags: unknown): string[] | undefined {2263 if (!Array.isArray(tags)) return undefined;2264 const out: string[] = [];2265 for (const t of tags) {2266 if (typeof t !== "string") continue;2267 const trimmed = t.trim();2268 if (!trimmed) continue;2269 out.push(trimmed.slice(0, 256));2270 if (out.length >= 255) break;2271 }2272 return out.length > 0 ? out : undefined;2273}227422752276227722782279export async function clearVaultEntries(2280 confirmation: "REPLACE_ALL"2281): Promise<void> {2282 if (confirmation !== "REPLACE_ALL") {2283 throw new Error("clearVaultEntries requires explicit confirmation");2284 }2285 if (!derivedKey) throw new Error("vault_locked");2286 for (const entry of entries) {2287 if (!entry.deletedAt) {2288 const stamp = monotonicEntryTimestamp(Math.max(2289 entry.modifiedAt,2290 tombstones.get(entry.id) ?? 0,2291 ));2292 entry.deletedAt = stamp;2293 entry.modifiedAt = stamp;2294 tombstones.set(entry.id, stamp);2295 }2296 }2297 await persistEntries();2298}22992300export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2301 if (!derivedKey) throw new Error("vault_locked");23022303 const sanitized = sanitizeEntryDraft(entry);2304 const now = Date.now();2305 const stamp = monotonicEntryTimestamp();2306 const nextEntry: VaultEntry = {2307 ...sanitized,2308 id: crypto.randomUUID(),2309 createdAt: now,2310 modifiedAt: stamp,2311 2312 2313 passwordChangedAt: sanitized.password ? stamp : undefined,2314 };23152316 2317 2318 2319 const rollback = entries;2320 entries = [...entries, nextEntry];2321 try {2322 await persistEntries();2323 } catch (err) {2324 entries = rollback;2325 throw err;2326 }23272328 2329 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2330 checkBreach(nextEntry.password).then(async (count) => {2331 if (count > 0) {2332 const idx = entries.findIndex((e) => e.id === nextEntry.id);2333 if (idx !== -1) {2334 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2335 await persistEntries();2336 }2337 }2338 });2339 }23402341 fireSyncTrigger("vault_change");23422343 return nextEntry;2344}23452346234723482349235023512352export async function consolidatePasskeyEntries(): Promise<void> {2353 if (!derivedKey) return;2354 const { merged } = mergePasskeyOnlyIntoLogins(entries);2355 if (merged) {2356 await persistEntries();2357 }2358}23592360export async function updateEntry(2361 id: string,2362 patch: Partial<VaultEntry>2363): Promise<void> {2364 if (!derivedKey) throw new Error("vault_locked");23652366 const idx = entries.findIndex((entry) => entry.id === id);2367 if (idx === -1 || entries[idx].deletedAt) {2368 throw new Error("entry_not_found");2369 }23702371 const current = entries[idx];2372 const sanitizedPatch = sanitizeEntryPatch(patch);2373 2374 2375 2376 const passwordChanged =2377 "password" in patch &&2378 typeof sanitizedPatch.password === "string" &&2379 sanitizedPatch.password !== current.password;2380 const stamp = monotonicEntryTimestamp(current.modifiedAt);2381 2382 2383 2384 2385 2386 2387 2388 const priorPassword = current.password;2389 const shouldCapture =2390 passwordChanged &&2391 typeof priorPassword === "string" &&2392 priorPassword.length > 0 &&2393 current.passwordHistory?.[0]?.password !== priorPassword;2394 const nextHistory = shouldCapture2395 ? [2396 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2397 ...(current.passwordHistory ?? []),2398 ].slice(0, PASSWORD_HISTORY_MAX)2399 : current.passwordHistory;2400 entries[idx] = {2401 ...current,2402 ...sanitizedPatch,2403 modifiedAt: stamp,2404 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2405 };2406240724082409 2410 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2411 entries[idx] = current;2412 throw new Error("card number is required for card entries");2413 }24142415 2416 2417 2418 2419 try {2420 await persistEntries();2421 } catch (err) {2422 entries[idx] = current;2423 throw err;2424 }24252426 2427 2428 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2429 entries[idx] = { ...entries[idx], breach: undefined };2430 await persistEntries();2431 const password = entries[idx].password;2432 if (password) {2433 checkBreach(password).then(async (count) => {2434 const i = entries.findIndex((e) => e.id === id);2435 if (i !== -1 && count > 0) {2436 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2437 await persistEntries();2438 }2439 });2440 }2441 }24422443 fireSyncTrigger("vault_change");2444}24452446export async function deleteEntry(id: string): Promise<void> {2447 if (!derivedKey) throw new Error("vault_locked");24482449 const entry = entries.find((item) => item.id === id);2450 if (!entry || entry.deletedAt) {2451 throw new Error("entry_not_found");2452 }24532454 2455 2456 2457 const previousModifiedAt = entry.modifiedAt;2458 const previousTombstone = tombstones.get(id);2459 const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2460 entry.deletedAt = stamp;2461 entry.modifiedAt = stamp;2462 tombstones.set(id, stamp);2463 2464 2465 try {2466 await persistEntries();2467 } catch (err) {2468 delete entry.deletedAt;2469 entry.modifiedAt = previousModifiedAt;2470 if (previousTombstone === undefined) tombstones.delete(id);2471 else tombstones.set(id, previousTombstone);2472 throw err;2473 }2474 fireSyncTrigger("vault_change");2475}24762477export function getDeletedEntries(): VaultEntry[] {2478 if (!derivedKey) return [];2479 return entries.filter((e) => !!e.deletedAt);2480}248124822483export function getTombstoneManifest(): Record<string, number> {2484 if (!derivedKey) return {};2485 return Object.fromEntries(tombstones);2486}2487248824892490249124922493export async function getVaultVersion(): Promise<number> {2494 try {2495 const raw = await chrome.storage.local.get("peach_last_modified_at");2496 const v = (raw as Record<string, unknown>).peach_last_modified_at;2497 if (typeof v === "number") return v;2498 if (typeof v === "string" && v !== "") {2499 const n = Number.parseInt(v, 10);2500 return Number.isFinite(n) ? n : 0;2501 }2502 return 0;2503 } catch {2504 return 0;2505 }2506}2507250825092510251125122513251425152516251725182519export async function applyMergedRemoteEntries(2520 remoteEntries: VaultEntry[],2521 remoteTombstones: Record<string, number>,2522): Promise<{ received: number; applied: number; conflicts: number }> {2523 2524 2525 2526 2527 2528 2529 if (!derivedKey) throw new Error("vault_locked");25302531 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2532 let applied = 0;2533 let conflicts = 0;25342535 const byId = new Map<string, VaultEntry>();2536 for (const e of entries) byId.set(e.id, e);25372538 const mergeNow = Date.now();2539 const remoteTombstoneTimes = new Map<string, number>();2540 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2541 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2542 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2543 }2544 }25452546 for (const remote of remoteEntries) {2547 const sanitized = sanitizeRestoredEntry(remote);2548 if (!sanitized) continue;2549 2550 2551 2552 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2553 if (typeof sanitized.deletedAt === "number") {2554 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2555 }2556 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2557 const localTombstoneTs = tombstones.get(sanitized.id);2558 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2559 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2560 continue;2561 }2562 const local = byId.get(sanitized.id);2563 if (!local) {2564 entries.push(sanitized);2565 byId.set(sanitized.id, sanitized);2566 tombstones.delete(sanitized.id);2567 applied += 1;2568 } else if (sanitized.modifiedAt > local.modifiedAt) {2569 const idx = entries.findIndex((e) => e.id === local.id);2570 if (idx !== -1) {2571 entries[idx] = sanitized;2572 byId.set(sanitized.id, sanitized);2573 tombstones.delete(sanitized.id);2574 applied += 1;2575 if (!local.deletedAt) conflicts += 1;2576 }2577 }2578 }25792580 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2581 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2582 2583 const deletedAt = remoteTombstoneTimes.get(id)!;2584 const knownTombstone = tombstones.get(id);2585 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2586 const local = byId.get(id);2587 if (!local) {2588 tombstones.set(id, deletedAt);2589 applied += 1;2590 continue;2591 }2592 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2593 if (deletedAt < local.modifiedAt) continue;2594 const idx = entries.findIndex((e) => e.id === id);2595 if (idx !== -1) {2596 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2597 tombstones.set(id, deletedAt);2598 applied += 1;2599 }2600 }26012602 if (applied > 0) {2603 await persistEntries("sync_received");2604 buildDomainIndexSync(entries);2605 }2606 return { received, applied, conflicts };2607}26082609export async function restoreEntry(id: string): Promise<void> {2610 if (!derivedKey) throw new Error("vault_locked");2611 const entry = entries.find((e) => e.id === id && e.deletedAt);2612 if (!entry) throw new Error("deleted entry not found");2613 const previousDeletedAt = entry.deletedAt;2614 const previousModifiedAt = entry.modifiedAt;2615 const previousTombstone = tombstones.get(id);2616 delete entry.deletedAt;2617 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2618 entry.modifiedAt,2619 previousTombstone ?? 0,2620 ));2621 tombstones.delete(id);2622 try {2623 await persistEntries();2624 } catch (err) {2625 entry.deletedAt = previousDeletedAt;2626 entry.modifiedAt = previousModifiedAt;2627 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2628 throw err;2629 }2630 buildDomainIndexSync(entries);2631}26322633export async function purgeEntry(id: string): Promise<void> {2634 if (!derivedKey) throw new Error("vault_locked");2635 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2636 if (idx === -1) throw new Error("deleted entry not found");2637 const entry = entries[idx]!;2638 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2639 entries.splice(idx, 1);2640 await persistEntries();2641}264226432644export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2645 if (!derivedKey || retentionMs === 0) return 0;2646 const cutoff = Date.now() - retentionMs;2647 const before = entries.length;2648 for (const entry of entries) {2649 if (entry.deletedAt && entry.deletedAt < cutoff) {2650 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2651 }2652 }2653 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2654 const purged = before - entries.length;2655 if (purged > 0) await persistEntries();2656 return purged;2657}26582659export function queryDomain(domain: string): VaultEntry[] {2660 return domainIndex.get(domain) ?? [];2661}26622663export function getEntryById(id: string): VaultEntry | undefined {2664 return entries.find((entry) => entry.id === id && !entry.deletedAt);2665}26662667async function persistSlotA(2668 reason: VaultSnapshot["reason"] = "local_save",2669): Promise<StoredVault> {2670 if (!derivedKey) throw new Error("vault_locked");2671 const epoch = vaultEpoch;2672 const throwIfStale = () => {2673 if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2674 };26752676 const slotA = await loadSlot(SLOT_A);2677 throwIfStale();2678 if (!slotA) throw new Error("missing slot A");26792680 2681 2682 await takeVaultSnapshot(reason);2683 throwIfStale();26842685 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2686 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");2687 throwIfStale();26882689 2690 const nextSlotA: StoredVault = {2691 ...slotA,2692 nonce,2693 ciphertext,2694 tag,2695 2696 };26972698 2699 2700 2701 2702 2703 2704 let slotACommitted = false;2705 try {2706 throwIfStale();2707 await writeSlot(SLOT_A, nextSlotA);2708 slotACommitted = true;2709 throwIfStale();27102711 2712 2713 2714 if (!duressConfigured) {2715 throwIfStale();2716 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2717 throwIfStale();2718 }2719 2720 2721 2722 2723 2724 if (reason !== "sync_received") {2725 throwIfStale();2726 await stampLocalVaultMutation();2727 throwIfStale();2728 }2729 } catch (err) {2730 2731 2732 if (slotACommitted) await writeSlot(SLOT_A, slotA).catch(() => {});2733 if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2734 throw err;2735 }27362737 return nextSlotA;2738}27392740async function persistSlotB(): Promise<void> {2741 if (!derivedKey) throw new Error("vault_locked");27422743 const slotB = await loadSlot(SLOT_B);2744 if (!slotB) throw new Error("missing slot B");27452746 2747 2748 2749 2750 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2751 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");27522753 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2754 2755 2756 2757 try {2758 await stampLocalVaultMutation();2759 } catch (err) {2760 await writeSlot(SLOT_B, slotB).catch(() => {});2761 throw err;2762 }2763}27642765async function persistEntries(2766 reason: VaultSnapshot["reason"] = "local_save",2767): Promise<void> {2768 if (!derivedKey) throw new Error("vault_locked");27692770 2771 2772 const previousTombstones = tombstones;2773 tombstones = new Map(tombstones);2774 reconcileTombstones();2775 try {2776 if (vaultMode === "decoy") {2777 await persistSlotB();2778 } else {2779 await persistSlotA(reason);2780 }2781 } catch (err) {2782 tombstones = previousTombstones;2783 throw err;2784 }2785 buildDomainIndexSync(entries);2786 await refreshLockedSyncState();2787}2788278927902791279227932794function allowHttpUrl(url: string | undefined): string {2795 const trimmed = url?.trim() ?? "";2796 if (!trimmed) return "";2797 try {2798 const parsed = new URL(trimmed);2799 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2800 } catch {2801 2802 }2803 return trimmed;2804}28052806function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2807 if (!Array.isArray(values)) return undefined;2808 const seen = new Set<string>();2809 const cleaned: string[] = [];2810 for (const value of values) {2811 const trimmed = typeof value === "string" ? value.trim() : "";2812 if (!trimmed) continue;2813 if (!allowHttpUrl(trimmed)) continue;2814 const key = trimmed.toLowerCase();2815 if (seen.has(key)) continue;2816 seen.add(key);2817 cleaned.push(trimmed);2818 }2819 return cleaned.length > 0 ? cleaned : undefined;2820}28212822function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2823 const title = entry.title.trim();2824 const type: VaultEntry["type"] = entry.type ?? "login";28252826 if (!title) throw new Error("title is required");28272828 if (entry.passkey) {2829 const p = entry.passkey;2830 if (2831 !p.rpId ||2832 !p.credentialId ||2833 !p.publicKeyCose ||2834 !p.userHandle ||2835 !p.userName ||2836 (p.algorithm !== -7 && p.algorithm !== -8)2837 ) {2838 throw new Error("invalid passkey credential");2839 }2840 }2841 if (type === "card") {2842 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2843 throw new Error("cardholder name and card number are required");2844 }2845 }28462847 return {2848 type,2849 title,2850 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2851 email: type === "login" ? entry.email?.trim() || undefined : undefined,2852 password: type === "login" ? entry.password : undefined,2853 url: type === "login" ? allowHttpUrl(entry.url) : undefined,2854 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2855 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2856 2857 2858 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2859 totpDigits: type === "login" ? entry.totpDigits : undefined,2860 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2861 notes: entry.notes?.trim() || undefined,2862 breach: type === "login" ? entry.breach : undefined,2863 2864 2865 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2866 tags: sanitizeTags(entry.tags),2867 passkey: entry.passkey,2868 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2869 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2870 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2871 expYear: type === "card" ? entry.expYear?.trim() : undefined,2872 cvc: type === "card" ? entry.cvc?.trim() : undefined,2873 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2874 network: type === "card" ? entry.network : undefined,2875 last4: type === "card" ? entry.last4?.trim() : undefined,2876 };2877}28782879function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2880 const nextPatch: Partial<VaultEntry> = {};28812882 if ("type" in patch) {2883 nextPatch.type = patch.type;2884 }28852886 if ("title" in patch) {2887 const title = patch.title?.trim() ?? "";2888 if (!title) throw new Error("title is required");2889 nextPatch.title = title;2890 }28912892 if ("url" in patch) {2893 nextPatch.url = allowHttpUrl(patch.url);2894 }28952896 if ("additionalUrls" in patch) {2897 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2898 const seen = new Set<string>();2899 const cleaned: string[] = [];2900 for (const value of incoming) {2901 const trimmed = typeof value === "string" ? value.trim() : "";2902 if (!trimmed || !allowHttpUrl(trimmed)) continue;2903 const key = trimmed.toLowerCase();2904 if (seen.has(key)) continue;2905 seen.add(key);2906 cleaned.push(trimmed);2907 }2908 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2909 }29102911 if ("username" in patch) {2912 nextPatch.username = patch.username?.trim() ?? "";2913 }29142915 if ("email" in patch) {2916 nextPatch.email = patch.email?.trim() || undefined;2917 }29182919 if ("password" in patch) {2920 const password = patch.password ?? "";2921 2922 nextPatch.password = password || undefined;2923 }29242925 if ("totp" in patch) {2926 nextPatch.totp = patch.totp?.trim() || undefined;2927 }29282929 if ("totpAlgorithm" in patch) {2930 nextPatch.totpAlgorithm = patch.totpAlgorithm;2931 }29322933 if ("totpDigits" in patch) {2934 nextPatch.totpDigits = patch.totpDigits;2935 }29362937 if ("totpPeriod" in patch) {2938 nextPatch.totpPeriod = patch.totpPeriod;2939 }29402941 if ("notes" in patch) {2942 nextPatch.notes = patch.notes?.trim() || undefined;2943 }29442945 if ("breach" in patch) {2946 nextPatch.breach = patch.breach;2947 }29482949 if ("canary" in patch) {2950 2951 2952 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2953 }29542955 if ("passkey" in patch) {2956 2957 2958 if (patch.passkey === undefined || patch.passkey === null) {2959 nextPatch.passkey = undefined;2960 } else {2961 const sanitized = sanitizePasskey(patch.passkey);2962 if (!sanitized) throw new Error("invalid passkey credential");2963 nextPatch.passkey = sanitized;2964 }2965 }29662967 if ("tags" in patch) {2968 2969 nextPatch.tags = sanitizeTags(patch.tags);2970 }29712972 if ("cardholderName" in patch) {2973 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2974 }2975 if ("cardNumber" in patch) {2976 const n = patch.cardNumber ?? "";2977 nextPatch.cardNumber = n || undefined;2978 }2979 if ("expMonth" in patch) {2980 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2981 }2982 if ("expYear" in patch) {2983 nextPatch.expYear = patch.expYear?.trim() || undefined;2984 }2985 if ("cvc" in patch) {2986 nextPatch.cvc = patch.cvc?.trim() || undefined;2987 }2988 if ("cardNickname" in patch) {2989 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2990 }2991 if ("network" in patch) {2992 nextPatch.network = patch.network;2993 }2994 if ("last4" in patch) {2995 nextPatch.last4 = patch.last4?.trim() || undefined;2996 }29972998 return nextPatch;2999}30003001function normalizeEntry(entry: VaultEntry): VaultEntry {3002 const base: VaultEntry = {3003 id: entry.id,3004 type: entry.type,3005 title: entry.title,3006 username: entry.username,3007 email: entry.email,3008 password: entry.password,3009 url: entry.url,3010 additionalUrls:3011 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)3012 ? entry.additionalUrls3013 : undefined,3014 totp: entry.totp,3015 3016 totpAlgorithm: entry.totpAlgorithm,3017 totpDigits: entry.totpDigits,3018 totpPeriod: entry.totpPeriod,3019 notes: entry.notes,3020 breach: entry.breach,3021 3022 3023 3024 passwordChangedAt:3025 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,3026 3027 3028 3029 passwordHistory:3030 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)3031 ? entry.passwordHistory3032 : undefined,3033 canary: entry.canary,3034 passkey: entry.passkey,3035 createdAt: entry.createdAt,3036 modifiedAt: entry.modifiedAt,3037 deletedAt: entry.deletedAt,3038 tags: entry.tags,3039 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,3040 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,3041 expMonth: "expMonth" in entry ? entry.expMonth : undefined,3042 expYear: "expYear" in entry ? entry.expYear : undefined,3043 cvc: "cvc" in entry ? entry.cvc : undefined,3044 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,3045 network: "network" in entry ? entry.network : undefined,3046 last4: "last4" in entry ? entry.last4 : undefined,3047 };30483049 return base;3050}3051305230533054305530563057function entryDupeKey(e: VaultEntry): string {3058 if (e.type === "note") return `note|${e.title.toLowerCase()}`;3059 3060 3061 3062 3063 if (e.passkey) {3064 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;3065 }3066 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;3067}306830693070export function countDuplicates(): number {3071 const best = new Map<string, VaultEntry>();3072 let dupeCount = 0;3073 for (const e of entries.filter((e) => !e.deletedAt)) {3074 const key = entryDupeKey(e);3075 const existing = best.get(key);3076 if (!existing) {3077 best.set(key, e);3078 } else {3079 dupeCount++;3080 3081 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3082 best.set(key, e);3083 }3084 }3085 }3086 return dupeCount;3087}308830893090309130923093export async function deduplicateEntries(): Promise<number> {3094 if (!derivedKey) throw new Error("vault_locked");3095 3096 3097 if (vaultMode === "decoy") {3098 throw new Error("deduplicate disabled in decoy mode");3099 }31003101 const best = new Map<string, VaultEntry>();3102 for (const e of entries.filter((en) => !en.deletedAt)) {3103 const key = entryDupeKey(e);3104 const existing = best.get(key);3105 if (!existing) {3106 best.set(key, e);3107 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3108 best.set(key, e);3109 }3110 }31113112 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));3113 let removed = 0;3114 entries = entries.map((e) => {3115 if (!e.deletedAt && !keepIds.has(e.id)) {3116 removed++;3117 const deletedAt = monotonicEntryTimestamp(Math.max(3118 e.modifiedAt,3119 tombstones.get(e.id) ?? 0,3120 ));3121 tombstones.set(e.id, deletedAt);3122 return { ...e, deletedAt };3123 }3124 return e;3125 });31263127 if (removed > 0) await persistEntries();3128 return removed;3129}313031313132313331343135313631373138const HISTORY_KEY = "peach_vault_history";3139const HISTORY_MAX = 5;314031413142314331443145function bucketEntryCount(n: number): number {3146 if (n <= 0) return 0;3147 if (n <= 10) return 10;3148 if (n <= 25) return 25;3149 if (n <= 50) return 50;3150 if (n <= 100) return 100;3151 if (n <= 250) return 250;3152 if (n <= 500) return 500;3153 return Math.ceil(n / 500) * 500;3154}31553156315731583159316031613162export async function takeVaultSnapshot(3163 reason: VaultSnapshot["reason"],3164): Promise<void> {3165 try {3166 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3167 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3168 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];31693170 3171 3172 3173 if (!slotA) return;31743175 const vault_a = JSON.stringify(slotA);3176 const vault_b = JSON.stringify(slotB ?? slotA);31773178 3179 3180 3181 3182 3183 3184 let entryCount: number | null = null;3185 if (derivedKey && vaultMode === "real") {3186 try {3187 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3188 } catch {3189 entryCount = null;3190 }3191 }31923193 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3194 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3195 ? existingRaw3196 : { snapshots: [] };31973198 3199 3200 3201 const latest = history.snapshots[0];3202 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3203 return;3204 }32053206 const snapshot: VaultSnapshot = {3207 id: crypto.randomUUID(),3208 savedAt: Date.now(),3209 reason,3210 vault_a,3211 vault_b,3212 entryCount,3213 };32143215 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3216 await chrome.storage.local.set({ [HISTORY_KEY]: history });3217 } catch (err) {3218 3219 3220 3221 3222 console.warn('[Peach] takeVaultSnapshot failed:', err);3223 if (String(err).includes('QUOTA') || String(err).includes('quota')) {3224 try {3225 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3226 } catch {3227 3228 }3229 }3230 }3231}323232333234export async function clearVaultHistory(): Promise<void> {3235 try {3236 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3237 } catch {3238 3239 }3240}324132423243export async function getVaultHistory(): Promise<VaultSnapshot[]> {3244 const raw = await chrome.storage.local.get(HISTORY_KEY);3245 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3246 if (!history || !Array.isArray(history.snapshots)) return [];3247 return history.snapshots;3248}32493250325132523253325432553256export async function restoreVaultSnapshot(3257 snapshotId: string,3258): Promise<{ ok: true } | { ok: false; error: string }> {3259 const history = await getVaultHistory();3260 const snapshot = history.find((s) => s.id === snapshotId);3261 if (!snapshot) return { ok: false, error: "snapshot_not_found" };32623263 3264 3265 3266 await takeVaultSnapshot("restore");32673268 let restoredA: StoredVault;3269 let restoredB: StoredVault;3270 try {3271 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3272 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3273 } catch {3274 return { ok: false, error: "snapshot_corrupt" };3275 }32763277 await writeSlot(SLOT_A, restoredA);3278 await writeSlot(SLOT_B, restoredB);3279 await stampLocalVaultMutation();32803281 3282 3283 3284 await lock();32853286 return { ok: true };3287}328832893290329132923293329432953296329732983299330033013302export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3303 if (!derivedKey || vaultMode !== "real") {3304 return { ok: false, error: "vault_locked" };3305 }3306 const slotA = await loadSlot(SLOT_A);3307 if (!slotA) return { ok: false, error: "missing_slot_a" };33083309 3310 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3311 await generateAndWrapRootKey();33123313 3314 const newRootKey = unhex(rawRootKey);3315 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);33163317 3318 3319 3320 3321 await takeVaultSnapshot("local_save");3322 const nextSlotA: StoredVault = {3323 ...slotA,3324 recovery_salt: recoverySalt,3325 recovery_wrapped_root_key: wrappedRootKey,3326 recovery_nonce: wrappedNonce,3327 recovery_tag: wrappedTag,3328 ...pwRoot,3329 };3330 await writeSlot(SLOT_A, nextSlotA);33313332 3333 if (rootKey) zeroKey(rootKey);3334 rootKey = newRootKey;33353336 3337 3338 3339 await clearPairedDevices();3340 await clearPairSecrets();33413342 3343 await setRecoveryConfirmPending(); 33443345 await stampLocalVaultMutation();3346 return { ok: true };3347}33483349335033513352335333543355export async function changePassword(3356 currentPassword: string,3357 newPassword: string3358): Promise<boolean> {3359 const epoch = vaultEpoch;3360 const stale = () => epoch !== vaultEpoch;3361 if (!derivedKey || vaultMode !== "real") return false;33623363 3364 const valid = await verifyMainPassword(currentPassword);3365 if (!valid) return false;33663367 const slotA = await loadSlot(SLOT_A);3368 if (!slotA) return false;3369 if (!rootKey) return false;33703371 3372 const newSalt = generateSalt();3373 const newKey = await deriveKey(newPassword, newSalt);3374 if (stale()) {3375 zeroKey(newKey);3376 return false;3377 }33783379 3380 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3381 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");33823383 3384 3385 3386 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);33873388 const finalSlotA: StoredVault = {3389 ...slotA,3390 salt: hex(newSalt),3391 nonce,3392 ciphertext,3393 tag,3394 ...pwRoot,3395 };33963397 3398 3399 3400 await takeVaultSnapshot("local_save");34013402 3403 3404 3405 3406 await writeSlot(SLOT_A, finalSlotA);3407 if (stale()) {3408 zeroKey(newKey);3409 return false;3410 }34113412 3413 3414 3415 zeroKey(derivedKey);3416 derivedKey = newKey;34173418 3419 3420 3421 3422 if (stale()) {3423 await lock();3424 return false;3425 }3426 await saveSessionKey(newKey, "real", epoch);3427 if (stale()) return false;34283429 3430 3431 3432 if (!duressConfigured) {3433 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3434 }3435 await clearPinUnlockStorage();3436 await stampLocalVaultMutation();34373438 return true;3439}