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 231 232 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {233 headers: { "Add-Padding": "true" },234 });235 if (!res.ok) return 0;236 const text = await res.text();237 const match = text.split("\r\n").find((l) => l.startsWith(suffix));238 return match ? parseInt(match.split(":")[1], 10) : 0;239 } catch {240 return 0; 241 }242}243244export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {245 const fingerprint = await cardFingerprint(last4, expMonth, expYear);246 await loadSettings();247 settings = {248 ...settings,249 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],250 };251 await persistSettings();252}253254export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {255 await loadSettings();256 const fingerprint = await cardFingerprint(last4, expMonth, expYear);257 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);258}259260async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {261 const encoder = new TextEncoder();262 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));263 return Array.from(new Uint8Array(buf).slice(0, 8))264 .map((b) => b.toString(16).padStart(2, "0"))265 .join("");266}267268function hex(buf: Uint8Array): string {269 return Array.from(buf)270 .map((b) => b.toString(16).padStart(2, "0"))271 .join("");272}273274function unhex(s: string): Uint8Array {275 const out = new Uint8Array(s.length / 2);276 for (let i = 0; i < s.length; i += 2) {277 out[i / 2] = parseInt(s.slice(i, i + 2), 16);278 }279 return out;280}281282283284285286287288289290291292293294295296297298299300301async function generateAndWrapRootKey(): Promise<{302 rawRootKey: string; 303 recoverySalt: string;304 wrappedRootKey: string;305 wrappedNonce: string;306 wrappedTag: string;307}> {308 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));309310 311 const words = await rootKeyToWords(rootKeyBytes);312 const wordMaterial = await wordsToRootKeyMaterial(words);313 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));314 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);315316 317 318 const nonce = crypto.getRandomValues(new Uint8Array(12));319 const encoded = new TextEncoder().encode(hex(rootKeyBytes));320 const raw = await crypto.subtle.encrypt(321 {322 name: "AES-GCM",323 iv: nonce,324 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,325 tagLength: 128,326 },327 recoveryKey,328 encoded329 );330 331 const rawU8 = new Uint8Array(raw);332 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));333 const tag = hex(rawU8.slice(rawU8.length - 16));334335 return {336 rawRootKey: hex(rootKeyBytes),337 recoverySalt: hex(recoverySalt),338 wrappedRootKey: ciphertext,339 wrappedNonce: hex(nonce),340 wrappedTag: tag,341 };342}343344345346347348349export async function unwrapRootKeyForRecovery(350 wrappedRootKey: string,351 wrappedNonce: string,352 wrappedTag: string,353 recoverySaltHex: string,354 words: string[]355): Promise<void> {356 const wordMaterial = await wordsToRootKeyMaterial(words);357 const recoverySalt = unhex(recoverySaltHex);358 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);359360 361 362 363 364 365 const nonceRaw = unhex(wrappedNonce);366 const ciphertextRaw = unhex(wrappedRootKey);367 const tagRaw = unhex(wrappedTag);368 369 370 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;371 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);372 combined.set(ciphertextRaw, 0);373 combined.set(tagRaw, ciphertextRaw.length);374 const raw = await crypto.subtle.decrypt(375 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },376 recoveryKey,377 combined378 );379 const decryptedHex = new TextDecoder().decode(raw);380 rootKey = unhex(decryptedHex);381}382383384385386387388389390391392export async function rewrapRootKeyForPassword(393 newDerivedKey: Uint8Array,394 _newSalt: Uint8Array395): Promise<void> {396 if (!rootKey) throw new Error("root key not set");397 const slotA = await loadSlot(SLOT_A);398 if (!slotA) throw new Error("missing slot A");399 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);400 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });401}402403404405406407408async function reencryptVaultForNewKey(409 newKey: Uint8Array,410 slotA: StoredVault411): Promise<void> {412 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);413 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");414 await writeSlot(SLOT_A, {415 ...slotA,416 nonce,417 ciphertext,418 tag,419 });420}421422423424425426427export async function changePasswordWithNewKey(428 newSalt: Uint8Array,429 newKey: Uint8Array430): Promise<void> {431 const slotA = await loadSlot(SLOT_A);432 if (!slotA) throw new Error("missing slot A");433 434 435 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });436 if (derivedKey) zeroKey(derivedKey);437 derivedKey = newKey;438 439 440 441 void saveSessionKey(newKey, "real");442 443 444 445 446 if (rootKey) {447 const slotAfter = await loadSlot(SLOT_A);448 if (slotAfter) {449 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);450 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });451 }452 }453 if (!duressConfigured) {454 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));455 }456 await stampLocalVaultMutation();457}458459460461462463464export { deriveKey, generateSalt };465466export async function loadSlotA(): Promise<StoredVault | null> {467 return loadSlot(SLOT_A);468}469470471472473474475476477478479480481482async function deriveRootKeyFromDerivedKey(483 derivedKeyBytes: Uint8Array,484 slotA: StoredVault,485): Promise<Uint8Array | null> {486 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {487 return null;488 }489 try {490 const rootHex = await decrypt(491 derivedKeyBytes,492 slotA.pw_root_nonce,493 slotA.pw_root_ciphertext,494 slotA.pw_root_tag,495 "pw_root_v1",496 );497 return unhex(rootHex);498 } catch {499 return null;500 }501}502503504505async function wrapRootKeyForDerivedKey(506 derivedKeyBytes: Uint8Array,507 rootKeyBytes: Uint8Array,508): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {509 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");510 return {511 pw_root_ciphertext: ciphertext,512 pw_root_nonce: nonce,513 pw_root_tag: tag,514 };515}516517518519520521522523524export async function migrateToRecovery(): Promise<void> {525 if (!derivedKey || vaultMode !== "real") {526 throw new Error("vault must be unlocked in real mode");527 }528 const slotA = await loadSlot(SLOT_A);529 if (!slotA) throw new Error("missing slot A");530 const hasRecoveryEscrow =531 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;532 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;533 if (hasRecoveryEscrow && hasPwRoot) return; 534535 536 537 538 539 540 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =541 await generateAndWrapRootKey();542 rootKey = unhex(rawRootKey);543544 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);545 const nextSlotA: StoredVault = {546 ...slotA,547 recovery_salt: recoverySalt,548 recovery_wrapped_root_key: wrappedRootKey,549 recovery_nonce: wrappedNonce,550 recovery_tag: wrappedTag,551 ...pwRoot,552 };553 await writeSlot(SLOT_A, nextSlotA);554 await stampLocalVaultMutation();555}556557558559560561562export async function getRecoveryWords(): Promise<string[]> {563 if (!rootKey) {564 throw new Error("recovery not set up — vault may need migration");565 }566 return rootKeyToWords(rootKey);567}568569async function loadSlot(key: string): Promise<StoredVault | null> {570 const result = await chrome.storage.local.get(key);571 return (result as Record<string, StoredVault | undefined>)[key] ?? null;572}573574async function writeSlot(key: string, vault: StoredVault): Promise<void> {575 try {576 await chrome.storage.local.set({ [key]: vault });577 } catch (err) {578 throw mapStorageError(err);579 }580}581582583584585586587588589let lastEntryTimestamp = 0;590function monotonicEntryTimestamp(currentModifiedAt?: number): number {591 const base = Math.max(592 Date.now(),593 (currentModifiedAt ?? 0) + 1,594 lastEntryTimestamp + 1,595 );596 lastEntryTimestamp = base;597 return base;598}599600async function stampLocalVaultMutation(): Promise<void> {601 const raw = await chrome.storage.local.get("peach_last_modified_at");602 const previous = Number(603 (raw as Record<string, unknown>).peach_last_modified_at,604 );605 const next = Math.max(606 Date.now(),607 Number.isFinite(previous) ? previous + 1 : 0,608 );609 await chrome.storage.local.set({610 peach_last_modified_at: String(next),611 });612}613614615616617618619async function ensureMigrated(): Promise<void> {620 if (migrationRan) return;621 migrationRan = true;622623 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];624 const raw = await chrome.storage.local.get(keys);625 const slotAExists = raw[SLOT_A] !== undefined;626 const slotBExists = raw[SLOT_B] !== undefined;627 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;628 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;629630 631 if (!slotAExists && !legacyMain) return;632633 634 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;635636 let didMigrate = false;637 if (legacyMain && !slotAExists) {638 await writeSlot(SLOT_A, legacyMain);639 didMigrate = true;640 }641642 643 if (!slotBExists) {644 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;645 if (legacyDecoy) {646 await writeSlot(SLOT_B, legacyDecoy);647 didMigrate = true;648 } else if (target) {649 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));650 didMigrate = true;651 }652 }653654 const toRemove: string[] = [];655 if (legacyMain) toRemove.push(LEGACY_MAIN);656 if (legacyDecoy) toRemove.push(LEGACY_DECOY);657 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);658 if (toRemove.length > 0) {659 await chrome.storage.local.remove(toRemove);660 didMigrate = true;661 }662 if (didMigrate) {663 await stampLocalVaultMutation();664 }665}666667668669670671672673674675676677function padPlaintext(json: string, minBytes = 0): string {678 const encoded = new TextEncoder().encode(json);679 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);680 681 const target = Math.max(bucket, minBytes);682 const padLen = target - encoded.length;683 return json + " ".repeat(padLen);684}685686687688689690691692693694695696const CSPRNG_CHUNK = 65_536;697698function fillRandom(buf: Uint8Array): Uint8Array {699 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {700 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);701 const chunk = new Uint8Array(end - offset);702 crypto.getRandomValues(chunk);703 buf.set(chunk, offset);704 }705 return buf;706}707708function generateNoiseBlob(ciphertextBytes: number): StoredVault {709 const salt = crypto.getRandomValues(new Uint8Array(16));710 const nonce = crypto.getRandomValues(new Uint8Array(12));711 const cipher = fillRandom(new Uint8Array(ciphertextBytes));712 const tag = crypto.getRandomValues(new Uint8Array(16));713 return {714 version: 1,715 salt: hex(salt),716 nonce: hex(nonce),717 ciphertext: hex(cipher),718 tag: hex(tag),719 };720}721722723724725726type VaultPayload = {727 entries: VaultEntry[];728 duressConfigured?: boolean;729};730731type DecryptedPayload = {732 entries: VaultEntry[];733 duressConfigured: boolean;734 735736737738739740741 migratedFromLegacy: boolean;742};743744function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {745 const envelope: VaultPayload = { entries, duressConfigured: duress };746 return padPlaintext(JSON.stringify(envelope), minBytes);747}748749function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {750 if ("created" in raw || "updated" in raw) return true;751 const flat = raw as Partial<VaultEntry>;752 if (flat.type === "passkey") return true;753 754 if (flat.rpId !== undefined && flat.passkey === undefined) return true;755 return false;756}757758function decodePayload(plain: string): DecryptedPayload {759 const parsed = JSON.parse(plain) as760 | Array<VaultEntry | LegacyVaultEntry>761 | VaultPayload;762763 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];764 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);765766 const normalized = rawEntries.map(normalizeEntry);767 const { merged } = mergePasskeyOnlyIntoLogins(normalized);768769 if (Array.isArray(parsed)) {770 return {771 entries: normalized,772 duressConfigured: false,773 migratedFromLegacy: true, 774 };775 }776777 return {778 entries: normalized,779 duressConfigured: parsed.duressConfigured === true,780 migratedFromLegacy: flatMigrationNeeded || merged,781 };782}783784785786787788789790791792793794795796797798799800801802803804805806807function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {808 let merged = false;809 const now = Date.now();810811 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);812 const byRpId = new Map<string, VaultEntry[]>();813 for (const e of candidates) {814 const rpId = e.passkey!.rpId;815 const list = byRpId.get(rpId);816 if (list) list.push(e);817 else byRpId.set(rpId, [e]);818 }819820 for (const [rpId, group] of byRpId) {821 if (group.length === 0) continue;822 group.sort((a, b) => {823 const aT =824 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;825 const bT =826 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;827 return bT - aT;828 });829 const winner = group[0]!;830831 832 833 834 const loginTarget = entries.find((e) => {835 if (e.deletedAt || e.passkey || e.type !== "login") return false;836 if (e.url && extractDomain(e.url) === rpId) return true;837 for (const extra of e.additionalUrls ?? []) {838 if (extractDomain(extra) === rpId) return true;839 }840 return false;841 });842843 if (loginTarget) {844 loginTarget.passkey = winner.passkey;845 loginTarget.modifiedAt = now;846 for (const dup of group) {847 if (!dup.deletedAt) {848 dup.deletedAt = now;849 dup.modifiedAt = now;850 }851 }852 merged = true;853 } else if (group.length > 1) {854 for (let i = 1; i < group.length; i += 1) {855 const dup = group[i]!;856 if (!dup.deletedAt) {857 dup.deletedAt = now;858 dup.modifiedAt = now;859 }860 }861 merged = true;862 }863 }864865 return { merged };866}867868869870871872873874function hasValidVaultSchema(v: unknown): v is StoredVault {875 if (typeof v !== "object" || v === null) return false;876 const r = v as Record<string, unknown>;877 return (878 r.version === 1 &&879 typeof r.salt === "string" &&880 typeof r.nonce === "string" &&881 typeof r.ciphertext === "string" &&882 typeof r.tag === "string"883 );884}885886export type TryDecryptResult =887 | { status: "ok"; payload: DecryptedPayload }888 | { status: "wrong_key" }889 | { status: "corrupted"; reason: string };890891892893894895896export async function tryDecryptSlot(897 vault: StoredVault,898 key: Uint8Array,899 aadTag?: string,900): Promise<DecryptedPayload | null> {901 const res = await tryDecryptSlotResult(vault, key, aadTag);902 return res.status === "ok" ? res.payload : null;903}904905export async function tryDecryptSlotResult(906 vault: StoredVault,907 key: Uint8Array,908 aadTag?: string,909): Promise<TryDecryptResult> {910 if (!hasValidVaultSchema(vault)) {911 return { status: "corrupted", reason: "invalid_slot_schema" };912 }913 let plain: string;914 try {915 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);916 } catch {917 return { status: "wrong_key" };918 }919 try {920 const parsed = JSON.parse(plain) as unknown;921 if (typeof parsed !== "object" || parsed === null) {922 return { status: "corrupted", reason: "invalid_payload_shape" };923 }924 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {925 return { status: "corrupted", reason: "invalid_payload_shape" };926 }927 return { status: "ok", payload: decodePayload(plain) };928 } catch {929 return { status: "corrupted", reason: "invalid_payload_json" };930 }931}932933934935936937function buildDomainIndexSync(all: VaultEntry[]): void {938 domainIndex = new Map();939940 for (const entry of all) {941 if (entry.deletedAt) continue;942 if (entry.type === "note" || entry.type === "card") continue;943944 945 946 947 948 const domains = new Set<string>();949 const primary = extractDomain(entry.url ?? "");950 if (primary) domains.add(primary);951 for (const extra of entry.additionalUrls ?? []) {952 const d = extractDomain(extra);953 if (d) domains.add(d);954 }955 if (domains.size === 0) continue;956957 for (const domain of domains) {958 const bucket = domainIndex.get(domain) ?? [];959 bucket.push(entry);960 domainIndex.set(domain, bucket);961 }962 }963}964965export async function createVault(password: string): Promise<void> {966 await ensureMigrated();967968 const salt = generateSalt();969 derivedKey = await deriveKey(password, salt);970 entries = [];971 vaultMode = "real";972 duressConfigured = false;973 buildDomainIndexSync(entries);974975 976 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =977 await generateAndWrapRootKey();978 rootKey = unhex(rawRootKey);979980 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);981 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");982983 984 985 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);986987 const slotA: StoredVault = {988 version: 1,989 salt: hex(salt),990 nonce,991 ciphertext,992 tag,993 recovery_salt: recoverySalt,994 recovery_wrapped_root_key: wrappedRootKey,995 recovery_nonce: wrappedNonce,996 recovery_tag: wrappedTag,997 ...pwRoot,998 };999 await writeSlot(SLOT_A, slotA);10001001 1002 1003 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1004 await stampLocalVaultMutation();10051006 touchAutoLock();1007}10081009export async function hasVault(): Promise<boolean> {1010 await ensureMigrated();1011 return (await loadSlot(SLOT_A)) !== null;1012}1013101410151016101710181019export async function isDuressEnabled(): Promise<boolean> {1020 return vaultMode === "real" && duressConfigured;1021}10221023export async function setupDuress(duressPassword: string): Promise<void> {1024 if (!derivedKey || vaultMode !== "real") {1025 throw new Error("vault must be unlocked in real mode");1026 }1027 await ensureMigrated();10281029 1030 1031 1032 const slotA = await loadSlot(SLOT_A);1033 if (!slotA) throw new Error("missing slot A");10341035 const decoySalt = generateSalt();1036 const decoyKey = await deriveKey(duressPassword, decoySalt);1037 try {1038 1039 1040 1041 1042 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1043 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1044 await writeSlot(SLOT_B, {1045 version: 1,1046 salt: hex(decoySalt),1047 nonce,1048 ciphertext,1049 tag,1050 });1051 } finally {1052 zeroKey(decoyKey);1053 }10541055 1056 1057 duressConfigured = true;1058 await persistSlotA();1059}10601061export async function disableDuress(): Promise<void> {1062 if (!derivedKey || vaultMode !== "real") {1063 throw new Error("vault must be unlocked in real mode");1064 }1065 await ensureMigrated();10661067 1068 1069 1070 const slotA = await loadSlot(SLOT_A);1071 if (slotA) {1072 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1073 }10741075 duressConfigured = false;1076 await persistSlotA();1077}10781079108010811082108310841085export async function verifyMainPassword(password: string): Promise<boolean> {1086 await ensureMigrated();1087 const slotA = await loadSlot(SLOT_A);1088 if (!slotA) return false;1089 const key = await deriveKey(password, unhex(slotA.salt));1090 try {1091 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1092 } finally {1093 zeroKey(key);1094 }1095}10961097export async function unlock(password: string): Promise<boolean> {1098 1099 1100 1101 if (await attemptUnlockWithDerive(password, deriveKey)) return true;11021103 const nfc = password.normalize("NFC");1104 if (password === nfc) return false; 11051106 if (await attemptUnlockWithDerive(password, deriveKeyRaw)) {1107 1108 1109 1110 if (vaultMode === "real") {1111 try {1112 const newSalt = generateSalt();1113 const newKey = await deriveKey(password, newSalt);1114 await changePasswordWithNewKey(newSalt, newKey);1115 } catch (healErr) {1116 console.warn("[Peach] LV-1 NFC heal failed:", healErr);1117 }1118 }1119 return true;1120 }1121 return false;1122}11231124async function attemptUnlockWithDerive(1125 password: string,1126 derive: typeof deriveKey,1127): Promise<boolean> {1128 await ensureMigrated();11291130 1131 1132 1133 1134 1135 const [slotA, slotB] = await Promise.all([1136 loadSlot(SLOT_A),1137 loadSlot(SLOT_B),1138 ]);11391140 1141 const [keyA, keyB] = await Promise.all([1142 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1143 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1144 ]);11451146 1147 1148 1149 const [resultA, resultB] = await Promise.all([1150 keyA && slotA1151 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1152 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1153 keyB && slotB1154 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1155 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1156 ]);1157 const payloadA = resultA.status === "ok" ? resultA.payload : null;1158 const payloadB = resultB.status === "ok" ? resultB.payload : null;11591160 if (payloadA && keyA) {1161 derivedKey = keyA;1162 void saveSessionKey(keyA, "real");1163 vaultMode = "real";1164 entries = payloadA.entries;1165 duressConfigured = payloadA.duressConfigured;1166 buildDomainIndexSync(entries);1167 await loadSettings();1168 touchAutoLock();1169 if (keyB) zeroKey(keyB);11701171 1172 if (slotA) {1173 const needsRecoverySetup =1174 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1175 if (!needsRecoverySetup) {1176 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1177 }1178 1179 1180 1181 1182 if (needsRecoverySetup || !rootKey) {1183 try {1184 await migrateToRecovery();1185 if (needsRecoverySetup) {1186 1187 1188 1189 await chrome.storage.local.set({ peach_recovery_pending: '1' });1190 }1191 } catch (migrationErr) {1192 console.warn('[Peach] recovery migration failed:', migrationErr);1193 }1194 }1195 }11961197 1198 1199 1200 if (payloadA.migratedFromLegacy) {1201 await persistSlotA();1202 }1203 return true;1204 }12051206 if (payloadB && keyB) {1207 derivedKey = keyB;1208 void saveSessionKey(keyB, "decoy");1209 vaultMode = "decoy";1210 entries = payloadB.entries;1211 duressConfigured = false;1212 buildDomainIndexSync(entries);1213 await loadSettings();1214 touchAutoLock();1215 if (keyA) zeroKey(keyA);1216 return true;1217 }12181219 if (keyA) zeroKey(keyA);1220 if (keyB) zeroKey(keyB);1221 1222 1223 if (resultA.status === "corrupted") {1224 lastUnlockFailureReason = resultA.reason;1225 } else if (resultB.status === "corrupted") {1226 lastUnlockFailureReason = resultB.reason;1227 }1228 return false;1229}12301231123212331234123512361237const SESSION_KEY_STORAGE = "peach_session_dk";12381239async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1240 if (!chrome.storage.session) return;1241 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1242}12431244async function clearSessionKey(): Promise<void> {1245 if (!chrome.storage.session) return;1246 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1247}12481249125012511252125312541255export async function restoreFromSession(): Promise<boolean> {1256 if (derivedKey !== null) return true;1257 if (!chrome.storage.session) return false;1258 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1259 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1260 if (!entry?.keyHex) return false;12611262 const key = unhex(entry.keyHex);1263 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1264 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1265 const slot = await loadSlot(slotKey);1266 if (!slot) { await clearSessionKey(); return false; }12671268 const result = await tryDecryptSlotResult(slot, key, aadTag);1269 if (result.status !== "ok") { await clearSessionKey(); return false; }12701271 derivedKey = key;1272 vaultMode = entry.mode;1273 entries = result.payload.entries;1274 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1275 buildDomainIndexSync(entries);1276 await loadSettings();1277 touchAutoLock();12781279 if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1280 try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { }1281 }12821283 return true;1284}12851286export function lock(): void {1287 void clearSessionKey();1288 if (derivedKey) {1289 zeroKey(derivedKey);1290 derivedKey = null;1291 }1292 if (rootKey) {1293 rootKey.fill(0);1294 rootKey = null;1295 }12961297 entries = [];1298 domainIndex = new Map();1299 vaultMode = "real";1300 duressConfigured = false;1301 clearAutoLock();1302}13031304export function isUnlocked(): boolean {1305 return derivedKey !== null;1306}1307130813091310export async function unlockWithKey(1311 masterKeyHex: string,1312): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1313 try {1314 const masterKeyBytes = unhex(masterKeyHex);1315 const stored = await loadSlot(SLOT_A);1316 if (!stored) return { ok: false, error: "invalid_key" };1317 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1318 if (!payload) return { ok: false, error: "invalid_key" };13191320 derivedKey = masterKeyBytes;1321 vaultMode = "real";1322 entries = payload.entries;1323 duressConfigured = payload.duressConfigured;1324 buildDomainIndexSync(entries);1325 await loadSettings();1326 touchAutoLock();13271328 1329 1330 1331 1332 1333 if (stored.recovery_salt && stored.recovery_wrapped_root_key) {1334 try {1335 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1336 } catch { }1337 }1338 return { ok: true, entries };1339 } catch {1340 return { ok: false, error: "invalid_key" };1341 }1342}13431344134513461347134813491350135113521353135413551356135713581359136013611362export async function unlockFromCodex(params: {1363 slotA: string; 1364 slotB: string | null; 1365 timestamp: number;1366 masterPassphrase: string;1367}): Promise<1368 | { ok: true; entryCount: number }1369 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1370> {1371 let parsedSlotA: StoredVault;1372 let parsedSlotB: StoredVault | null = null;1373 try {1374 const rawA = JSON.parse(params.slotA) as unknown;1375 if (!hasValidVaultSchema(rawA)) {1376 return { ok: false, reason: "invalid_slot_format" };1377 }1378 parsedSlotA = rawA;1379 if (params.slotB !== null) {1380 const rawB = JSON.parse(params.slotB) as unknown;1381 if (!hasValidVaultSchema(rawB)) {1382 return { ok: false, reason: "invalid_slot_format" };1383 }1384 parsedSlotB = rawB;1385 }1386 } catch {1387 return { ok: false, reason: "invalid_slot_format" };1388 }13891390 1391 1392 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1393 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1394 if (!trialPayload) {1395 zeroKey(trialKey);1396 return { ok: false, reason: "wrong_passphrase" };1397 }1398 1399 1400 zeroKey(trialKey);14011402 1403 1404 await takeVaultSnapshot("restore");14051406 await writeSlot(SLOT_A, parsedSlotA);1407 if (parsedSlotB) {1408 await writeSlot(SLOT_B, parsedSlotB);1409 } else {1410 1411 1412 1413 await writeSlot(1414 SLOT_B,1415 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1416 );1417 }1418 await chrome.storage.local.set({1419 peach_last_modified_at: String(params.timestamp),1420 });14211422 1423 1424 const ok = await unlock(params.masterPassphrase);1425 if (!ok) {1426 1427 1428 1429 return { ok: false, reason: "wrong_passphrase" };1430 }1431 return { ok: true, entryCount: getEntries().length };1432}1433143414351436export function getDerivedKeyHex(): string {1437 if (!derivedKey) throw new Error("vault_not_unlocked");1438 return hex(derivedKey);1439}14401441144214431444144514461447144814491450145114521453145414551456export function getRootKeyHex(): string | null {1457 return rootKey ? hex(rootKey) : null;1458}14591460export function getEntries(): VaultEntry[] {1461 if (!derivedKey) throw new Error("vault_locked");1462 return entries.filter((entry) => !entry.deletedAt);1463}14641465const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;14661467export function getIdentities(): SavedIdentity[] {1468 if (!derivedKey) throw new Error("vault_locked");1469 const active = entries.filter((e) => !e.deletedAt && e.type === "login");14701471 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1472 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();14731474 for (const entry of active) {1475 const domain = extractDomain(entry.url ?? "") ?? "";1476 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;14771478 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1479 const existing = map.get(v);1480 if (!existing || modifiedAt > existing.lastUsed) {1481 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1482 } else {1483 map.set(v, { ...existing, count: existing.count + 1 });1484 }1485 }14861487 const uVal = entry.username?.trim();1488 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);14891490 1491 1492 const eVal = entry.email?.trim();1493 if (eVal) tally(emailMap, eVal);1494 }14951496 const toIdentity = (type: "email" | "username") =>1497 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1498 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1499 });15001501 return [1502 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1503 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1504 ];1505}15061507export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1508 if (!derivedKey) throw new Error("vault_locked");15091510 const now = Date.now();1511 const added: VaultEntry[] = [];1512 for (const draft of drafts) {1513 try {1514 const sanitized = sanitizeEntryDraft(draft);1515 const nextEntry: VaultEntry = {1516 ...sanitized,1517 id: crypto.randomUUID(),1518 createdAt: now,1519 modifiedAt: monotonicEntryTimestamp(),1520 };1521 entries.push(nextEntry);1522 added.push(nextEntry);1523 } catch {1524 1525 }1526 }15271528 if (added.length > 0) {1529 await persistEntries();1530 }15311532 return added;1533}153415351536153715381539154015411542export async function restoreEntries(1543 incoming: VaultEntry[],1544 mode: "replace" | "merge"1545): Promise<{ added: number; updated: number; skipped: number }> {1546 if (!derivedKey) throw new Error("vault_locked");15471548 1549 1550 1551 if (mode === "replace" && incoming.length === 0) {1552 throw new Error("refuse to replace with empty entry set");1553 }15541555 let added = 0;1556 let updated = 0;1557 let skipped = 0;15581559 if (mode === "replace") {1560 entries = [];1561 for (const entry of incoming) {1562 const normalized = sanitizeRestoredEntry(entry);1563 if (!normalized) {1564 skipped += 1;1565 continue;1566 }1567 entries.push(normalized);1568 added += 1;1569 }1570 } else {1571 const byId = new Map<string, VaultEntry>();1572 for (const e of entries) byId.set(e.id, e);15731574 for (const entry of incoming) {1575 const normalized = sanitizeRestoredEntry(entry);1576 if (!normalized) {1577 skipped += 1;1578 continue;1579 }1580 const existing = byId.get(normalized.id);1581 if (!existing) {1582 entries.push(normalized);1583 added += 1;1584 } else if (normalized.modifiedAt > existing.modifiedAt) {1585 const idx = entries.findIndex((e) => e.id === existing.id);1586 if (idx !== -1) {1587 entries[idx] = normalized;1588 updated += 1;1589 }1590 } else {1591 skipped += 1;1592 }1593 }1594 }15951596 await persistEntries("import");1597 return { added, updated, skipped };1598}159916001601160216031604160516061607function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1608 if (!entry || typeof entry !== "object") return null;1609 const title = typeof entry.title === "string" ? entry.title.trim() : "";1610 if (!title) return null;1611 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1612 const now = Date.now();1613 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1614 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;16151616 1617 1618 1619 1620 const normalized = normalizeEntry({1621 ...entry,1622 id,1623 title,1624 type: entry.type ?? "login",1625 createdAt,1626 modifiedAt,1627 });16281629 if (normalized.passkey) {1630 normalized.passkey = sanitizePasskey(normalized.passkey);1631 }1632 if (normalized.canary) {1633 normalized.canary = sanitizeCanary(normalized.canary);1634 }1635 1636 normalized.tags = sanitizeTags(normalized.tags);16371638 1639 1640 if (1641 normalized.type === "login" &&1642 !normalized.passkey &&1643 typeof normalized.password !== "string"1644 ) {1645 return null;1646 }1647 if (entry.passkey && !normalized.passkey) {1648 1649 1650 return null;1651 }16521653 return normalized;1654}1655165616571658165916601661function sanitizePasskey(pk: unknown): PasskeyData | undefined {1662 if (!pk || typeof pk !== "object") return undefined;1663 const r = pk as Record<string, unknown>;1664 if (1665 typeof r.rpId !== "string" || !r.rpId ||1666 typeof r.credentialId !== "string" || !r.credentialId ||1667 typeof r.publicKeyCose !== "string" ||1668 typeof r.userHandle !== "string" ||1669 typeof r.userName !== "string"1670 ) {1671 return undefined;1672 }1673 return {1674 rpId: r.rpId,1675 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1676 credentialId: r.credentialId,1677 publicKeyCose: r.publicKeyCose,1678 privateKeyJwk:1679 r.privateKeyJwk && typeof r.privateKeyJwk === "object"1680 ? (r.privateKeyJwk as JsonWebKey)1681 : undefined,1682 userHandle: r.userHandle,1683 userName: r.userName,1684 userDisplayName:1685 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1686 algorithm: r.algorithm === -8 ? -8 : -7,1687 signCount: typeof r.signCount === "number" ? r.signCount : 0,1688 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1689 backupEligible: r.backupEligible === true,1690 backupState: r.backupState === true,1691 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1692 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1693 transports: Array.isArray(r.transports)1694 ? r.transports.filter((t): t is string => typeof t === "string")1695 : undefined,1696 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1697 };1698}16991700170117021703170417051706170717081709function sanitizeCanary(c: unknown): Canary | undefined {1710 if (!c || typeof c !== "object") return undefined;1711 const r = c as Record<string, unknown>;1712 if (typeof r.webhookUrl !== "string") return undefined;1713 let url: URL;1714 try {1715 url = new URL(r.webhookUrl);1716 } catch {1717 return undefined;1718 }1719 if (url.protocol !== "https:") return undefined;1720 return {1721 webhookUrl: url.toString(),1722 label: typeof r.label === "string" ? r.label : undefined,1723 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,1724 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,1725 };1726}17271728172917301731173217331734function sanitizeTags(tags: unknown): string[] | undefined {1735 if (!Array.isArray(tags)) return undefined;1736 const out: string[] = [];1737 for (const t of tags) {1738 if (typeof t !== "string") continue;1739 const trimmed = t.trim();1740 if (!trimmed) continue;1741 out.push(trimmed.slice(0, 256));1742 if (out.length >= 255) break;1743 }1744 return out.length > 0 ? out : undefined;1745}174617471748174917501751export async function clearVaultEntries(1752 confirmation: "REPLACE_ALL"1753): Promise<void> {1754 if (confirmation !== "REPLACE_ALL") {1755 throw new Error("clearVaultEntries requires explicit confirmation");1756 }1757 if (!derivedKey) throw new Error("vault_locked");1758 for (const entry of entries) {1759 if (!entry.deletedAt) {1760 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1761 entry.deletedAt = stamp;1762 entry.modifiedAt = stamp;1763 }1764 }1765 await persistEntries();1766}17671768export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1769 if (!derivedKey) throw new Error("vault_locked");17701771 const sanitized = sanitizeEntryDraft(entry);1772 const now = Date.now();1773 const stamp = monotonicEntryTimestamp();1774 const nextEntry: VaultEntry = {1775 ...sanitized,1776 id: crypto.randomUUID(),1777 createdAt: now,1778 modifiedAt: stamp,1779 1780 1781 passwordChangedAt: sanitized.password ? stamp : undefined,1782 };17831784 1785 1786 1787 const rollback = entries;1788 entries = [...entries, nextEntry];1789 try {1790 await persistEntries();1791 } catch (err) {1792 entries = rollback;1793 throw err;1794 }17951796 1797 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1798 checkBreach(nextEntry.password).then(async (count) => {1799 if (count > 0) {1800 const idx = entries.findIndex((e) => e.id === nextEntry.id);1801 if (idx !== -1) {1802 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };1803 await persistEntries();1804 }1805 }1806 });1807 }18081809 fireSyncTrigger("vault_change");18101811 return nextEntry;1812}18131814181518161817181818191820export async function consolidatePasskeyEntries(): Promise<void> {1821 if (!derivedKey) return;1822 const { merged } = mergePasskeyOnlyIntoLogins(entries);1823 if (merged) {1824 await persistEntries();1825 }1826}18271828export async function updateEntry(1829 id: string,1830 patch: Partial<VaultEntry>1831): Promise<void> {1832 if (!derivedKey) throw new Error("vault_locked");18331834 const idx = entries.findIndex((entry) => entry.id === id);1835 if (idx === -1 || entries[idx].deletedAt) {1836 throw new Error("entry_not_found");1837 }18381839 const current = entries[idx];1840 const sanitizedPatch = sanitizeEntryPatch(patch);1841 1842 1843 1844 const passwordChanged =1845 "password" in patch &&1846 typeof sanitizedPatch.password === "string" &&1847 sanitizedPatch.password !== current.password;1848 const stamp = monotonicEntryTimestamp(current.modifiedAt);1849 1850 1851 1852 1853 1854 1855 1856 const priorPassword = current.password;1857 const shouldCapture =1858 passwordChanged &&1859 typeof priorPassword === "string" &&1860 priorPassword.length > 0 &&1861 current.passwordHistory?.[0]?.password !== priorPassword;1862 const nextHistory = shouldCapture1863 ? [1864 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },1865 ...(current.passwordHistory ?? []),1866 ].slice(0, PASSWORD_HISTORY_MAX)1867 : current.passwordHistory;1868 entries[idx] = {1869 ...current,1870 ...sanitizedPatch,1871 modifiedAt: stamp,1872 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),1873 };1874187518761877 1878 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1879 entries[idx] = current;1880 throw new Error("card number is required for card entries");1881 }18821883 1884 1885 1886 1887 try {1888 await persistEntries();1889 } catch (err) {1890 entries[idx] = current;1891 throw err;1892 }18931894 1895 1896 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1897 entries[idx] = { ...entries[idx], breach: undefined };1898 await persistEntries();1899 const password = entries[idx].password;1900 if (password) {1901 checkBreach(password).then(async (count) => {1902 const i = entries.findIndex((e) => e.id === id);1903 if (i !== -1 && count > 0) {1904 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };1905 await persistEntries();1906 }1907 });1908 }1909 }19101911 fireSyncTrigger("vault_change");1912}19131914export async function deleteEntry(id: string): Promise<void> {1915 if (!derivedKey) throw new Error("vault_locked");19161917 const entry = entries.find((item) => item.id === id);1918 if (!entry || entry.deletedAt) {1919 throw new Error("entry_not_found");1920 }19211922 1923 1924 1925 const previousModifiedAt = entry.modifiedAt;1926 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1927 entry.deletedAt = stamp;1928 entry.modifiedAt = stamp;1929 1930 1931 try {1932 await persistEntries();1933 } catch (err) {1934 delete entry.deletedAt;1935 entry.modifiedAt = previousModifiedAt;1936 throw err;1937 }1938 fireSyncTrigger("vault_change");1939}19401941export function getDeletedEntries(): VaultEntry[] {1942 if (!derivedKey) return [];1943 return entries.filter((e) => !!e.deletedAt);1944}1945194619471948194919501951export async function getVaultVersion(): Promise<number> {1952 try {1953 const raw = await chrome.storage.local.get("peach_last_modified_at");1954 const v = (raw as Record<string, unknown>).peach_last_modified_at;1955 if (typeof v === "number") return v;1956 if (typeof v === "string" && v !== "") {1957 const n = Number.parseInt(v, 10);1958 return Number.isFinite(n) ? n : 0;1959 }1960 return 0;1961 } catch {1962 return 0;1963 }1964}196519661967196819691970197119721973197419751976export async function addEntryDirect(entry: VaultEntry): Promise<void> {1977 if (!derivedKey) throw new Error("vault_locked");1978 entries = [...entries, entry];1979 await persistEntries("sync_received");1980}19811982export async function updateEntryDirect(patch: VaultEntry): Promise<void> {1983 if (!derivedKey) throw new Error("vault_locked");1984 const idx = entries.findIndex((e) => e.id === patch.id);1985 if (idx === -1) throw new Error("entry_not_found");1986 entries[idx] = { ...entries[idx], ...patch };1987 await persistEntries("sync_received");1988}19891990export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {1991 if (!derivedKey) throw new Error("vault_locked");1992 const idx = entries.findIndex((e) => e.id === id);1993 if (idx === -1) throw new Error("entry_not_found");1994 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };1995 await persistEntries("sync_received");1996}1997199819992000200120022003200420052006200720082009export async function applyMergedRemoteEntries(2010 remoteEntries: VaultEntry[],2011 remoteTombstones: Record<string, number>,2012 context: { deviceName?: string } = {},2013): Promise<{ received: number; applied: number; conflicts: number }> {2014 2015 2016 2017 2018 2019 2020 if (!derivedKey) throw new Error("vault_locked");20212022 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2023 let applied = 0;2024 let conflicts = 0;20252026 const byId = new Map<string, VaultEntry>();2027 for (const e of entries) byId.set(e.id, e);20282029 const alerts: SyncAlert[] = [];2030 const deviceName = context.deviceName ?? "paired device";2031 const mergeNow = Date.now();20322033 for (const remote of remoteEntries) {2034 const sanitized = sanitizeRestoredEntry(remote);2035 if (!sanitized) continue;2036 2037 2038 2039 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2040 if (typeof sanitized.deletedAt === "number") {2041 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2042 }2043 const tombstoneTs = remoteTombstones[sanitized.id];2044 if (tombstoneTs !== undefined && clampSyncTimestamp(tombstoneTs, mergeNow) >= sanitized.modifiedAt) {2045 continue;2046 }2047 const local = byId.get(sanitized.id);2048 if (!local) {2049 entries.push(sanitized);2050 byId.set(sanitized.id, sanitized);2051 applied += 1;2052 } else if (sanitized.modifiedAt > local.modifiedAt) {2053 const idx = entries.findIndex((e) => e.id === local.id);2054 if (idx !== -1) {2055 2056 2057 if (2058 !local.deletedAt &&2059 typeof sanitized.password === "string" &&2060 typeof local.password === "string" &&2061 sanitized.password !== local.password2062 ) {2063 alerts.push({2064 type: "REMOTE_PASSWORD_CHANGE",2065 entryId: sanitized.id,2066 entryTitle: sanitized.title,2067 deviceName,2068 timestamp: mergeNow,2069 });2070 }2071 entries[idx] = sanitized;2072 byId.set(sanitized.id, sanitized);2073 applied += 1;2074 if (!local.deletedAt) conflicts += 1;2075 }2076 }2077 }20782079 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2080 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt)) continue;2081 2082 const deletedAt = clampSyncTimestamp(rawDeletedAt, mergeNow);2083 const local = byId.get(id);2084 if (!local) continue;2085 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2086 if (deletedAt < local.modifiedAt) continue;2087 const idx = entries.findIndex((e) => e.id === id);2088 if (idx !== -1) {2089 2090 if (!local.deletedAt) {2091 alerts.push({2092 type: "REMOTE_DELETE",2093 entryId: id,2094 entryTitle: local.title,2095 deviceName,2096 timestamp: mergeNow,2097 });2098 }2099 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2100 applied += 1;2101 }2102 }21032104 if (applied > 0) {2105 await persistEntries("sync_received");2106 buildDomainIndexSync(entries);2107 }2108 2109 2110 await recordSyncAlerts(alerts);2111 return { received, applied, conflicts };2112}21132114export async function restoreEntry(id: string): Promise<void> {2115 if (!derivedKey) throw new Error("vault_locked");2116 const entry = entries.find((e) => e.id === id && e.deletedAt);2117 if (!entry) throw new Error("deleted entry not found");2118 delete entry.deletedAt;2119 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);2120 await persistEntries();2121 buildDomainIndexSync(entries);2122}21232124export async function purgeEntry(id: string): Promise<void> {2125 if (!derivedKey) throw new Error("vault_locked");2126 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2127 if (idx === -1) throw new Error("deleted entry not found");2128 entries.splice(idx, 1);2129 await persistEntries();2130}213121322133export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2134 if (!derivedKey || retentionMs === 0) return 0;2135 const cutoff = Date.now() - retentionMs;2136 const before = entries.length;2137 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2138 const purged = before - entries.length;2139 if (purged > 0) await persistEntries();2140 return purged;2141}21422143export function queryDomain(domain: string): VaultEntry[] {2144 return domainIndex.get(domain) ?? [];2145}21462147export function getEntryById(id: string): VaultEntry | undefined {2148 return entries.find((entry) => entry.id === id && !entry.deletedAt);2149}21502151async function persistSlotA(2152 reason: VaultSnapshot["reason"] = "local_save",2153): Promise<StoredVault> {2154 if (!derivedKey) throw new Error("vault_locked");21552156 const slotA = await loadSlot(SLOT_A);2157 if (!slotA) throw new Error("missing slot A");21582159 2160 2161 await takeVaultSnapshot(reason);21622163 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2164 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");21652166 2167 const nextSlotA: StoredVault = {2168 ...slotA,2169 nonce,2170 ciphertext,2171 tag,2172 2173 };2174 await writeSlot(SLOT_A, nextSlotA);21752176 2177 2178 2179 2180 2181 2182 try {2183 2184 2185 2186 if (!duressConfigured) {2187 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2188 }2189 2190 2191 2192 2193 2194 if (reason !== "sync_received") {2195 await stampLocalVaultMutation();2196 }2197 } catch (err) {2198 2199 2200 await writeSlot(SLOT_A, slotA).catch(() => {});2201 throw err;2202 }22032204 return nextSlotA;2205}22062207async function persistSlotB(): Promise<void> {2208 if (!derivedKey) throw new Error("vault_locked");22092210 const slotB = await loadSlot(SLOT_B);2211 if (!slotB) throw new Error("missing slot B");22122213 2214 2215 2216 2217 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2218 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");22192220 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2221 2222 2223 2224 try {2225 await stampLocalVaultMutation();2226 } catch (err) {2227 await writeSlot(SLOT_B, slotB).catch(() => {});2228 throw err;2229 }2230}22312232async function persistEntries(2233 reason: VaultSnapshot["reason"] = "local_save",2234): Promise<void> {2235 if (!derivedKey) throw new Error("vault_locked");22362237 if (vaultMode === "decoy") {2238 await persistSlotB();2239 } else {2240 await persistSlotA(reason);2241 }2242 buildDomainIndexSync(entries);2243}22442245function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2246 if (!Array.isArray(values)) return undefined;2247 const seen = new Set<string>();2248 const cleaned: string[] = [];2249 for (const value of values) {2250 const trimmed = typeof value === "string" ? value.trim() : "";2251 if (!trimmed) continue;2252 const key = trimmed.toLowerCase();2253 if (seen.has(key)) continue;2254 seen.add(key);2255 cleaned.push(trimmed);2256 }2257 return cleaned.length > 0 ? cleaned : undefined;2258}22592260function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2261 const title = entry.title.trim();2262 2263 2264 2265 let type: VaultEntry["type"] = entry.type ?? "login";2266 if (type === "passkey") type = "login";22672268 if (!title) throw new Error("title is required");22692270 if (entry.passkey) {2271 const p = entry.passkey;2272 if (2273 !p.rpId ||2274 !p.credentialId ||2275 !p.publicKeyCose ||2276 !p.userHandle ||2277 !p.userName ||2278 (p.algorithm !== -7 && p.algorithm !== -8)2279 ) {2280 throw new Error("invalid passkey credential");2281 }2282 }2283 if (type === "card") {2284 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2285 throw new Error("cardholder name and card number are required");2286 }2287 }22882289 return {2290 type,2291 title,2292 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2293 email: type === "login" ? entry.email?.trim() || undefined : undefined,2294 password: type === "login" ? entry.password : undefined,2295 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2296 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2297 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2298 2299 2300 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2301 totpDigits: type === "login" ? entry.totpDigits : undefined,2302 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2303 notes: entry.notes?.trim() || undefined,2304 breach: type === "login" ? entry.breach : undefined,2305 2306 2307 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2308 tags: sanitizeTags(entry.tags),2309 passkey: entry.passkey,2310 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2311 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2312 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2313 expYear: type === "card" ? entry.expYear?.trim() : undefined,2314 cvc: type === "card" ? entry.cvc?.trim() : undefined,2315 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2316 network: type === "card" ? entry.network : undefined,2317 last4: type === "card" ? entry.last4?.trim() : undefined,2318 };2319}23202321function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2322 const nextPatch: Partial<VaultEntry> = {};23232324 if ("type" in patch) {2325 nextPatch.type = patch.type;2326 }23272328 if ("title" in patch) {2329 const title = patch.title?.trim() ?? "";2330 if (!title) throw new Error("title is required");2331 nextPatch.title = title;2332 }23332334 if ("url" in patch) {2335 nextPatch.url = patch.url?.trim() ?? "";2336 }23372338 if ("additionalUrls" in patch) {2339 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2340 const seen = new Set<string>();2341 const cleaned: string[] = [];2342 for (const value of incoming) {2343 const trimmed = typeof value === "string" ? value.trim() : "";2344 if (!trimmed) continue;2345 const key = trimmed.toLowerCase();2346 if (seen.has(key)) continue;2347 seen.add(key);2348 cleaned.push(trimmed);2349 }2350 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2351 }23522353 if ("username" in patch) {2354 nextPatch.username = patch.username?.trim() ?? "";2355 }23562357 if ("email" in patch) {2358 nextPatch.email = patch.email?.trim() || undefined;2359 }23602361 if ("password" in patch) {2362 const password = patch.password ?? "";2363 2364 nextPatch.password = password || undefined;2365 }23662367 if ("totp" in patch) {2368 nextPatch.totp = patch.totp?.trim() || undefined;2369 }23702371 if ("notes" in patch) {2372 nextPatch.notes = patch.notes?.trim() || undefined;2373 }23742375 if ("breach" in patch) {2376 nextPatch.breach = patch.breach;2377 }23782379 if ("canary" in patch) {2380 2381 2382 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2383 }23842385 if ("passkey" in patch) {2386 2387 2388 2389 if (patch.passkey === undefined || patch.passkey === null) {2390 nextPatch.passkey = undefined;2391 } else {2392 const sanitized = sanitizePasskey(patch.passkey);2393 if (!sanitized) throw new Error("invalid passkey credential");2394 nextPatch.passkey = sanitized;2395 }2396 }23972398 if ("tags" in patch) {2399 2400 nextPatch.tags = sanitizeTags(patch.tags);2401 }24022403 if ("cardholderName" in patch) {2404 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2405 }2406 if ("cardNumber" in patch) {2407 const n = patch.cardNumber ?? "";2408 nextPatch.cardNumber = n || undefined;2409 }2410 if ("expMonth" in patch) {2411 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2412 }2413 if ("expYear" in patch) {2414 nextPatch.expYear = patch.expYear?.trim() || undefined;2415 }2416 if ("cvc" in patch) {2417 nextPatch.cvc = patch.cvc?.trim() || undefined;2418 }2419 if ("cardNickname" in patch) {2420 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2421 }2422 if ("network" in patch) {2423 nextPatch.network = patch.network;2424 }2425 if ("last4" in patch) {2426 nextPatch.last4 = patch.last4?.trim() || undefined;2427 }24282429 return nextPatch;2430}24312432function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2433 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2434 const modifiedAt =2435 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;24362437 const base: VaultEntry = {2438 id: entry.id,2439 type: "type" in entry ? entry.type : "login",2440 title: entry.title,2441 username: entry.username,2442 email: "email" in entry ? entry.email : undefined,2443 password: entry.password,2444 url: entry.url,2445 additionalUrls:2446 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2447 ? entry.additionalUrls2448 : undefined,2449 totp: entry.totp,2450 2451 totpAlgorithm: entry.totpAlgorithm,2452 totpDigits: entry.totpDigits,2453 totpPeriod: entry.totpPeriod,2454 notes: entry.notes,2455 breach: entry.breach,2456 2457 2458 2459 passwordChangedAt:2460 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2461 2462 2463 2464 passwordHistory:2465 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2466 ? entry.passwordHistory2467 : undefined,2468 canary: "canary" in entry ? entry.canary : undefined,2469 passkey: "passkey" in entry ? entry.passkey : undefined,2470 createdAt,2471 modifiedAt,2472 deletedAt: entry.deletedAt,2473 tags: "tags" in entry ? entry.tags : undefined,2474 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2475 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2476 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2477 expYear: "expYear" in entry ? entry.expYear : undefined,2478 cvc: "cvc" in entry ? entry.cvc : undefined,2479 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2480 network: "network" in entry ? entry.network : undefined,2481 last4: "last4" in entry ? entry.last4 : undefined,2482 };24832484 return migrateLegacyPasskey(base, entry);2485}248624872488248924902491249224932494function migrateLegacyPasskey(2495 base: VaultEntry,2496 raw: VaultEntry | LegacyVaultEntry2497): VaultEntry {2498 if (base.passkey) return base;2499 const flat = raw as Partial<VaultEntry>;2500 const isPasskeyType = base.type === "passkey";2501 const hasFlatPasskeyFields =2502 typeof flat.rpId === "string" &&2503 typeof flat.credentialId === "string" &&2504 typeof flat.publicKeyCose === "string" &&2505 typeof flat.userHandle === "string" &&2506 typeof flat.userName === "string";2507 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2508 if (!hasFlatPasskeyFields) return base;25092510 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2511 base.passkey = {2512 rpId: flat.rpId!,2513 rpName: flat.rpName ?? flat.rpId!,2514 credentialId: flat.credentialId!,2515 publicKeyCose: flat.publicKeyCose!,2516 privateKeyJwk: flat.privateKeyJwk,2517 userHandle: flat.userHandle!,2518 userName: flat.userName!,2519 userDisplayName: flat.userDisplayName ?? flat.userName!,2520 algorithm,2521 signCount: flat.signCount ?? 0,2522 aaguid: flat.aaguid ?? "",2523 backupEligible: flat.backupEligible === true,2524 backupState: flat.backupState === true,2525 lastUsedAt: flat.lastUsedAt,2526 browser: flat.browser,2527 transports: flat.transports,2528 createdAt: base.createdAt,2529 };2530 2531 2532 2533 if (isPasskeyType) base.type = "login";2534 if (!base.username) base.username = flat.userName;2535 if (!base.url) base.url = `https://${flat.rpId}`;2536 return base;2537}2538253925402541254225432544function entryDupeKey(e: VaultEntry): string {2545 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2546 2547 2548 2549 2550 if (e.passkey) {2551 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2552 }2553 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2554}255525562557export function countDuplicates(): number {2558 const best = new Map<string, VaultEntry>();2559 let dupeCount = 0;2560 for (const e of entries.filter((e) => !e.deletedAt)) {2561 const key = entryDupeKey(e);2562 const existing = best.get(key);2563 if (!existing) {2564 best.set(key, e);2565 } else {2566 dupeCount++;2567 2568 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2569 best.set(key, e);2570 }2571 }2572 }2573 return dupeCount;2574}257525762577257825792580export async function deduplicateEntries(): Promise<number> {2581 if (!derivedKey) throw new Error("vault_locked");2582 2583 2584 if (vaultMode === "decoy") {2585 throw new Error("deduplicate disabled in decoy mode");2586 }25872588 const best = new Map<string, VaultEntry>();2589 for (const e of entries.filter((en) => !en.deletedAt)) {2590 const key = entryDupeKey(e);2591 const existing = best.get(key);2592 if (!existing) {2593 best.set(key, e);2594 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2595 best.set(key, e);2596 }2597 }25982599 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2600 let removed = 0;2601 entries = entries.map((e) => {2602 if (!e.deletedAt && !keepIds.has(e.id)) {2603 removed++;2604 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2605 }2606 return e;2607 });26082609 if (removed > 0) await persistEntries();2610 return removed;2611}261226132614261526162617261826192620const HISTORY_KEY = "peach_vault_history";2621const HISTORY_MAX = 5;262226232624262526262627function bucketEntryCount(n: number): number {2628 if (n <= 0) return 0;2629 if (n <= 10) return 10;2630 if (n <= 25) return 25;2631 if (n <= 50) return 50;2632 if (n <= 100) return 100;2633 if (n <= 250) return 250;2634 if (n <= 500) return 500;2635 return Math.ceil(n / 500) * 500;2636}26372638263926402641264226432644export async function takeVaultSnapshot(2645 reason: VaultSnapshot["reason"],2646): Promise<void> {2647 try {2648 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2649 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2650 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];26512652 2653 2654 2655 if (!slotA) return;26562657 const vault_a = JSON.stringify(slotA);2658 const vault_b = JSON.stringify(slotB ?? slotA);26592660 2661 2662 2663 2664 2665 2666 let entryCount: number | null = null;2667 if (derivedKey && vaultMode === "real") {2668 try {2669 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);2670 } catch {2671 entryCount = null;2672 }2673 }26742675 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2676 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2677 ? existingRaw2678 : { snapshots: [] };26792680 2681 2682 2683 const latest = history.snapshots[0];2684 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2685 return;2686 }26872688 const snapshot: VaultSnapshot = {2689 id: crypto.randomUUID(),2690 savedAt: Date.now(),2691 reason,2692 vault_a,2693 vault_b,2694 entryCount,2695 };26962697 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2698 await chrome.storage.local.set({ [HISTORY_KEY]: history });2699 } catch (err) {2700 2701 2702 2703 2704 console.warn('[Peach] takeVaultSnapshot failed:', err);2705 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2706 try {2707 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2708 } catch {2709 2710 }2711 }2712 }2713}271427152716export async function clearVaultHistory(): Promise<void> {2717 try {2718 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2719 } catch {2720 2721 }2722}272327242725export async function getVaultHistory(): Promise<VaultSnapshot[]> {2726 const raw = await chrome.storage.local.get(HISTORY_KEY);2727 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2728 if (!history || !Array.isArray(history.snapshots)) return [];2729 return history.snapshots;2730}27312732273327342735273627372738export async function restoreVaultSnapshot(2739 snapshotId: string,2740): Promise<{ ok: true } | { ok: false; error: string }> {2741 const history = await getVaultHistory();2742 const snapshot = history.find((s) => s.id === snapshotId);2743 if (!snapshot) return { ok: false, error: "snapshot_not_found" };27442745 2746 2747 2748 await takeVaultSnapshot("restore");27492750 let restoredA: StoredVault;2751 let restoredB: StoredVault;2752 try {2753 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2754 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2755 } catch {2756 return { ok: false, error: "snapshot_corrupt" };2757 }27582759 await writeSlot(SLOT_A, restoredA);2760 await writeSlot(SLOT_B, restoredB);2761 await stampLocalVaultMutation();27622763 2764 2765 2766 lock();27672768 return { ok: true };2769}277027712772277327742775277627772778277927802781278227832784export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {2785 if (!derivedKey || vaultMode !== "real") {2786 return { ok: false, error: "vault_locked" };2787 }2788 const slotA = await loadSlot(SLOT_A);2789 if (!slotA) return { ok: false, error: "missing_slot_a" };27902791 2792 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =2793 await generateAndWrapRootKey();27942795 2796 const newRootKey = unhex(rawRootKey);2797 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);27982799 2800 2801 2802 2803 await takeVaultSnapshot("local_save");2804 const nextSlotA: StoredVault = {2805 ...slotA,2806 recovery_salt: recoverySalt,2807 recovery_wrapped_root_key: wrappedRootKey,2808 recovery_nonce: wrappedNonce,2809 recovery_tag: wrappedTag,2810 ...pwRoot,2811 };2812 await writeSlot(SLOT_A, nextSlotA);28132814 2815 if (rootKey) zeroKey(rootKey);2816 rootKey = newRootKey;28172818 2819 const { clearPairedDevices } = await import("./paired-devices");2820 await clearPairedDevices();28212822 2823 await setRecoveryConfirmPending(); 28242825 await stampLocalVaultMutation();2826 return { ok: true };2827}28282829283028312832283328342835export async function changePassword(2836 currentPassword: string,2837 newPassword: string2838): Promise<boolean> {2839 if (!derivedKey || vaultMode !== "real") return false;28402841 2842 const valid = await verifyMainPassword(currentPassword);2843 if (!valid) return false;28442845 const slotA = await loadSlot(SLOT_A);2846 if (!slotA) return false;2847 if (!rootKey) return false;28482849 2850 const newSalt = generateSalt();2851 const newKey = await deriveKey(newPassword, newSalt);28522853 2854 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2855 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");28562857 2858 2859 2860 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);28612862 const finalSlotA: StoredVault = {2863 ...slotA,2864 salt: hex(newSalt),2865 nonce,2866 ciphertext,2867 tag,2868 ...pwRoot,2869 };28702871 2872 2873 2874 await takeVaultSnapshot("local_save");28752876 2877 2878 2879 2880 await writeSlot(SLOT_A, finalSlotA);28812882 2883 2884 2885 zeroKey(derivedKey);2886 derivedKey = newKey;28872888 2889 2890 2891 2892 void saveSessionKey(newKey, "real");28932894 2895 2896 2897 if (!duressConfigured) {2898 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2899 }2900 await stampLocalVaultMutation();29012902 return true;2903}