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 _context: { deviceName?: string } = {},2523): Promise<{ received: number; applied: number; conflicts: number }> {2524 2525 2526 2527 2528 2529 2530 if (!derivedKey) throw new Error("vault_locked");25312532 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2533 let applied = 0;2534 let conflicts = 0;25352536 const byId = new Map<string, VaultEntry>();2537 for (const e of entries) byId.set(e.id, e);25382539 const mergeNow = Date.now();2540 const remoteTombstoneTimes = new Map<string, number>();2541 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2542 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2543 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2544 }2545 }25462547 for (const remote of remoteEntries) {2548 const sanitized = sanitizeRestoredEntry(remote);2549 if (!sanitized) continue;2550 2551 2552 2553 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2554 if (typeof sanitized.deletedAt === "number") {2555 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2556 }2557 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2558 const localTombstoneTs = tombstones.get(sanitized.id);2559 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2560 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2561 continue;2562 }2563 const local = byId.get(sanitized.id);2564 if (!local) {2565 entries.push(sanitized);2566 byId.set(sanitized.id, sanitized);2567 tombstones.delete(sanitized.id);2568 applied += 1;2569 } else if (sanitized.modifiedAt > local.modifiedAt) {2570 const idx = entries.findIndex((e) => e.id === local.id);2571 if (idx !== -1) {2572 entries[idx] = sanitized;2573 byId.set(sanitized.id, sanitized);2574 tombstones.delete(sanitized.id);2575 applied += 1;2576 if (!local.deletedAt) conflicts += 1;2577 }2578 }2579 }25802581 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2582 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2583 2584 const deletedAt = remoteTombstoneTimes.get(id)!;2585 const knownTombstone = tombstones.get(id);2586 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2587 const local = byId.get(id);2588 if (!local) {2589 tombstones.set(id, deletedAt);2590 applied += 1;2591 continue;2592 }2593 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2594 if (deletedAt < local.modifiedAt) continue;2595 const idx = entries.findIndex((e) => e.id === id);2596 if (idx !== -1) {2597 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2598 tombstones.set(id, deletedAt);2599 applied += 1;2600 }2601 }26022603 if (applied > 0) {2604 await persistEntries("sync_received");2605 buildDomainIndexSync(entries);2606 }2607 return { received, applied, conflicts };2608}26092610export async function restoreEntry(id: string): Promise<void> {2611 if (!derivedKey) throw new Error("vault_locked");2612 const entry = entries.find((e) => e.id === id && e.deletedAt);2613 if (!entry) throw new Error("deleted entry not found");2614 const previousDeletedAt = entry.deletedAt;2615 const previousModifiedAt = entry.modifiedAt;2616 const previousTombstone = tombstones.get(id);2617 delete entry.deletedAt;2618 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2619 entry.modifiedAt,2620 previousTombstone ?? 0,2621 ));2622 tombstones.delete(id);2623 try {2624 await persistEntries();2625 } catch (err) {2626 entry.deletedAt = previousDeletedAt;2627 entry.modifiedAt = previousModifiedAt;2628 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2629 throw err;2630 }2631 buildDomainIndexSync(entries);2632}26332634export async function purgeEntry(id: string): Promise<void> {2635 if (!derivedKey) throw new Error("vault_locked");2636 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2637 if (idx === -1) throw new Error("deleted entry not found");2638 const entry = entries[idx]!;2639 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2640 entries.splice(idx, 1);2641 await persistEntries();2642}264326442645export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2646 if (!derivedKey || retentionMs === 0) return 0;2647 const cutoff = Date.now() - retentionMs;2648 const before = entries.length;2649 for (const entry of entries) {2650 if (entry.deletedAt && entry.deletedAt < cutoff) {2651 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2652 }2653 }2654 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2655 const purged = before - entries.length;2656 if (purged > 0) await persistEntries();2657 return purged;2658}26592660export function queryDomain(domain: string): VaultEntry[] {2661 return domainIndex.get(domain) ?? [];2662}26632664export function getEntryById(id: string): VaultEntry | undefined {2665 return entries.find((entry) => entry.id === id && !entry.deletedAt);2666}26672668async function persistSlotA(2669 reason: VaultSnapshot["reason"] = "local_save",2670): Promise<StoredVault> {2671 if (!derivedKey) throw new Error("vault_locked");2672 const epoch = vaultEpoch;2673 const throwIfStale = () => {2674 if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2675 };26762677 const slotA = await loadSlot(SLOT_A);2678 throwIfStale();2679 if (!slotA) throw new Error("missing slot A");26802681 2682 2683 await takeVaultSnapshot(reason);2684 throwIfStale();26852686 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2687 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");2688 throwIfStale();26892690 2691 const nextSlotA: StoredVault = {2692 ...slotA,2693 nonce,2694 ciphertext,2695 tag,2696 2697 };26982699 2700 2701 2702 2703 2704 2705 let slotACommitted = false;2706 try {2707 throwIfStale();2708 await writeSlot(SLOT_A, nextSlotA);2709 slotACommitted = true;2710 throwIfStale();27112712 2713 2714 2715 if (!duressConfigured) {2716 throwIfStale();2717 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2718 throwIfStale();2719 }2720 2721 2722 2723 2724 2725 if (reason !== "sync_received") {2726 throwIfStale();2727 await stampLocalVaultMutation();2728 throwIfStale();2729 }2730 } catch (err) {2731 2732 2733 if (slotACommitted) await writeSlot(SLOT_A, slotA).catch(() => {});2734 if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2735 throw err;2736 }27372738 return nextSlotA;2739}27402741async function persistSlotB(): Promise<void> {2742 if (!derivedKey) throw new Error("vault_locked");27432744 const slotB = await loadSlot(SLOT_B);2745 if (!slotB) throw new Error("missing slot B");27462747 2748 2749 2750 2751 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2752 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");27532754 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2755 2756 2757 2758 try {2759 await stampLocalVaultMutation();2760 } catch (err) {2761 await writeSlot(SLOT_B, slotB).catch(() => {});2762 throw err;2763 }2764}27652766async function persistEntries(2767 reason: VaultSnapshot["reason"] = "local_save",2768): Promise<void> {2769 if (!derivedKey) throw new Error("vault_locked");27702771 2772 2773 const previousTombstones = tombstones;2774 tombstones = new Map(tombstones);2775 reconcileTombstones();2776 try {2777 if (vaultMode === "decoy") {2778 await persistSlotB();2779 } else {2780 await persistSlotA(reason);2781 }2782 } catch (err) {2783 tombstones = previousTombstones;2784 throw err;2785 }2786 buildDomainIndexSync(entries);2787 await refreshLockedSyncState();2788}2789279027912792279327942795function allowHttpUrl(url: string | undefined): string {2796 const trimmed = url?.trim() ?? "";2797 if (!trimmed) return "";2798 try {2799 const parsed = new URL(trimmed);2800 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2801 } catch {2802 2803 }2804 return trimmed;2805}28062807function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2808 if (!Array.isArray(values)) return undefined;2809 const seen = new Set<string>();2810 const cleaned: string[] = [];2811 for (const value of values) {2812 const trimmed = typeof value === "string" ? value.trim() : "";2813 if (!trimmed) continue;2814 if (!allowHttpUrl(trimmed)) continue;2815 const key = trimmed.toLowerCase();2816 if (seen.has(key)) continue;2817 seen.add(key);2818 cleaned.push(trimmed);2819 }2820 return cleaned.length > 0 ? cleaned : undefined;2821}28222823function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2824 const title = entry.title.trim();2825 const type: VaultEntry["type"] = entry.type ?? "login";28262827 if (!title) throw new Error("title is required");28282829 if (entry.passkey) {2830 const p = entry.passkey;2831 if (2832 !p.rpId ||2833 !p.credentialId ||2834 !p.publicKeyCose ||2835 !p.userHandle ||2836 !p.userName ||2837 (p.algorithm !== -7 && p.algorithm !== -8)2838 ) {2839 throw new Error("invalid passkey credential");2840 }2841 }2842 if (type === "card") {2843 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2844 throw new Error("cardholder name and card number are required");2845 }2846 }28472848 return {2849 type,2850 title,2851 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2852 email: type === "login" ? entry.email?.trim() || undefined : undefined,2853 password: type === "login" ? entry.password : undefined,2854 url: type === "login" ? allowHttpUrl(entry.url) : undefined,2855 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2856 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2857 2858 2859 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2860 totpDigits: type === "login" ? entry.totpDigits : undefined,2861 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2862 notes: entry.notes?.trim() || undefined,2863 breach: type === "login" ? entry.breach : undefined,2864 2865 2866 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2867 tags: sanitizeTags(entry.tags),2868 passkey: entry.passkey,2869 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2870 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2871 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2872 expYear: type === "card" ? entry.expYear?.trim() : undefined,2873 cvc: type === "card" ? entry.cvc?.trim() : undefined,2874 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2875 network: type === "card" ? entry.network : undefined,2876 last4: type === "card" ? entry.last4?.trim() : undefined,2877 };2878}28792880function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2881 const nextPatch: Partial<VaultEntry> = {};28822883 if ("type" in patch) {2884 nextPatch.type = patch.type;2885 }28862887 if ("title" in patch) {2888 const title = patch.title?.trim() ?? "";2889 if (!title) throw new Error("title is required");2890 nextPatch.title = title;2891 }28922893 if ("url" in patch) {2894 nextPatch.url = allowHttpUrl(patch.url);2895 }28962897 if ("additionalUrls" in patch) {2898 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2899 const seen = new Set<string>();2900 const cleaned: string[] = [];2901 for (const value of incoming) {2902 const trimmed = typeof value === "string" ? value.trim() : "";2903 if (!trimmed || !allowHttpUrl(trimmed)) continue;2904 const key = trimmed.toLowerCase();2905 if (seen.has(key)) continue;2906 seen.add(key);2907 cleaned.push(trimmed);2908 }2909 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2910 }29112912 if ("username" in patch) {2913 nextPatch.username = patch.username?.trim() ?? "";2914 }29152916 if ("email" in patch) {2917 nextPatch.email = patch.email?.trim() || undefined;2918 }29192920 if ("password" in patch) {2921 const password = patch.password ?? "";2922 2923 nextPatch.password = password || undefined;2924 }29252926 if ("totp" in patch) {2927 nextPatch.totp = patch.totp?.trim() || undefined;2928 }29292930 if ("totpAlgorithm" in patch) {2931 nextPatch.totpAlgorithm = patch.totpAlgorithm;2932 }29332934 if ("totpDigits" in patch) {2935 nextPatch.totpDigits = patch.totpDigits;2936 }29372938 if ("totpPeriod" in patch) {2939 nextPatch.totpPeriod = patch.totpPeriod;2940 }29412942 if ("notes" in patch) {2943 nextPatch.notes = patch.notes?.trim() || undefined;2944 }29452946 if ("breach" in patch) {2947 nextPatch.breach = patch.breach;2948 }29492950 if ("canary" in patch) {2951 2952 2953 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2954 }29552956 if ("passkey" in patch) {2957 2958 2959 if (patch.passkey === undefined || patch.passkey === null) {2960 nextPatch.passkey = undefined;2961 } else {2962 const sanitized = sanitizePasskey(patch.passkey);2963 if (!sanitized) throw new Error("invalid passkey credential");2964 nextPatch.passkey = sanitized;2965 }2966 }29672968 if ("tags" in patch) {2969 2970 nextPatch.tags = sanitizeTags(patch.tags);2971 }29722973 if ("cardholderName" in patch) {2974 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2975 }2976 if ("cardNumber" in patch) {2977 const n = patch.cardNumber ?? "";2978 nextPatch.cardNumber = n || undefined;2979 }2980 if ("expMonth" in patch) {2981 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2982 }2983 if ("expYear" in patch) {2984 nextPatch.expYear = patch.expYear?.trim() || undefined;2985 }2986 if ("cvc" in patch) {2987 nextPatch.cvc = patch.cvc?.trim() || undefined;2988 }2989 if ("cardNickname" in patch) {2990 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2991 }2992 if ("network" in patch) {2993 nextPatch.network = patch.network;2994 }2995 if ("last4" in patch) {2996 nextPatch.last4 = patch.last4?.trim() || undefined;2997 }29982999 return nextPatch;3000}30013002function normalizeEntry(entry: VaultEntry): VaultEntry {3003 const base: VaultEntry = {3004 id: entry.id,3005 type: entry.type,3006 title: entry.title,3007 username: entry.username,3008 email: entry.email,3009 password: entry.password,3010 url: entry.url,3011 additionalUrls:3012 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)3013 ? entry.additionalUrls3014 : undefined,3015 totp: entry.totp,3016 3017 totpAlgorithm: entry.totpAlgorithm,3018 totpDigits: entry.totpDigits,3019 totpPeriod: entry.totpPeriod,3020 notes: entry.notes,3021 breach: entry.breach,3022 3023 3024 3025 passwordChangedAt:3026 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,3027 3028 3029 3030 passwordHistory:3031 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)3032 ? entry.passwordHistory3033 : undefined,3034 canary: entry.canary,3035 passkey: entry.passkey,3036 createdAt: entry.createdAt,3037 modifiedAt: entry.modifiedAt,3038 deletedAt: entry.deletedAt,3039 tags: entry.tags,3040 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,3041 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,3042 expMonth: "expMonth" in entry ? entry.expMonth : undefined,3043 expYear: "expYear" in entry ? entry.expYear : undefined,3044 cvc: "cvc" in entry ? entry.cvc : undefined,3045 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,3046 network: "network" in entry ? entry.network : undefined,3047 last4: "last4" in entry ? entry.last4 : undefined,3048 };30493050 return base;3051}3052305330543055305630573058function entryDupeKey(e: VaultEntry): string {3059 if (e.type === "note") return `note|${e.title.toLowerCase()}`;3060 3061 3062 3063 3064 if (e.passkey) {3065 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;3066 }3067 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;3068}306930703071export function countDuplicates(): number {3072 const best = new Map<string, VaultEntry>();3073 let dupeCount = 0;3074 for (const e of entries.filter((e) => !e.deletedAt)) {3075 const key = entryDupeKey(e);3076 const existing = best.get(key);3077 if (!existing) {3078 best.set(key, e);3079 } else {3080 dupeCount++;3081 3082 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3083 best.set(key, e);3084 }3085 }3086 }3087 return dupeCount;3088}308930903091309230933094export async function deduplicateEntries(): Promise<number> {3095 if (!derivedKey) throw new Error("vault_locked");3096 3097 3098 if (vaultMode === "decoy") {3099 throw new Error("deduplicate disabled in decoy mode");3100 }31013102 const best = new Map<string, VaultEntry>();3103 for (const e of entries.filter((en) => !en.deletedAt)) {3104 const key = entryDupeKey(e);3105 const existing = best.get(key);3106 if (!existing) {3107 best.set(key, e);3108 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3109 best.set(key, e);3110 }3111 }31123113 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));3114 let removed = 0;3115 entries = entries.map((e) => {3116 if (!e.deletedAt && !keepIds.has(e.id)) {3117 removed++;3118 const deletedAt = monotonicEntryTimestamp(Math.max(3119 e.modifiedAt,3120 tombstones.get(e.id) ?? 0,3121 ));3122 tombstones.set(e.id, deletedAt);3123 return { ...e, deletedAt };3124 }3125 return e;3126 });31273128 if (removed > 0) await persistEntries();3129 return removed;3130}313131323133313431353136313731383139const HISTORY_KEY = "peach_vault_history";3140const HISTORY_MAX = 5;314131423143314431453146function bucketEntryCount(n: number): number {3147 if (n <= 0) return 0;3148 if (n <= 10) return 10;3149 if (n <= 25) return 25;3150 if (n <= 50) return 50;3151 if (n <= 100) return 100;3152 if (n <= 250) return 250;3153 if (n <= 500) return 500;3154 return Math.ceil(n / 500) * 500;3155}31563157315831593160316131623163export async function takeVaultSnapshot(3164 reason: VaultSnapshot["reason"],3165): Promise<void> {3166 try {3167 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3168 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3169 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];31703171 3172 3173 3174 if (!slotA) return;31753176 const vault_a = JSON.stringify(slotA);3177 const vault_b = JSON.stringify(slotB ?? slotA);31783179 3180 3181 3182 3183 3184 3185 let entryCount: number | null = null;3186 if (derivedKey && vaultMode === "real") {3187 try {3188 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3189 } catch {3190 entryCount = null;3191 }3192 }31933194 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3195 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3196 ? existingRaw3197 : { snapshots: [] };31983199 3200 3201 3202 const latest = history.snapshots[0];3203 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3204 return;3205 }32063207 const snapshot: VaultSnapshot = {3208 id: crypto.randomUUID(),3209 savedAt: Date.now(),3210 reason,3211 vault_a,3212 vault_b,3213 entryCount,3214 };32153216 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3217 await chrome.storage.local.set({ [HISTORY_KEY]: history });3218 } catch (err) {3219 3220 3221 3222 3223 console.warn('[Peach] takeVaultSnapshot failed:', err);3224 if (String(err).includes('QUOTA') || String(err).includes('quota')) {3225 try {3226 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3227 } catch {3228 3229 }3230 }3231 }3232}323332343235export async function clearVaultHistory(): Promise<void> {3236 try {3237 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3238 } catch {3239 3240 }3241}324232433244export async function getVaultHistory(): Promise<VaultSnapshot[]> {3245 const raw = await chrome.storage.local.get(HISTORY_KEY);3246 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3247 if (!history || !Array.isArray(history.snapshots)) return [];3248 return history.snapshots;3249}32503251325232533254325532563257export async function restoreVaultSnapshot(3258 snapshotId: string,3259): Promise<{ ok: true } | { ok: false; error: string }> {3260 const history = await getVaultHistory();3261 const snapshot = history.find((s) => s.id === snapshotId);3262 if (!snapshot) return { ok: false, error: "snapshot_not_found" };32633264 3265 3266 3267 await takeVaultSnapshot("restore");32683269 let restoredA: StoredVault;3270 let restoredB: StoredVault;3271 try {3272 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3273 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3274 } catch {3275 return { ok: false, error: "snapshot_corrupt" };3276 }32773278 await writeSlot(SLOT_A, restoredA);3279 await writeSlot(SLOT_B, restoredB);3280 await stampLocalVaultMutation();32813282 3283 3284 3285 await lock();32863287 return { ok: true };3288}328932903291329232933294329532963297329832993300330133023303export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3304 if (!derivedKey || vaultMode !== "real") {3305 return { ok: false, error: "vault_locked" };3306 }3307 const slotA = await loadSlot(SLOT_A);3308 if (!slotA) return { ok: false, error: "missing_slot_a" };33093310 3311 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3312 await generateAndWrapRootKey();33133314 3315 const newRootKey = unhex(rawRootKey);3316 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);33173318 3319 3320 3321 3322 await takeVaultSnapshot("local_save");3323 const nextSlotA: StoredVault = {3324 ...slotA,3325 recovery_salt: recoverySalt,3326 recovery_wrapped_root_key: wrappedRootKey,3327 recovery_nonce: wrappedNonce,3328 recovery_tag: wrappedTag,3329 ...pwRoot,3330 };3331 await writeSlot(SLOT_A, nextSlotA);33323333 3334 if (rootKey) zeroKey(rootKey);3335 rootKey = newRootKey;33363337 3338 3339 3340 await clearPairedDevices();3341 await clearPairSecrets();33423343 3344 await setRecoveryConfirmPending(); 33453346 await stampLocalVaultMutation();3347 return { ok: true };3348}33493350335133523353335433553356export async function changePassword(3357 currentPassword: string,3358 newPassword: string3359): Promise<boolean> {3360 const epoch = vaultEpoch;3361 const stale = () => epoch !== vaultEpoch;3362 if (!derivedKey || vaultMode !== "real") return false;33633364 3365 const valid = await verifyMainPassword(currentPassword);3366 if (!valid) return false;33673368 const slotA = await loadSlot(SLOT_A);3369 if (!slotA) return false;3370 if (!rootKey) return false;33713372 3373 const newSalt = generateSalt();3374 const newKey = await deriveKey(newPassword, newSalt);3375 if (stale()) {3376 zeroKey(newKey);3377 return false;3378 }33793380 3381 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3382 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");33833384 3385 3386 3387 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);33883389 const finalSlotA: StoredVault = {3390 ...slotA,3391 salt: hex(newSalt),3392 nonce,3393 ciphertext,3394 tag,3395 ...pwRoot,3396 };33973398 3399 3400 3401 await takeVaultSnapshot("local_save");34023403 3404 3405 3406 3407 await writeSlot(SLOT_A, finalSlotA);3408 if (stale()) {3409 zeroKey(newKey);3410 return false;3411 }34123413 3414 3415 3416 zeroKey(derivedKey);3417 derivedKey = newKey;34183419 3420 3421 3422 3423 if (stale()) {3424 await lock();3425 return false;3426 }3427 await saveSessionKey(newKey, "real", epoch);3428 if (stale()) return false;34293430 3431 3432 3433 if (!duressConfigured) {3434 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3435 }3436 await clearPinUnlockStorage();3437 await stampLocalVaultMutation();34383439 return true;3440}