123456789101112import { deriveKey, deriveKeyRaw, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import { clampSyncTimestamp } from "../shared/sync-limits";15import { recordSyncAlerts, type SyncAlert } from "./sync-alerts";16import {17 rootKeyToWords,18 wordsToRootKeyMaterial,19 deriveRecoveryKey,20} from "../shared/recovery-words";21import type {22 AccentColorName,23 Canary,24 LegacyVaultEntry,25 ManualSummonRecord,26 PasskeyData,27 SavedIdentity,28 StoredVault,29 VaultEntry,30 VaultHistory,31 VaultSnapshot,32} from "../shared/types";33import { decrypt, encrypt, zeroKey } from "./aes";34import { clearAutoLock, touchAutoLock } from "./alarms";35import { setRecoveryConfirmPending } from "./recovery-pending";3637type EntryDraft = Omit<38 VaultEntry,39 "id" | "createdAt" | "modifiedAt" | "deletedAt"40>;414243444546const SLOT_A = "peach_vault_a";47const SLOT_B = "peach_vault_b";484950const LEGACY_MAIN = "peach_vault";51const LEGACY_DECOY = "peach_decoy_vault";52const LEGACY_DURESS_FLAG = "peach_duress";53545556const PAD_BLOCK = 1024;5758596061const PASSWORD_HISTORY_MAX = 50;6263646566676869707172const DURESS_SLOT_SIZE = 262_144;7374let derivedKey: Uint8Array | null = null;7576777879let rootKey: Uint8Array | null = null;80let entries: VaultEntry[] = [];81let domainIndex: Map<string, VaultEntry[]> = new Map();82let vaultMode: "real" | "decoy" = "real";83848586let duressConfigured = false;8788let migrationRan = false;8990919293949596979899let lastUnlockFailureReason: string | null = null;100101export function consumeLastUnlockFailureReason(): string | null {102 const r = lastUnlockFailureReason;103 lastUnlockFailureReason = null;104 return r;105}106107108109110111export type TextSize = "small" | "medium" | "large";112113export interface VaultSettings {114 accentColor?: AccentColorName;115 theme?: "dark" | "light";116 autoLockMinutes?: number;117 clipboardClearSeconds?: number;118 lockOnClose?: boolean;119 breachMonitoring?: boolean;120 passkeyProviderEnabled?: boolean;121 trashRetentionDays?: number;122 manualSummonDomains?: Record<string, ManualSummonRecord>;123 declinedCardFingerprints?: string[];124 125126127128129 textSize?: TextSize;130 131132133134135 reduceMotion?: boolean;136 137138139140141 biometricsEnabled?: boolean;142 143144145146147148149 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;150}151152let settings: VaultSettings = {};153154export function getSettings(): VaultSettings {155 return settings;156}157158export async function ensureSettingsLoaded(): Promise<VaultSettings> {159 await loadSettings();160 return settings;161}162163export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {164 await loadSettings();165 166 167 168 169 const safe = Object.fromEntries(170 Object.entries(patch).filter(171 ([k]) => !["__proto__", "constructor", "prototype"].includes(k),172 ),173 ) as Partial<VaultSettings>;174 settings = { ...settings, ...safe };175 await persistSettings();176}177178async function persistSettings(): Promise<void> {179 try {180 await chrome.storage.local.set({ peach_settings: settings });181 } catch (err) {182 throw mapStorageError(err);183 }184}185186function mapStorageError(err: unknown): Error {187 const message = err instanceof Error ? err.message : String(err);188 if (/QUOTA_BYTES|quota/i.test(message)) {189 return new Error("STORAGE_QUOTA_EXCEEDED");190 }191 return err instanceof Error ? err : new Error(message);192}193194async function loadSettings(): Promise<void> {195 const result = await chrome.storage.local.get("peach_settings");196 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};197}198199200201202203204function fireSyncTrigger(reason: "vault_change"): void {205 void import("./sync-trigger")206 .then((mod) => mod.triggerSyncIfReachable({ reason }))207 .catch(() => {208 209 });210}211212213214215216export async function checkBreach(password: string): Promise<number> {217 218 219 const encoder = new TextEncoder();220 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));221 const hashHex = Array.from(new Uint8Array(hashBuffer))222 .map((b) => b.toString(16).padStart(2, "0"))223 .join("")224 .toUpperCase();225226 const prefix = hashHex.slice(0, 5);227 const suffix = hashHex.slice(5);228229 try {230 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);231 if (!res.ok) return 0;232 const text = await res.text();233 const match = text.split("\r\n").find((l) => l.startsWith(suffix));234 return match ? parseInt(match.split(":")[1], 10) : 0;235 } catch {236 return 0; 237 }238}239240export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {241 const fingerprint = await cardFingerprint(last4, expMonth, expYear);242 await loadSettings();243 settings = {244 ...settings,245 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],246 };247 await persistSettings();248}249250export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {251 await loadSettings();252 const fingerprint = await cardFingerprint(last4, expMonth, expYear);253 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);254}255256async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {257 const encoder = new TextEncoder();258 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));259 return Array.from(new Uint8Array(buf).slice(0, 8))260 .map((b) => b.toString(16).padStart(2, "0"))261 .join("");262}263264function hex(buf: Uint8Array): string {265 return Array.from(buf)266 .map((b) => b.toString(16).padStart(2, "0"))267 .join("");268}269270function unhex(s: string): Uint8Array {271 const out = new Uint8Array(s.length / 2);272 for (let i = 0; i < s.length; i += 2) {273 out[i / 2] = parseInt(s.slice(i, i + 2), 16);274 }275 return out;276}277278279280281282283284285286287288289290291292293294295296297async function generateAndWrapRootKey(): Promise<{298 rawRootKey: string; 299 recoverySalt: string;300 wrappedRootKey: string;301 wrappedNonce: string;302 wrappedTag: string;303}> {304 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));305306 307 const words = await rootKeyToWords(rootKeyBytes);308 const wordMaterial = await wordsToRootKeyMaterial(words);309 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));310 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);311312 313 314 const nonce = crypto.getRandomValues(new Uint8Array(12));315 const encoded = new TextEncoder().encode(hex(rootKeyBytes));316 const raw = await crypto.subtle.encrypt(317 {318 name: "AES-GCM",319 iv: nonce,320 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,321 tagLength: 128,322 },323 recoveryKey,324 encoded325 );326 327 const rawU8 = new Uint8Array(raw);328 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));329 const tag = hex(rawU8.slice(rawU8.length - 16));330331 return {332 rawRootKey: hex(rootKeyBytes),333 recoverySalt: hex(recoverySalt),334 wrappedRootKey: ciphertext,335 wrappedNonce: hex(nonce),336 wrappedTag: tag,337 };338}339340341342343344345export async function unwrapRootKeyForRecovery(346 wrappedRootKey: string,347 wrappedNonce: string,348 wrappedTag: string,349 recoverySaltHex: string,350 words: string[]351): Promise<void> {352 const wordMaterial = await wordsToRootKeyMaterial(words);353 const recoverySalt = unhex(recoverySaltHex);354 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);355356 357 358 359 360 361 const nonceRaw = unhex(wrappedNonce);362 const ciphertextRaw = unhex(wrappedRootKey);363 const tagRaw = unhex(wrappedTag);364 365 366 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;367 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);368 combined.set(ciphertextRaw, 0);369 combined.set(tagRaw, ciphertextRaw.length);370 const raw = await crypto.subtle.decrypt(371 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },372 recoveryKey,373 combined374 );375 const decryptedHex = new TextDecoder().decode(raw);376 rootKey = unhex(decryptedHex);377}378379380381382383384385386387388export async function rewrapRootKeyForPassword(389 newDerivedKey: Uint8Array,390 _newSalt: Uint8Array391): Promise<void> {392 if (!rootKey) throw new Error("root key not set");393 const slotA = await loadSlot(SLOT_A);394 if (!slotA) throw new Error("missing slot A");395 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);396 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });397}398399400401402403404async function reencryptVaultForNewKey(405 newKey: Uint8Array,406 slotA: StoredVault407): Promise<void> {408 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);409 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");410 await writeSlot(SLOT_A, {411 ...slotA,412 nonce,413 ciphertext,414 tag,415 });416}417418419420421422423export async function changePasswordWithNewKey(424 newSalt: Uint8Array,425 newKey: Uint8Array426): Promise<void> {427 const slotA = await loadSlot(SLOT_A);428 if (!slotA) throw new Error("missing slot A");429 430 431 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });432 if (derivedKey) zeroKey(derivedKey);433 derivedKey = newKey;434 435 436 437 void saveSessionKey(newKey, "real");438 439 440 441 442 if (rootKey) {443 const slotAfter = await loadSlot(SLOT_A);444 if (slotAfter) {445 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);446 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });447 }448 }449 if (!duressConfigured) {450 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));451 }452 await stampLocalVaultMutation();453}454455456457458459460export { deriveKey, generateSalt };461462export async function loadSlotA(): Promise<StoredVault | null> {463 return loadSlot(SLOT_A);464}465466467468469470471472473474475476477478async function deriveRootKeyFromDerivedKey(479 derivedKeyBytes: Uint8Array,480 slotA: StoredVault,481): Promise<Uint8Array | null> {482 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {483 return null;484 }485 try {486 const rootHex = await decrypt(487 derivedKeyBytes,488 slotA.pw_root_nonce,489 slotA.pw_root_ciphertext,490 slotA.pw_root_tag,491 "pw_root_v1",492 );493 return unhex(rootHex);494 } catch {495 return null;496 }497}498499500501async function wrapRootKeyForDerivedKey(502 derivedKeyBytes: Uint8Array,503 rootKeyBytes: Uint8Array,504): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {505 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");506 return {507 pw_root_ciphertext: ciphertext,508 pw_root_nonce: nonce,509 pw_root_tag: tag,510 };511}512513514515516517518519520export async function migrateToRecovery(): Promise<void> {521 if (!derivedKey || vaultMode !== "real") {522 throw new Error("vault must be unlocked in real mode");523 }524 const slotA = await loadSlot(SLOT_A);525 if (!slotA) throw new Error("missing slot A");526 const hasRecoveryEscrow =527 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;528 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;529 if (hasRecoveryEscrow && hasPwRoot) return; 530531 532 533 534 535 536 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =537 await generateAndWrapRootKey();538 rootKey = unhex(rawRootKey);539540 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);541 const nextSlotA: StoredVault = {542 ...slotA,543 recovery_salt: recoverySalt,544 recovery_wrapped_root_key: wrappedRootKey,545 recovery_nonce: wrappedNonce,546 recovery_tag: wrappedTag,547 ...pwRoot,548 };549 await writeSlot(SLOT_A, nextSlotA);550 await stampLocalVaultMutation();551}552553554555556557558export async function getRecoveryWords(): Promise<string[]> {559 if (!rootKey) {560 throw new Error("recovery not set up — vault may need migration");561 }562 return rootKeyToWords(rootKey);563}564565async function loadSlot(key: string): Promise<StoredVault | null> {566 const result = await chrome.storage.local.get(key);567 return (result as Record<string, StoredVault | undefined>)[key] ?? null;568}569570async function writeSlot(key: string, vault: StoredVault): Promise<void> {571 try {572 await chrome.storage.local.set({ [key]: vault });573 } catch (err) {574 throw mapStorageError(err);575 }576}577578579580581582583584585let lastEntryTimestamp = 0;586function monotonicEntryTimestamp(currentModifiedAt?: number): number {587 const base = Math.max(588 Date.now(),589 (currentModifiedAt ?? 0) + 1,590 lastEntryTimestamp + 1,591 );592 lastEntryTimestamp = base;593 return base;594}595596async function stampLocalVaultMutation(): Promise<void> {597 const raw = await chrome.storage.local.get("peach_last_modified_at");598 const previous = Number(599 (raw as Record<string, unknown>).peach_last_modified_at,600 );601 const next = Math.max(602 Date.now(),603 Number.isFinite(previous) ? previous + 1 : 0,604 );605 await chrome.storage.local.set({606 peach_last_modified_at: String(next),607 });608}609610611612613614615async function ensureMigrated(): Promise<void> {616 if (migrationRan) return;617 migrationRan = true;618619 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];620 const raw = await chrome.storage.local.get(keys);621 const slotAExists = raw[SLOT_A] !== undefined;622 const slotBExists = raw[SLOT_B] !== undefined;623 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;624 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;625626 627 if (!slotAExists && !legacyMain) return;628629 630 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;631632 let didMigrate = false;633 if (legacyMain && !slotAExists) {634 await writeSlot(SLOT_A, legacyMain);635 didMigrate = true;636 }637638 639 if (!slotBExists) {640 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;641 if (legacyDecoy) {642 await writeSlot(SLOT_B, legacyDecoy);643 didMigrate = true;644 } else if (target) {645 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));646 didMigrate = true;647 }648 }649650 const toRemove: string[] = [];651 if (legacyMain) toRemove.push(LEGACY_MAIN);652 if (legacyDecoy) toRemove.push(LEGACY_DECOY);653 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);654 if (toRemove.length > 0) {655 await chrome.storage.local.remove(toRemove);656 didMigrate = true;657 }658 if (didMigrate) {659 await stampLocalVaultMutation();660 }661}662663664665666667668669670671672673function padPlaintext(json: string, minBytes = 0): string {674 const encoded = new TextEncoder().encode(json);675 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);676 677 const target = Math.max(bucket, minBytes);678 const padLen = target - encoded.length;679 return json + " ".repeat(padLen);680}681682683684685686687688689690691692const CSPRNG_CHUNK = 65_536;693694function fillRandom(buf: Uint8Array): Uint8Array {695 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {696 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);697 const chunk = new Uint8Array(end - offset);698 crypto.getRandomValues(chunk);699 buf.set(chunk, offset);700 }701 return buf;702}703704function generateNoiseBlob(ciphertextBytes: number): StoredVault {705 const salt = crypto.getRandomValues(new Uint8Array(16));706 const nonce = crypto.getRandomValues(new Uint8Array(12));707 const cipher = fillRandom(new Uint8Array(ciphertextBytes));708 const tag = crypto.getRandomValues(new Uint8Array(16));709 return {710 version: 1,711 salt: hex(salt),712 nonce: hex(nonce),713 ciphertext: hex(cipher),714 tag: hex(tag),715 };716}717718719720721722type VaultPayload = {723 entries: VaultEntry[];724 duressConfigured?: boolean;725};726727type DecryptedPayload = {728 entries: VaultEntry[];729 duressConfigured: boolean;730 731732733734735736737 migratedFromLegacy: boolean;738};739740function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {741 const envelope: VaultPayload = { entries, duressConfigured: duress };742 return padPlaintext(JSON.stringify(envelope), minBytes);743}744745function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {746 if ("created" in raw || "updated" in raw) return true;747 const flat = raw as Partial<VaultEntry>;748 if (flat.type === "passkey") return true;749 750 if (flat.rpId !== undefined && flat.passkey === undefined) return true;751 return false;752}753754function decodePayload(plain: string): DecryptedPayload {755 const parsed = JSON.parse(plain) as756 | Array<VaultEntry | LegacyVaultEntry>757 | VaultPayload;758759 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];760 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);761762 const normalized = rawEntries.map(normalizeEntry);763 const { merged } = mergePasskeyOnlyIntoLogins(normalized);764765 if (Array.isArray(parsed)) {766 return {767 entries: normalized,768 duressConfigured: false,769 migratedFromLegacy: true, 770 };771 }772773 return {774 entries: normalized,775 duressConfigured: parsed.duressConfigured === true,776 migratedFromLegacy: flatMigrationNeeded || merged,777 };778}779780781782783784785786787788789790791792793794795796797798799800801802803function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {804 let merged = false;805 const now = Date.now();806807 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);808 const byRpId = new Map<string, VaultEntry[]>();809 for (const e of candidates) {810 const rpId = e.passkey!.rpId;811 const list = byRpId.get(rpId);812 if (list) list.push(e);813 else byRpId.set(rpId, [e]);814 }815816 for (const [rpId, group] of byRpId) {817 if (group.length === 0) continue;818 group.sort((a, b) => {819 const aT =820 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;821 const bT =822 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;823 return bT - aT;824 });825 const winner = group[0]!;826827 828 829 830 const loginTarget = entries.find((e) => {831 if (e.deletedAt || e.passkey || e.type !== "login") return false;832 if (e.url && extractDomain(e.url) === rpId) return true;833 for (const extra of e.additionalUrls ?? []) {834 if (extractDomain(extra) === rpId) return true;835 }836 return false;837 });838839 if (loginTarget) {840 loginTarget.passkey = winner.passkey;841 loginTarget.modifiedAt = now;842 for (const dup of group) {843 if (!dup.deletedAt) {844 dup.deletedAt = now;845 dup.modifiedAt = now;846 }847 }848 merged = true;849 } else if (group.length > 1) {850 for (let i = 1; i < group.length; i += 1) {851 const dup = group[i]!;852 if (!dup.deletedAt) {853 dup.deletedAt = now;854 dup.modifiedAt = now;855 }856 }857 merged = true;858 }859 }860861 return { merged };862}863864865866867868869870function hasValidVaultSchema(v: unknown): v is StoredVault {871 if (typeof v !== "object" || v === null) return false;872 const r = v as Record<string, unknown>;873 return (874 r.version === 1 &&875 typeof r.salt === "string" &&876 typeof r.nonce === "string" &&877 typeof r.ciphertext === "string" &&878 typeof r.tag === "string"879 );880}881882export type TryDecryptResult =883 | { status: "ok"; payload: DecryptedPayload }884 | { status: "wrong_key" }885 | { status: "corrupted"; reason: string };886887888889890891892export async function tryDecryptSlot(893 vault: StoredVault,894 key: Uint8Array,895 aadTag?: string,896): Promise<DecryptedPayload | null> {897 const res = await tryDecryptSlotResult(vault, key, aadTag);898 return res.status === "ok" ? res.payload : null;899}900901export async function tryDecryptSlotResult(902 vault: StoredVault,903 key: Uint8Array,904 aadTag?: string,905): Promise<TryDecryptResult> {906 if (!hasValidVaultSchema(vault)) {907 return { status: "corrupted", reason: "invalid_slot_schema" };908 }909 let plain: string;910 try {911 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);912 } catch {913 return { status: "wrong_key" };914 }915 try {916 const parsed = JSON.parse(plain) as unknown;917 if (typeof parsed !== "object" || parsed === null) {918 return { status: "corrupted", reason: "invalid_payload_shape" };919 }920 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {921 return { status: "corrupted", reason: "invalid_payload_shape" };922 }923 return { status: "ok", payload: decodePayload(plain) };924 } catch {925 return { status: "corrupted", reason: "invalid_payload_json" };926 }927}928929930931932933function buildDomainIndexSync(all: VaultEntry[]): void {934 domainIndex = new Map();935936 for (const entry of all) {937 if (entry.deletedAt) continue;938 if (entry.type === "note" || entry.type === "card") continue;939940 941 942 943 944 const domains = new Set<string>();945 const primary = extractDomain(entry.url ?? "");946 if (primary) domains.add(primary);947 for (const extra of entry.additionalUrls ?? []) {948 const d = extractDomain(extra);949 if (d) domains.add(d);950 }951 if (domains.size === 0) continue;952953 for (const domain of domains) {954 const bucket = domainIndex.get(domain) ?? [];955 bucket.push(entry);956 domainIndex.set(domain, bucket);957 }958 }959}960961export async function createVault(password: string): Promise<void> {962 await ensureMigrated();963964 const salt = generateSalt();965 derivedKey = await deriveKey(password, salt);966 entries = [];967 vaultMode = "real";968 duressConfigured = false;969 buildDomainIndexSync(entries);970971 972 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =973 await generateAndWrapRootKey();974 rootKey = unhex(rawRootKey);975976 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);977 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");978979 980 981 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);982983 const slotA: StoredVault = {984 version: 1,985 salt: hex(salt),986 nonce,987 ciphertext,988 tag,989 recovery_salt: recoverySalt,990 recovery_wrapped_root_key: wrappedRootKey,991 recovery_nonce: wrappedNonce,992 recovery_tag: wrappedTag,993 ...pwRoot,994 };995 await writeSlot(SLOT_A, slotA);996997 998 999 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1000 await stampLocalVaultMutation();10011002 touchAutoLock();1003}10041005export async function hasVault(): Promise<boolean> {1006 await ensureMigrated();1007 return (await loadSlot(SLOT_A)) !== null;1008}1009101010111012101310141015export async function isDuressEnabled(): Promise<boolean> {1016 return vaultMode === "real" && duressConfigured;1017}10181019export async function setupDuress(duressPassword: string): Promise<void> {1020 if (!derivedKey || vaultMode !== "real") {1021 throw new Error("vault must be unlocked in real mode");1022 }1023 await ensureMigrated();10241025 1026 1027 1028 const slotA = await loadSlot(SLOT_A);1029 if (!slotA) throw new Error("missing slot A");10301031 const decoySalt = generateSalt();1032 const decoyKey = await deriveKey(duressPassword, decoySalt);1033 try {1034 1035 1036 1037 1038 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1039 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1040 await writeSlot(SLOT_B, {1041 version: 1,1042 salt: hex(decoySalt),1043 nonce,1044 ciphertext,1045 tag,1046 });1047 } finally {1048 zeroKey(decoyKey);1049 }10501051 1052 1053 duressConfigured = true;1054 await persistSlotA();1055}10561057export async function disableDuress(): Promise<void> {1058 if (!derivedKey || vaultMode !== "real") {1059 throw new Error("vault must be unlocked in real mode");1060 }1061 await ensureMigrated();10621063 1064 1065 1066 const slotA = await loadSlot(SLOT_A);1067 if (slotA) {1068 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1069 }10701071 duressConfigured = false;1072 await persistSlotA();1073}10741075107610771078107910801081export async function verifyMainPassword(password: string): Promise<boolean> {1082 await ensureMigrated();1083 const slotA = await loadSlot(SLOT_A);1084 if (!slotA) return false;1085 const key = await deriveKey(password, unhex(slotA.salt));1086 try {1087 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1088 } finally {1089 zeroKey(key);1090 }1091}10921093export async function unlock(password: string): Promise<boolean> {1094 1095 1096 1097 if (await attemptUnlockWithDerive(password, deriveKey)) return true;10981099 const nfc = password.normalize("NFC");1100 if (password === nfc) return false; 11011102 if (await attemptUnlockWithDerive(password, deriveKeyRaw)) {1103 1104 1105 1106 if (vaultMode === "real") {1107 try {1108 const newSalt = generateSalt();1109 const newKey = await deriveKey(password, newSalt);1110 await changePasswordWithNewKey(newSalt, newKey);1111 } catch (healErr) {1112 console.warn("[Peach] LV-1 NFC heal failed:", healErr);1113 }1114 }1115 return true;1116 }1117 return false;1118}11191120async function attemptUnlockWithDerive(1121 password: string,1122 derive: typeof deriveKey,1123): Promise<boolean> {1124 await ensureMigrated();11251126 1127 1128 1129 1130 1131 const [slotA, slotB] = await Promise.all([1132 loadSlot(SLOT_A),1133 loadSlot(SLOT_B),1134 ]);11351136 1137 const [keyA, keyB] = await Promise.all([1138 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1139 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1140 ]);11411142 1143 1144 1145 const [resultA, resultB] = await Promise.all([1146 keyA && slotA1147 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1148 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1149 keyB && slotB1150 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1151 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1152 ]);1153 const payloadA = resultA.status === "ok" ? resultA.payload : null;1154 const payloadB = resultB.status === "ok" ? resultB.payload : null;11551156 if (payloadA && keyA) {1157 derivedKey = keyA;1158 void saveSessionKey(keyA, "real");1159 vaultMode = "real";1160 entries = payloadA.entries;1161 duressConfigured = payloadA.duressConfigured;1162 buildDomainIndexSync(entries);1163 await loadSettings();1164 touchAutoLock();1165 if (keyB) zeroKey(keyB);11661167 1168 if (slotA) {1169 const needsRecoverySetup =1170 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1171 if (!needsRecoverySetup) {1172 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1173 }1174 1175 1176 1177 1178 if (needsRecoverySetup || !rootKey) {1179 try {1180 await migrateToRecovery();1181 if (needsRecoverySetup) {1182 1183 1184 1185 await chrome.storage.local.set({ peach_recovery_pending: '1' });1186 }1187 } catch (migrationErr) {1188 console.warn('[Peach] recovery migration failed:', migrationErr);1189 }1190 }1191 }11921193 1194 1195 1196 if (payloadA.migratedFromLegacy) {1197 await persistSlotA();1198 }1199 return true;1200 }12011202 if (payloadB && keyB) {1203 derivedKey = keyB;1204 void saveSessionKey(keyB, "decoy");1205 vaultMode = "decoy";1206 entries = payloadB.entries;1207 duressConfigured = false;1208 buildDomainIndexSync(entries);1209 await loadSettings();1210 touchAutoLock();1211 if (keyA) zeroKey(keyA);1212 return true;1213 }12141215 if (keyA) zeroKey(keyA);1216 if (keyB) zeroKey(keyB);1217 1218 1219 if (resultA.status === "corrupted") {1220 lastUnlockFailureReason = resultA.reason;1221 } else if (resultB.status === "corrupted") {1222 lastUnlockFailureReason = resultB.reason;1223 }1224 return false;1225}12261227122812291230123112321233const SESSION_KEY_STORAGE = "peach_session_dk";12341235async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1236 if (!chrome.storage.session) return;1237 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1238}12391240async function clearSessionKey(): Promise<void> {1241 if (!chrome.storage.session) return;1242 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1243}12441245124612471248124912501251export async function restoreFromSession(): Promise<boolean> {1252 if (derivedKey !== null) return true;1253 if (!chrome.storage.session) return false;1254 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1255 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1256 if (!entry?.keyHex) return false;12571258 const key = unhex(entry.keyHex);1259 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1260 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1261 const slot = await loadSlot(slotKey);1262 if (!slot) { await clearSessionKey(); return false; }12631264 const result = await tryDecryptSlotResult(slot, key, aadTag);1265 if (result.status !== "ok") { await clearSessionKey(); return false; }12661267 derivedKey = key;1268 vaultMode = entry.mode;1269 entries = result.payload.entries;1270 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1271 buildDomainIndexSync(entries);1272 await loadSettings();1273 touchAutoLock();12741275 if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1276 try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { }1277 }12781279 return true;1280}12811282export function lock(): void {1283 void clearSessionKey();1284 if (derivedKey) {1285 zeroKey(derivedKey);1286 derivedKey = null;1287 }1288 if (rootKey) {1289 rootKey.fill(0);1290 rootKey = null;1291 }12921293 entries = [];1294 domainIndex = new Map();1295 vaultMode = "real";1296 duressConfigured = false;1297 clearAutoLock();1298}12991300export function isUnlocked(): boolean {1301 return derivedKey !== null;1302}1303130413051306export async function unlockWithKey(1307 masterKeyHex: string,1308): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1309 try {1310 const masterKeyBytes = unhex(masterKeyHex);1311 const stored = await loadSlot(SLOT_A);1312 if (!stored) return { ok: false, error: "invalid_key" };1313 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1314 if (!payload) return { ok: false, error: "invalid_key" };13151316 derivedKey = masterKeyBytes;1317 vaultMode = "real";1318 entries = payload.entries;1319 duressConfigured = payload.duressConfigured;1320 buildDomainIndexSync(entries);1321 await loadSettings();1322 touchAutoLock();13231324 1325 1326 1327 1328 1329 if (stored.recovery_salt && stored.recovery_wrapped_root_key) {1330 try {1331 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1332 } catch { }1333 }1334 return { ok: true, entries };1335 } catch {1336 return { ok: false, error: "invalid_key" };1337 }1338}13391340134113421343134413451346134713481349135013511352135313541355135613571358export async function unlockFromCodex(params: {1359 slotA: string; 1360 slotB: string | null; 1361 timestamp: number;1362 masterPassphrase: string;1363}): Promise<1364 | { ok: true; entryCount: number }1365 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1366> {1367 let parsedSlotA: StoredVault;1368 let parsedSlotB: StoredVault | null = null;1369 try {1370 const rawA = JSON.parse(params.slotA) as unknown;1371 if (!hasValidVaultSchema(rawA)) {1372 return { ok: false, reason: "invalid_slot_format" };1373 }1374 parsedSlotA = rawA;1375 if (params.slotB !== null) {1376 const rawB = JSON.parse(params.slotB) as unknown;1377 if (!hasValidVaultSchema(rawB)) {1378 return { ok: false, reason: "invalid_slot_format" };1379 }1380 parsedSlotB = rawB;1381 }1382 } catch {1383 return { ok: false, reason: "invalid_slot_format" };1384 }13851386 1387 1388 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1389 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1390 if (!trialPayload) {1391 zeroKey(trialKey);1392 return { ok: false, reason: "wrong_passphrase" };1393 }1394 1395 1396 zeroKey(trialKey);13971398 1399 1400 await takeVaultSnapshot("restore");14011402 await writeSlot(SLOT_A, parsedSlotA);1403 if (parsedSlotB) {1404 await writeSlot(SLOT_B, parsedSlotB);1405 } else {1406 1407 1408 1409 await writeSlot(1410 SLOT_B,1411 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1412 );1413 }1414 await chrome.storage.local.set({1415 peach_last_modified_at: String(params.timestamp),1416 });14171418 1419 1420 const ok = await unlock(params.masterPassphrase);1421 if (!ok) {1422 1423 1424 1425 return { ok: false, reason: "wrong_passphrase" };1426 }1427 return { ok: true, entryCount: getEntries().length };1428}1429143014311432export function getDerivedKeyHex(): string {1433 if (!derivedKey) throw new Error("vault_not_unlocked");1434 return hex(derivedKey);1435}14361437143814391440144114421443144414451446144714481449145014511452export function getRootKeyHex(): string | null {1453 return rootKey ? hex(rootKey) : null;1454}14551456export function getEntries(): VaultEntry[] {1457 if (!derivedKey) throw new Error("vault_locked");1458 return entries.filter((entry) => !entry.deletedAt);1459}14601461const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;14621463export function getIdentities(): SavedIdentity[] {1464 if (!derivedKey) throw new Error("vault_locked");1465 const active = entries.filter((e) => !e.deletedAt && e.type === "login");14661467 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1468 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();14691470 for (const entry of active) {1471 const domain = extractDomain(entry.url ?? "") ?? "";1472 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;14731474 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1475 const existing = map.get(v);1476 if (!existing || modifiedAt > existing.lastUsed) {1477 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1478 } else {1479 map.set(v, { ...existing, count: existing.count + 1 });1480 }1481 }14821483 const uVal = entry.username?.trim();1484 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);14851486 1487 1488 const eVal = entry.email?.trim();1489 if (eVal) tally(emailMap, eVal);1490 }14911492 const toIdentity = (type: "email" | "username") =>1493 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1494 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1495 });14961497 return [1498 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1499 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1500 ];1501}15021503export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1504 if (!derivedKey) throw new Error("vault_locked");15051506 const now = Date.now();1507 const added: VaultEntry[] = [];1508 for (const draft of drafts) {1509 try {1510 const sanitized = sanitizeEntryDraft(draft);1511 const nextEntry: VaultEntry = {1512 ...sanitized,1513 id: crypto.randomUUID(),1514 createdAt: now,1515 modifiedAt: monotonicEntryTimestamp(),1516 };1517 entries.push(nextEntry);1518 added.push(nextEntry);1519 } catch {1520 1521 }1522 }15231524 if (added.length > 0) {1525 await persistEntries();1526 }15271528 return added;1529}153015311532153315341535153615371538export async function restoreEntries(1539 incoming: VaultEntry[],1540 mode: "replace" | "merge"1541): Promise<{ added: number; updated: number; skipped: number }> {1542 if (!derivedKey) throw new Error("vault_locked");15431544 1545 1546 1547 if (mode === "replace" && incoming.length === 0) {1548 throw new Error("refuse to replace with empty entry set");1549 }15501551 let added = 0;1552 let updated = 0;1553 let skipped = 0;15541555 if (mode === "replace") {1556 entries = [];1557 for (const entry of incoming) {1558 const normalized = sanitizeRestoredEntry(entry);1559 if (!normalized) {1560 skipped += 1;1561 continue;1562 }1563 entries.push(normalized);1564 added += 1;1565 }1566 } else {1567 const byId = new Map<string, VaultEntry>();1568 for (const e of entries) byId.set(e.id, e);15691570 for (const entry of incoming) {1571 const normalized = sanitizeRestoredEntry(entry);1572 if (!normalized) {1573 skipped += 1;1574 continue;1575 }1576 const existing = byId.get(normalized.id);1577 if (!existing) {1578 entries.push(normalized);1579 added += 1;1580 } else if (normalized.modifiedAt > existing.modifiedAt) {1581 const idx = entries.findIndex((e) => e.id === existing.id);1582 if (idx !== -1) {1583 entries[idx] = normalized;1584 updated += 1;1585 }1586 } else {1587 skipped += 1;1588 }1589 }1590 }15911592 await persistEntries("import");1593 return { added, updated, skipped };1594}159515961597159815991600160116021603function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1604 if (!entry || typeof entry !== "object") return null;1605 const title = typeof entry.title === "string" ? entry.title.trim() : "";1606 if (!title) return null;1607 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1608 const now = Date.now();1609 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1610 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;16111612 1613 1614 1615 1616 const normalized = normalizeEntry({1617 ...entry,1618 id,1619 title,1620 type: entry.type ?? "login",1621 createdAt,1622 modifiedAt,1623 });16241625 if (normalized.passkey) {1626 normalized.passkey = sanitizePasskey(normalized.passkey);1627 }1628 if (normalized.canary) {1629 normalized.canary = sanitizeCanary(normalized.canary);1630 }1631 1632 normalized.tags = sanitizeTags(normalized.tags);16331634 1635 1636 if (1637 normalized.type === "login" &&1638 !normalized.passkey &&1639 typeof normalized.password !== "string"1640 ) {1641 return null;1642 }1643 if (entry.passkey && !normalized.passkey) {1644 1645 1646 return null;1647 }16481649 return normalized;1650}1651165216531654165516561657function sanitizePasskey(pk: unknown): PasskeyData | undefined {1658 if (!pk || typeof pk !== "object") return undefined;1659 const r = pk as Record<string, unknown>;1660 if (1661 typeof r.rpId !== "string" || !r.rpId ||1662 typeof r.credentialId !== "string" || !r.credentialId ||1663 typeof r.publicKeyCose !== "string" ||1664 typeof r.userHandle !== "string" ||1665 typeof r.userName !== "string"1666 ) {1667 return undefined;1668 }1669 return {1670 rpId: r.rpId,1671 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1672 credentialId: r.credentialId,1673 publicKeyCose: r.publicKeyCose,1674 privateKeyJwk:1675 r.privateKeyJwk && typeof r.privateKeyJwk === "object"1676 ? (r.privateKeyJwk as JsonWebKey)1677 : undefined,1678 userHandle: r.userHandle,1679 userName: r.userName,1680 userDisplayName:1681 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1682 algorithm: r.algorithm === -8 ? -8 : -7,1683 signCount: typeof r.signCount === "number" ? r.signCount : 0,1684 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1685 backupEligible: r.backupEligible === true,1686 backupState: r.backupState === true,1687 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1688 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1689 transports: Array.isArray(r.transports)1690 ? r.transports.filter((t): t is string => typeof t === "string")1691 : undefined,1692 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1693 };1694}16951696169716981699170017011702170317041705function sanitizeCanary(c: unknown): Canary | undefined {1706 if (!c || typeof c !== "object") return undefined;1707 const r = c as Record<string, unknown>;1708 if (typeof r.webhookUrl !== "string") return undefined;1709 let url: URL;1710 try {1711 url = new URL(r.webhookUrl);1712 } catch {1713 return undefined;1714 }1715 if (url.protocol !== "https:") return undefined;1716 return {1717 webhookUrl: url.toString(),1718 label: typeof r.label === "string" ? r.label : undefined,1719 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,1720 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,1721 };1722}17231724172517261727172817291730function sanitizeTags(tags: unknown): string[] | undefined {1731 if (!Array.isArray(tags)) return undefined;1732 const out: string[] = [];1733 for (const t of tags) {1734 if (typeof t !== "string") continue;1735 const trimmed = t.trim();1736 if (!trimmed) continue;1737 out.push(trimmed.slice(0, 256));1738 if (out.length >= 255) break;1739 }1740 return out.length > 0 ? out : undefined;1741}174217431744174517461747export async function clearVaultEntries(1748 confirmation: "REPLACE_ALL"1749): Promise<void> {1750 if (confirmation !== "REPLACE_ALL") {1751 throw new Error("clearVaultEntries requires explicit confirmation");1752 }1753 if (!derivedKey) throw new Error("vault_locked");1754 for (const entry of entries) {1755 if (!entry.deletedAt) {1756 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1757 entry.deletedAt = stamp;1758 entry.modifiedAt = stamp;1759 }1760 }1761 await persistEntries();1762}17631764export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1765 if (!derivedKey) throw new Error("vault_locked");17661767 const sanitized = sanitizeEntryDraft(entry);1768 const now = Date.now();1769 const stamp = monotonicEntryTimestamp();1770 const nextEntry: VaultEntry = {1771 ...sanitized,1772 id: crypto.randomUUID(),1773 createdAt: now,1774 modifiedAt: stamp,1775 1776 1777 passwordChangedAt: sanitized.password ? stamp : undefined,1778 };17791780 1781 1782 1783 const rollback = entries;1784 entries = [...entries, nextEntry];1785 try {1786 await persistEntries();1787 } catch (err) {1788 entries = rollback;1789 throw err;1790 }17911792 1793 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1794 checkBreach(nextEntry.password).then(async (count) => {1795 if (count > 0) {1796 const idx = entries.findIndex((e) => e.id === nextEntry.id);1797 if (idx !== -1) {1798 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };1799 await persistEntries();1800 }1801 }1802 });1803 }18041805 fireSyncTrigger("vault_change");18061807 return nextEntry;1808}18091810181118121813181418151816export async function consolidatePasskeyEntries(): Promise<void> {1817 if (!derivedKey) return;1818 const { merged } = mergePasskeyOnlyIntoLogins(entries);1819 if (merged) {1820 await persistEntries();1821 }1822}18231824export async function updateEntry(1825 id: string,1826 patch: Partial<VaultEntry>1827): Promise<void> {1828 if (!derivedKey) throw new Error("vault_locked");18291830 const idx = entries.findIndex((entry) => entry.id === id);1831 if (idx === -1 || entries[idx].deletedAt) {1832 throw new Error("entry_not_found");1833 }18341835 const current = entries[idx];1836 const sanitizedPatch = sanitizeEntryPatch(patch);1837 1838 1839 1840 const passwordChanged =1841 "password" in patch &&1842 typeof sanitizedPatch.password === "string" &&1843 sanitizedPatch.password !== current.password;1844 const stamp = monotonicEntryTimestamp(current.modifiedAt);1845 1846 1847 1848 1849 1850 1851 1852 const priorPassword = current.password;1853 const shouldCapture =1854 passwordChanged &&1855 typeof priorPassword === "string" &&1856 priorPassword.length > 0 &&1857 current.passwordHistory?.[0]?.password !== priorPassword;1858 const nextHistory = shouldCapture1859 ? [1860 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },1861 ...(current.passwordHistory ?? []),1862 ].slice(0, PASSWORD_HISTORY_MAX)1863 : current.passwordHistory;1864 entries[idx] = {1865 ...current,1866 ...sanitizedPatch,1867 modifiedAt: stamp,1868 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),1869 };1870187118721873 1874 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1875 entries[idx] = current;1876 throw new Error("card number is required for card entries");1877 }18781879 1880 1881 1882 1883 try {1884 await persistEntries();1885 } catch (err) {1886 entries[idx] = current;1887 throw err;1888 }18891890 1891 1892 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1893 entries[idx] = { ...entries[idx], breach: undefined };1894 await persistEntries();1895 const password = entries[idx].password;1896 if (password) {1897 checkBreach(password).then(async (count) => {1898 const i = entries.findIndex((e) => e.id === id);1899 if (i !== -1 && count > 0) {1900 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };1901 await persistEntries();1902 }1903 });1904 }1905 }19061907 fireSyncTrigger("vault_change");1908}19091910export async function deleteEntry(id: string): Promise<void> {1911 if (!derivedKey) throw new Error("vault_locked");19121913 const entry = entries.find((item) => item.id === id);1914 if (!entry || entry.deletedAt) {1915 throw new Error("entry_not_found");1916 }19171918 1919 1920 1921 const previousModifiedAt = entry.modifiedAt;1922 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1923 entry.deletedAt = stamp;1924 entry.modifiedAt = stamp;1925 1926 1927 try {1928 await persistEntries();1929 } catch (err) {1930 delete entry.deletedAt;1931 entry.modifiedAt = previousModifiedAt;1932 throw err;1933 }1934 fireSyncTrigger("vault_change");1935}19361937export function getDeletedEntries(): VaultEntry[] {1938 if (!derivedKey) return [];1939 return entries.filter((e) => !!e.deletedAt);1940}1941194219431944194519461947export async function getVaultVersion(): Promise<number> {1948 try {1949 const raw = await chrome.storage.local.get("peach_last_modified_at");1950 const v = (raw as Record<string, unknown>).peach_last_modified_at;1951 if (typeof v === "number") return v;1952 if (typeof v === "string" && v !== "") {1953 const n = Number.parseInt(v, 10);1954 return Number.isFinite(n) ? n : 0;1955 }1956 return 0;1957 } catch {1958 return 0;1959 }1960}196119621963196419651966196719681969197019711972export async function addEntryDirect(entry: VaultEntry): Promise<void> {1973 if (!derivedKey) throw new Error("vault_locked");1974 entries = [...entries, entry];1975 await persistEntries("sync_received");1976}19771978export async function updateEntryDirect(patch: VaultEntry): Promise<void> {1979 if (!derivedKey) throw new Error("vault_locked");1980 const idx = entries.findIndex((e) => e.id === patch.id);1981 if (idx === -1) throw new Error("entry_not_found");1982 entries[idx] = { ...entries[idx], ...patch };1983 await persistEntries("sync_received");1984}19851986export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {1987 if (!derivedKey) throw new Error("vault_locked");1988 const idx = entries.findIndex((e) => e.id === id);1989 if (idx === -1) throw new Error("entry_not_found");1990 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };1991 await persistEntries("sync_received");1992}1993199419951996199719981999200020012002200320042005export async function applyMergedRemoteEntries(2006 remoteEntries: VaultEntry[],2007 remoteTombstones: Record<string, number>,2008 context: { deviceName?: string } = {},2009): Promise<{ received: number; applied: number; conflicts: number }> {2010 2011 2012 2013 2014 2015 2016 if (!derivedKey) throw new Error("vault_locked");20172018 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2019 let applied = 0;2020 let conflicts = 0;20212022 const byId = new Map<string, VaultEntry>();2023 for (const e of entries) byId.set(e.id, e);20242025 const alerts: SyncAlert[] = [];2026 const deviceName = context.deviceName ?? "paired device";2027 const mergeNow = Date.now();20282029 for (const remote of remoteEntries) {2030 const sanitized = sanitizeRestoredEntry(remote);2031 if (!sanitized) continue;2032 2033 2034 2035 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2036 if (typeof sanitized.deletedAt === "number") {2037 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2038 }2039 const tombstoneTs = remoteTombstones[sanitized.id];2040 if (tombstoneTs !== undefined && clampSyncTimestamp(tombstoneTs, mergeNow) >= sanitized.modifiedAt) {2041 continue;2042 }2043 const local = byId.get(sanitized.id);2044 if (!local) {2045 entries.push(sanitized);2046 byId.set(sanitized.id, sanitized);2047 applied += 1;2048 } else if (sanitized.modifiedAt > local.modifiedAt) {2049 const idx = entries.findIndex((e) => e.id === local.id);2050 if (idx !== -1) {2051 2052 2053 if (2054 !local.deletedAt &&2055 typeof sanitized.password === "string" &&2056 typeof local.password === "string" &&2057 sanitized.password !== local.password2058 ) {2059 alerts.push({2060 type: "REMOTE_PASSWORD_CHANGE",2061 entryId: sanitized.id,2062 entryTitle: sanitized.title,2063 deviceName,2064 timestamp: mergeNow,2065 });2066 }2067 entries[idx] = sanitized;2068 byId.set(sanitized.id, sanitized);2069 applied += 1;2070 if (!local.deletedAt) conflicts += 1;2071 }2072 }2073 }20742075 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2076 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt)) continue;2077 2078 const deletedAt = clampSyncTimestamp(rawDeletedAt, mergeNow);2079 const local = byId.get(id);2080 if (!local) continue;2081 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2082 if (deletedAt < local.modifiedAt) continue;2083 const idx = entries.findIndex((e) => e.id === id);2084 if (idx !== -1) {2085 2086 if (!local.deletedAt) {2087 alerts.push({2088 type: "REMOTE_DELETE",2089 entryId: id,2090 entryTitle: local.title,2091 deviceName,2092 timestamp: mergeNow,2093 });2094 }2095 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2096 applied += 1;2097 }2098 }20992100 if (applied > 0) {2101 await persistEntries("sync_received");2102 buildDomainIndexSync(entries);2103 }2104 2105 2106 await recordSyncAlerts(alerts);2107 return { received, applied, conflicts };2108}21092110export async function restoreEntry(id: string): Promise<void> {2111 if (!derivedKey) throw new Error("vault_locked");2112 const entry = entries.find((e) => e.id === id && e.deletedAt);2113 if (!entry) throw new Error("deleted entry not found");2114 delete entry.deletedAt;2115 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);2116 await persistEntries();2117 buildDomainIndexSync(entries);2118}21192120export async function purgeEntry(id: string): Promise<void> {2121 if (!derivedKey) throw new Error("vault_locked");2122 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2123 if (idx === -1) throw new Error("deleted entry not found");2124 entries.splice(idx, 1);2125 await persistEntries();2126}212721282129export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2130 if (!derivedKey || retentionMs === 0) return 0;2131 const cutoff = Date.now() - retentionMs;2132 const before = entries.length;2133 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2134 const purged = before - entries.length;2135 if (purged > 0) await persistEntries();2136 return purged;2137}21382139export function queryDomain(domain: string): VaultEntry[] {2140 return domainIndex.get(domain) ?? [];2141}21422143export function getEntryById(id: string): VaultEntry | undefined {2144 return entries.find((entry) => entry.id === id && !entry.deletedAt);2145}21462147async function persistSlotA(2148 reason: VaultSnapshot["reason"] = "local_save",2149): Promise<StoredVault> {2150 if (!derivedKey) throw new Error("vault_locked");21512152 const slotA = await loadSlot(SLOT_A);2153 if (!slotA) throw new Error("missing slot A");21542155 2156 2157 await takeVaultSnapshot(reason);21582159 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2160 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");21612162 2163 const nextSlotA: StoredVault = {2164 ...slotA,2165 nonce,2166 ciphertext,2167 tag,2168 2169 };2170 await writeSlot(SLOT_A, nextSlotA);21712172 2173 2174 2175 2176 2177 2178 try {2179 2180 2181 2182 if (!duressConfigured) {2183 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2184 }2185 2186 2187 2188 2189 2190 if (reason !== "sync_received") {2191 await stampLocalVaultMutation();2192 }2193 } catch (err) {2194 2195 2196 await writeSlot(SLOT_A, slotA).catch(() => {});2197 throw err;2198 }21992200 return nextSlotA;2201}22022203async function persistSlotB(): Promise<void> {2204 if (!derivedKey) throw new Error("vault_locked");22052206 const slotB = await loadSlot(SLOT_B);2207 if (!slotB) throw new Error("missing slot B");22082209 2210 2211 2212 2213 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2214 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");22152216 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2217 2218 2219 2220 try {2221 await stampLocalVaultMutation();2222 } catch (err) {2223 await writeSlot(SLOT_B, slotB).catch(() => {});2224 throw err;2225 }2226}22272228async function persistEntries(2229 reason: VaultSnapshot["reason"] = "local_save",2230): Promise<void> {2231 if (!derivedKey) throw new Error("vault_locked");22322233 if (vaultMode === "decoy") {2234 await persistSlotB();2235 } else {2236 await persistSlotA(reason);2237 }2238 buildDomainIndexSync(entries);2239}22402241function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2242 if (!Array.isArray(values)) return undefined;2243 const seen = new Set<string>();2244 const cleaned: string[] = [];2245 for (const value of values) {2246 const trimmed = typeof value === "string" ? value.trim() : "";2247 if (!trimmed) continue;2248 const key = trimmed.toLowerCase();2249 if (seen.has(key)) continue;2250 seen.add(key);2251 cleaned.push(trimmed);2252 }2253 return cleaned.length > 0 ? cleaned : undefined;2254}22552256function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2257 const title = entry.title.trim();2258 2259 2260 2261 let type: VaultEntry["type"] = entry.type ?? "login";2262 if (type === "passkey") type = "login";22632264 if (!title) throw new Error("title is required");22652266 if (entry.passkey) {2267 const p = entry.passkey;2268 if (2269 !p.rpId ||2270 !p.credentialId ||2271 !p.publicKeyCose ||2272 !p.userHandle ||2273 !p.userName ||2274 (p.algorithm !== -7 && p.algorithm !== -8)2275 ) {2276 throw new Error("invalid passkey credential");2277 }2278 }2279 if (type === "card") {2280 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2281 throw new Error("cardholder name and card number are required");2282 }2283 }22842285 return {2286 type,2287 title,2288 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2289 email: type === "login" ? entry.email?.trim() || undefined : undefined,2290 password: type === "login" ? entry.password : undefined,2291 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2292 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2293 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2294 2295 2296 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2297 totpDigits: type === "login" ? entry.totpDigits : undefined,2298 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2299 notes: entry.notes?.trim() || undefined,2300 breach: type === "login" ? entry.breach : undefined,2301 2302 2303 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2304 tags: sanitizeTags(entry.tags),2305 passkey: entry.passkey,2306 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2307 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2308 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2309 expYear: type === "card" ? entry.expYear?.trim() : undefined,2310 cvc: type === "card" ? entry.cvc?.trim() : undefined,2311 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2312 network: type === "card" ? entry.network : undefined,2313 last4: type === "card" ? entry.last4?.trim() : undefined,2314 };2315}23162317function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2318 const nextPatch: Partial<VaultEntry> = {};23192320 if ("type" in patch) {2321 nextPatch.type = patch.type;2322 }23232324 if ("title" in patch) {2325 const title = patch.title?.trim() ?? "";2326 if (!title) throw new Error("title is required");2327 nextPatch.title = title;2328 }23292330 if ("url" in patch) {2331 nextPatch.url = patch.url?.trim() ?? "";2332 }23332334 if ("additionalUrls" in patch) {2335 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2336 const seen = new Set<string>();2337 const cleaned: string[] = [];2338 for (const value of incoming) {2339 const trimmed = typeof value === "string" ? value.trim() : "";2340 if (!trimmed) continue;2341 const key = trimmed.toLowerCase();2342 if (seen.has(key)) continue;2343 seen.add(key);2344 cleaned.push(trimmed);2345 }2346 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2347 }23482349 if ("username" in patch) {2350 nextPatch.username = patch.username?.trim() ?? "";2351 }23522353 if ("email" in patch) {2354 nextPatch.email = patch.email?.trim() || undefined;2355 }23562357 if ("password" in patch) {2358 const password = patch.password ?? "";2359 2360 nextPatch.password = password || undefined;2361 }23622363 if ("totp" in patch) {2364 nextPatch.totp = patch.totp?.trim() || undefined;2365 }23662367 if ("notes" in patch) {2368 nextPatch.notes = patch.notes?.trim() || undefined;2369 }23702371 if ("breach" in patch) {2372 nextPatch.breach = patch.breach;2373 }23742375 if ("canary" in patch) {2376 nextPatch.canary = patch.canary;2377 }23782379 if ("passkey" in patch) {2380 2381 2382 2383 nextPatch.passkey = patch.passkey;2384 }23852386 if ("tags" in patch) {2387 nextPatch.tags = patch.tags;2388 }23892390 if ("cardholderName" in patch) {2391 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2392 }2393 if ("cardNumber" in patch) {2394 const n = patch.cardNumber ?? "";2395 nextPatch.cardNumber = n || undefined;2396 }2397 if ("expMonth" in patch) {2398 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2399 }2400 if ("expYear" in patch) {2401 nextPatch.expYear = patch.expYear?.trim() || undefined;2402 }2403 if ("cvc" in patch) {2404 nextPatch.cvc = patch.cvc?.trim() || undefined;2405 }2406 if ("cardNickname" in patch) {2407 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2408 }2409 if ("network" in patch) {2410 nextPatch.network = patch.network;2411 }2412 if ("last4" in patch) {2413 nextPatch.last4 = patch.last4?.trim() || undefined;2414 }24152416 return nextPatch;2417}24182419function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2420 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2421 const modifiedAt =2422 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;24232424 const base: VaultEntry = {2425 id: entry.id,2426 type: "type" in entry ? entry.type : "login",2427 title: entry.title,2428 username: entry.username,2429 email: "email" in entry ? entry.email : undefined,2430 password: entry.password,2431 url: entry.url,2432 additionalUrls:2433 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2434 ? entry.additionalUrls2435 : undefined,2436 totp: entry.totp,2437 2438 totpAlgorithm: entry.totpAlgorithm,2439 totpDigits: entry.totpDigits,2440 totpPeriod: entry.totpPeriod,2441 notes: entry.notes,2442 breach: entry.breach,2443 2444 2445 2446 passwordChangedAt:2447 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2448 2449 2450 2451 passwordHistory:2452 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2453 ? entry.passwordHistory2454 : undefined,2455 canary: "canary" in entry ? entry.canary : undefined,2456 passkey: "passkey" in entry ? entry.passkey : undefined,2457 createdAt,2458 modifiedAt,2459 deletedAt: entry.deletedAt,2460 tags: "tags" in entry ? entry.tags : undefined,2461 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2462 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2463 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2464 expYear: "expYear" in entry ? entry.expYear : undefined,2465 cvc: "cvc" in entry ? entry.cvc : undefined,2466 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2467 network: "network" in entry ? entry.network : undefined,2468 last4: "last4" in entry ? entry.last4 : undefined,2469 };24702471 return migrateLegacyPasskey(base, entry);2472}247324742475247624772478247924802481function migrateLegacyPasskey(2482 base: VaultEntry,2483 raw: VaultEntry | LegacyVaultEntry2484): VaultEntry {2485 if (base.passkey) return base;2486 const flat = raw as Partial<VaultEntry>;2487 const isPasskeyType = base.type === "passkey";2488 const hasFlatPasskeyFields =2489 typeof flat.rpId === "string" &&2490 typeof flat.credentialId === "string" &&2491 typeof flat.publicKeyCose === "string" &&2492 typeof flat.userHandle === "string" &&2493 typeof flat.userName === "string";2494 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2495 if (!hasFlatPasskeyFields) return base;24962497 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2498 base.passkey = {2499 rpId: flat.rpId!,2500 rpName: flat.rpName ?? flat.rpId!,2501 credentialId: flat.credentialId!,2502 publicKeyCose: flat.publicKeyCose!,2503 privateKeyJwk: flat.privateKeyJwk,2504 userHandle: flat.userHandle!,2505 userName: flat.userName!,2506 userDisplayName: flat.userDisplayName ?? flat.userName!,2507 algorithm,2508 signCount: flat.signCount ?? 0,2509 aaguid: flat.aaguid ?? "",2510 backupEligible: flat.backupEligible === true,2511 backupState: flat.backupState === true,2512 lastUsedAt: flat.lastUsedAt,2513 browser: flat.browser,2514 transports: flat.transports,2515 createdAt: base.createdAt,2516 };2517 2518 2519 2520 if (isPasskeyType) base.type = "login";2521 if (!base.username) base.username = flat.userName;2522 if (!base.url) base.url = `https://${flat.rpId}`;2523 return base;2524}2525252625272528252925302531function entryDupeKey(e: VaultEntry): string {2532 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2533 2534 2535 2536 2537 if (e.passkey) {2538 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2539 }2540 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2541}254225432544export function countDuplicates(): number {2545 const best = new Map<string, VaultEntry>();2546 let dupeCount = 0;2547 for (const e of entries.filter((e) => !e.deletedAt)) {2548 const key = entryDupeKey(e);2549 const existing = best.get(key);2550 if (!existing) {2551 best.set(key, e);2552 } else {2553 dupeCount++;2554 2555 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2556 best.set(key, e);2557 }2558 }2559 }2560 return dupeCount;2561}256225632564256525662567export async function deduplicateEntries(): Promise<number> {2568 if (!derivedKey) throw new Error("vault_locked");2569 2570 2571 if (vaultMode === "decoy") {2572 throw new Error("deduplicate disabled in decoy mode");2573 }25742575 const best = new Map<string, VaultEntry>();2576 for (const e of entries.filter((en) => !en.deletedAt)) {2577 const key = entryDupeKey(e);2578 const existing = best.get(key);2579 if (!existing) {2580 best.set(key, e);2581 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2582 best.set(key, e);2583 }2584 }25852586 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2587 let removed = 0;2588 entries = entries.map((e) => {2589 if (!e.deletedAt && !keepIds.has(e.id)) {2590 removed++;2591 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2592 }2593 return e;2594 });25952596 if (removed > 0) await persistEntries();2597 return removed;2598}259926002601260226032604260526062607const HISTORY_KEY = "peach_vault_history";2608const HISTORY_MAX = 5;260926102611261226132614function bucketEntryCount(n: number): number {2615 if (n <= 0) return 0;2616 if (n <= 10) return 10;2617 if (n <= 25) return 25;2618 if (n <= 50) return 50;2619 if (n <= 100) return 100;2620 if (n <= 250) return 250;2621 if (n <= 500) return 500;2622 return Math.ceil(n / 500) * 500;2623}26242625262626272628262926302631export async function takeVaultSnapshot(2632 reason: VaultSnapshot["reason"],2633): Promise<void> {2634 try {2635 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2636 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2637 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];26382639 2640 2641 2642 if (!slotA) return;26432644 const vault_a = JSON.stringify(slotA);2645 const vault_b = JSON.stringify(slotB ?? slotA);26462647 2648 2649 2650 2651 2652 2653 let entryCount: number | null = null;2654 if (derivedKey && vaultMode === "real") {2655 try {2656 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);2657 } catch {2658 entryCount = null;2659 }2660 }26612662 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2663 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2664 ? existingRaw2665 : { snapshots: [] };26662667 2668 2669 2670 const latest = history.snapshots[0];2671 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2672 return;2673 }26742675 const snapshot: VaultSnapshot = {2676 id: crypto.randomUUID(),2677 savedAt: Date.now(),2678 reason,2679 vault_a,2680 vault_b,2681 entryCount,2682 };26832684 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2685 await chrome.storage.local.set({ [HISTORY_KEY]: history });2686 } catch (err) {2687 2688 2689 2690 2691 console.warn('[Peach] takeVaultSnapshot failed:', err);2692 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2693 try {2694 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2695 } catch {2696 2697 }2698 }2699 }2700}270127022703export async function clearVaultHistory(): Promise<void> {2704 try {2705 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2706 } catch {2707 2708 }2709}271027112712export async function getVaultHistory(): Promise<VaultSnapshot[]> {2713 const raw = await chrome.storage.local.get(HISTORY_KEY);2714 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2715 if (!history || !Array.isArray(history.snapshots)) return [];2716 return history.snapshots;2717}27182719272027212722272327242725export async function restoreVaultSnapshot(2726 snapshotId: string,2727): Promise<{ ok: true } | { ok: false; error: string }> {2728 const history = await getVaultHistory();2729 const snapshot = history.find((s) => s.id === snapshotId);2730 if (!snapshot) return { ok: false, error: "snapshot_not_found" };27312732 2733 2734 2735 await takeVaultSnapshot("restore");27362737 let restoredA: StoredVault;2738 let restoredB: StoredVault;2739 try {2740 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2741 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2742 } catch {2743 return { ok: false, error: "snapshot_corrupt" };2744 }27452746 await writeSlot(SLOT_A, restoredA);2747 await writeSlot(SLOT_B, restoredB);2748 await stampLocalVaultMutation();27492750 2751 2752 2753 lock();27542755 return { ok: true };2756}275727582759276027612762276327642765276627672768276927702771export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {2772 if (!derivedKey || vaultMode !== "real") {2773 return { ok: false, error: "vault_locked" };2774 }2775 const slotA = await loadSlot(SLOT_A);2776 if (!slotA) return { ok: false, error: "missing_slot_a" };27772778 2779 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =2780 await generateAndWrapRootKey();27812782 2783 const newRootKey = unhex(rawRootKey);2784 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);27852786 2787 2788 2789 2790 await takeVaultSnapshot("local_save");2791 const nextSlotA: StoredVault = {2792 ...slotA,2793 recovery_salt: recoverySalt,2794 recovery_wrapped_root_key: wrappedRootKey,2795 recovery_nonce: wrappedNonce,2796 recovery_tag: wrappedTag,2797 ...pwRoot,2798 };2799 await writeSlot(SLOT_A, nextSlotA);28002801 2802 if (rootKey) zeroKey(rootKey);2803 rootKey = newRootKey;28042805 2806 const { clearPairedDevices } = await import("./paired-devices");2807 await clearPairedDevices();28082809 2810 await setRecoveryConfirmPending(); 28112812 await stampLocalVaultMutation();2813 return { ok: true };2814}28152816281728182819282028212822export async function changePassword(2823 currentPassword: string,2824 newPassword: string2825): Promise<boolean> {2826 if (!derivedKey || vaultMode !== "real") return false;28272828 2829 const valid = await verifyMainPassword(currentPassword);2830 if (!valid) return false;28312832 const slotA = await loadSlot(SLOT_A);2833 if (!slotA) return false;2834 if (!rootKey) return false;28352836 2837 const newSalt = generateSalt();2838 const newKey = await deriveKey(newPassword, newSalt);28392840 2841 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2842 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");28432844 2845 2846 2847 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);28482849 const finalSlotA: StoredVault = {2850 ...slotA,2851 salt: hex(newSalt),2852 nonce,2853 ciphertext,2854 tag,2855 ...pwRoot,2856 };28572858 2859 2860 2861 await takeVaultSnapshot("local_save");28622863 2864 2865 2866 2867 await writeSlot(SLOT_A, finalSlotA);28682869 2870 2871 2872 zeroKey(derivedKey);2873 derivedKey = newKey;28742875 2876 2877 2878 2879 void saveSessionKey(newKey, "real");28802881 2882 2883 2884 if (!duressConfigured) {2885 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2886 }2887 await stampLocalVaultMutation();28882889 return true;2890}