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}277278279280281282283284285286287288289async function generateAndWrapRootKey(): Promise<{290 rawRootKey: string; 291 recoverySalt: string;292 wrappedRootKey: string;293 wrappedNonce: string;294 wrappedTag: string;295}> {296 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));297298 299 const words = await rootKeyToWords(rootKeyBytes);300 const wordMaterial = await wordsToRootKeyMaterial(words);301 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));302 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);303304 305 306 const nonce = crypto.getRandomValues(new Uint8Array(12));307 const encoded = new TextEncoder().encode(hex(rootKeyBytes));308 const raw = await crypto.subtle.encrypt(309 {310 name: "AES-GCM",311 iv: nonce,312 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,313 tagLength: 128,314 },315 recoveryKey,316 encoded317 );318 319 const rawU8 = new Uint8Array(raw);320 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));321 const tag = hex(rawU8.slice(rawU8.length - 16));322323 return {324 rawRootKey: hex(rootKeyBytes),325 recoverySalt: hex(recoverySalt),326 wrappedRootKey: ciphertext,327 wrappedNonce: hex(nonce),328 wrappedTag: tag,329 };330}331332333334335336337export async function unwrapRootKeyForRecovery(338 wrappedRootKey: string,339 wrappedNonce: string,340 wrappedTag: string,341 recoverySaltHex: string,342 words: string[]343): Promise<void> {344 const wordMaterial = await wordsToRootKeyMaterial(words);345 const recoverySalt = unhex(recoverySaltHex);346 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);347348 349 350 351 352 353 const nonceRaw = unhex(wrappedNonce);354 const ciphertextRaw = unhex(wrappedRootKey);355 const tagRaw = unhex(wrappedTag);356 357 358 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;359 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);360 combined.set(ciphertextRaw, 0);361 combined.set(tagRaw, ciphertextRaw.length);362 const raw = await crypto.subtle.decrypt(363 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },364 recoveryKey,365 combined366 );367 const decryptedHex = new TextDecoder().decode(raw);368 rootKey = unhex(decryptedHex);369}370371372373374375376377378379380export async function rewrapRootKeyForPassword(381 newDerivedKey: Uint8Array,382 _newSalt: Uint8Array383): Promise<void> {384 if (!rootKey) throw new Error("root key not set");385 const slotA = await loadSlot(SLOT_A);386 if (!slotA) throw new Error("missing slot A");387 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);388 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });389}390391392393394395396async function reencryptVaultForNewKey(397 newKey: Uint8Array,398 slotA: StoredVault399): Promise<void> {400 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);401 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");402 await writeSlot(SLOT_A, {403 ...slotA,404 nonce,405 ciphertext,406 tag,407 });408}409410411412413414415export async function changePasswordWithNewKey(416 newSalt: Uint8Array,417 newKey: Uint8Array418): Promise<void> {419 const slotA = await loadSlot(SLOT_A);420 if (!slotA) throw new Error("missing slot A");421 422 423 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });424 if (derivedKey) zeroKey(derivedKey);425 derivedKey = newKey;426 427 428 429 void saveSessionKey(newKey, "real");430 431 432 433 434 if (rootKey) {435 const slotAfter = await loadSlot(SLOT_A);436 if (slotAfter) {437 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);438 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });439 }440 }441 if (!duressConfigured) {442 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));443 }444 await stampLocalVaultMutation();445}446447448449450451452export { deriveKey, generateSalt };453454export async function loadSlotA(): Promise<StoredVault | null> {455 return loadSlot(SLOT_A);456}457458459460461462463464465466467468469470async function deriveRootKeyFromDerivedKey(471 derivedKeyBytes: Uint8Array,472 slotA: StoredVault,473): Promise<Uint8Array | null> {474 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {475 return null;476 }477 try {478 const rootHex = await decrypt(479 derivedKeyBytes,480 slotA.pw_root_nonce,481 slotA.pw_root_ciphertext,482 slotA.pw_root_tag,483 "pw_root_v1",484 );485 return unhex(rootHex);486 } catch {487 return null;488 }489}490491492493async function wrapRootKeyForDerivedKey(494 derivedKeyBytes: Uint8Array,495 rootKeyBytes: Uint8Array,496): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {497 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");498 return {499 pw_root_ciphertext: ciphertext,500 pw_root_nonce: nonce,501 pw_root_tag: tag,502 };503}504505506507508509510511512export async function migrateToRecovery(): Promise<void> {513 if (!derivedKey || vaultMode !== "real") {514 throw new Error("vault must be unlocked in real mode");515 }516 const slotA = await loadSlot(SLOT_A);517 if (!slotA) throw new Error("missing slot A");518 const hasRecoveryEscrow =519 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;520 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;521 if (hasRecoveryEscrow && hasPwRoot) return; 522523 524 525 526 527 528 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =529 await generateAndWrapRootKey();530 rootKey = unhex(rawRootKey);531532 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);533 const nextSlotA: StoredVault = {534 ...slotA,535 recovery_salt: recoverySalt,536 recovery_wrapped_root_key: wrappedRootKey,537 recovery_nonce: wrappedNonce,538 recovery_tag: wrappedTag,539 ...pwRoot,540 };541 await writeSlot(SLOT_A, nextSlotA);542 await stampLocalVaultMutation();543}544545546547548549550export async function getRecoveryWords(): Promise<string[]> {551 if (!rootKey) {552 throw new Error("recovery not set up — vault may need migration");553 }554 return rootKeyToWords(rootKey);555}556557async function loadSlot(key: string): Promise<StoredVault | null> {558 const result = await chrome.storage.local.get(key);559 return (result as Record<string, StoredVault | undefined>)[key] ?? null;560}561562async function writeSlot(key: string, vault: StoredVault): Promise<void> {563 try {564 await chrome.storage.local.set({ [key]: vault });565 } catch (err) {566 throw mapStorageError(err);567 }568}569570571572573574575576577let lastEntryTimestamp = 0;578function monotonicEntryTimestamp(currentModifiedAt?: number): number {579 const base = Math.max(580 Date.now(),581 (currentModifiedAt ?? 0) + 1,582 lastEntryTimestamp + 1,583 );584 lastEntryTimestamp = base;585 return base;586}587588async function stampLocalVaultMutation(): Promise<void> {589 const raw = await chrome.storage.local.get("peach_last_modified_at");590 const previous = Number(591 (raw as Record<string, unknown>).peach_last_modified_at,592 );593 const next = Math.max(594 Date.now(),595 Number.isFinite(previous) ? previous + 1 : 0,596 );597 await chrome.storage.local.set({598 peach_last_modified_at: String(next),599 });600}601602603604605606607async function ensureMigrated(): Promise<void> {608 if (migrationRan) return;609 migrationRan = true;610611 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];612 const raw = await chrome.storage.local.get(keys);613 const slotAExists = raw[SLOT_A] !== undefined;614 const slotBExists = raw[SLOT_B] !== undefined;615 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;616 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;617618 619 if (!slotAExists && !legacyMain) return;620621 622 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;623624 let didMigrate = false;625 if (legacyMain && !slotAExists) {626 await writeSlot(SLOT_A, legacyMain);627 didMigrate = true;628 }629630 631 if (!slotBExists) {632 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;633 if (legacyDecoy) {634 await writeSlot(SLOT_B, legacyDecoy);635 didMigrate = true;636 } else if (target) {637 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));638 didMigrate = true;639 }640 }641642 const toRemove: string[] = [];643 if (legacyMain) toRemove.push(LEGACY_MAIN);644 if (legacyDecoy) toRemove.push(LEGACY_DECOY);645 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);646 if (toRemove.length > 0) {647 await chrome.storage.local.remove(toRemove);648 didMigrate = true;649 }650 if (didMigrate) {651 await stampLocalVaultMutation();652 }653}654655656657658659660661662663664665function padPlaintext(json: string, minBytes = 0): string {666 const encoded = new TextEncoder().encode(json);667 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);668 669 const target = Math.max(bucket, minBytes);670 const padLen = target - encoded.length;671 return json + " ".repeat(padLen);672}673674675676677678679680681682683684const CSPRNG_CHUNK = 65_536;685686function fillRandom(buf: Uint8Array): Uint8Array {687 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {688 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);689 const chunk = new Uint8Array(end - offset);690 crypto.getRandomValues(chunk);691 buf.set(chunk, offset);692 }693 return buf;694}695696function generateNoiseBlob(ciphertextBytes: number): StoredVault {697 const salt = crypto.getRandomValues(new Uint8Array(16));698 const nonce = crypto.getRandomValues(new Uint8Array(12));699 const cipher = fillRandom(new Uint8Array(ciphertextBytes));700 const tag = crypto.getRandomValues(new Uint8Array(16));701 return {702 version: 1,703 salt: hex(salt),704 nonce: hex(nonce),705 ciphertext: hex(cipher),706 tag: hex(tag),707 };708}709710711712713714type VaultPayload = {715 entries: VaultEntry[];716 duressConfigured?: boolean;717};718719type DecryptedPayload = {720 entries: VaultEntry[];721 duressConfigured: boolean;722 723724725726727728729 migratedFromLegacy: boolean;730};731732function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {733 const envelope: VaultPayload = { entries, duressConfigured: duress };734 return padPlaintext(JSON.stringify(envelope), minBytes);735}736737function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {738 if ("created" in raw || "updated" in raw) return true;739 const flat = raw as Partial<VaultEntry>;740 if (flat.type === "passkey") return true;741 742 if (flat.rpId !== undefined && flat.passkey === undefined) return true;743 return false;744}745746function decodePayload(plain: string): DecryptedPayload {747 const parsed = JSON.parse(plain) as748 | Array<VaultEntry | LegacyVaultEntry>749 | VaultPayload;750751 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];752 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);753754 const normalized = rawEntries.map(normalizeEntry);755 const { merged } = mergePasskeyOnlyIntoLogins(normalized);756757 if (Array.isArray(parsed)) {758 return {759 entries: normalized,760 duressConfigured: false,761 migratedFromLegacy: true, 762 };763 }764765 return {766 entries: normalized,767 duressConfigured: parsed.duressConfigured === true,768 migratedFromLegacy: flatMigrationNeeded || merged,769 };770}771772773774775776777778779780781782783784785786787788789790791792793794795function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {796 let merged = false;797 const now = Date.now();798799 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);800 const byRpId = new Map<string, VaultEntry[]>();801 for (const e of candidates) {802 const rpId = e.passkey!.rpId;803 const list = byRpId.get(rpId);804 if (list) list.push(e);805 else byRpId.set(rpId, [e]);806 }807808 for (const [rpId, group] of byRpId) {809 if (group.length === 0) continue;810 group.sort((a, b) => {811 const aT =812 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;813 const bT =814 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;815 return bT - aT;816 });817 const winner = group[0]!;818819 820 821 822 const loginTarget = entries.find((e) => {823 if (e.deletedAt || e.passkey || e.type !== "login") return false;824 if (e.url && extractDomain(e.url) === rpId) return true;825 for (const extra of e.additionalUrls ?? []) {826 if (extractDomain(extra) === rpId) return true;827 }828 return false;829 });830831 if (loginTarget) {832 loginTarget.passkey = winner.passkey;833 loginTarget.modifiedAt = now;834 for (const dup of group) {835 if (!dup.deletedAt) {836 dup.deletedAt = now;837 dup.modifiedAt = now;838 }839 }840 merged = true;841 } else if (group.length > 1) {842 for (let i = 1; i < group.length; i += 1) {843 const dup = group[i]!;844 if (!dup.deletedAt) {845 dup.deletedAt = now;846 dup.modifiedAt = now;847 }848 }849 merged = true;850 }851 }852853 return { merged };854}855856857858859860861862function hasValidVaultSchema(v: unknown): v is StoredVault {863 if (typeof v !== "object" || v === null) return false;864 const r = v as Record<string, unknown>;865 return (866 r.version === 1 &&867 typeof r.salt === "string" &&868 typeof r.nonce === "string" &&869 typeof r.ciphertext === "string" &&870 typeof r.tag === "string"871 );872}873874export type TryDecryptResult =875 | { status: "ok"; payload: DecryptedPayload }876 | { status: "wrong_key" }877 | { status: "corrupted"; reason: string };878879880881882883884export async function tryDecryptSlot(885 vault: StoredVault,886 key: Uint8Array,887 aadTag?: string,888): Promise<DecryptedPayload | null> {889 const res = await tryDecryptSlotResult(vault, key, aadTag);890 return res.status === "ok" ? res.payload : null;891}892893export async function tryDecryptSlotResult(894 vault: StoredVault,895 key: Uint8Array,896 aadTag?: string,897): Promise<TryDecryptResult> {898 if (!hasValidVaultSchema(vault)) {899 return { status: "corrupted", reason: "invalid_slot_schema" };900 }901 let plain: string;902 try {903 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);904 } catch {905 return { status: "wrong_key" };906 }907 try {908 const parsed = JSON.parse(plain) as unknown;909 if (typeof parsed !== "object" || parsed === null) {910 return { status: "corrupted", reason: "invalid_payload_shape" };911 }912 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {913 return { status: "corrupted", reason: "invalid_payload_shape" };914 }915 return { status: "ok", payload: decodePayload(plain) };916 } catch {917 return { status: "corrupted", reason: "invalid_payload_json" };918 }919}920921922923924925function buildDomainIndexSync(all: VaultEntry[]): void {926 domainIndex = new Map();927928 for (const entry of all) {929 if (entry.deletedAt) continue;930 if (entry.type === "note" || entry.type === "card") continue;931932 933 934 935 936 const domains = new Set<string>();937 const primary = extractDomain(entry.url ?? "");938 if (primary) domains.add(primary);939 for (const extra of entry.additionalUrls ?? []) {940 const d = extractDomain(extra);941 if (d) domains.add(d);942 }943 if (domains.size === 0) continue;944945 for (const domain of domains) {946 const bucket = domainIndex.get(domain) ?? [];947 bucket.push(entry);948 domainIndex.set(domain, bucket);949 }950 }951}952953export async function createVault(password: string): Promise<void> {954 await ensureMigrated();955956 const salt = generateSalt();957 derivedKey = await deriveKey(password, salt);958 entries = [];959 vaultMode = "real";960 duressConfigured = false;961 buildDomainIndexSync(entries);962963 964 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =965 await generateAndWrapRootKey();966 rootKey = unhex(rawRootKey);967968 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);969 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");970971 972 973 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);974975 const slotA: StoredVault = {976 version: 1,977 salt: hex(salt),978 nonce,979 ciphertext,980 tag,981 recovery_salt: recoverySalt,982 recovery_wrapped_root_key: wrappedRootKey,983 recovery_nonce: wrappedNonce,984 recovery_tag: wrappedTag,985 ...pwRoot,986 };987 await writeSlot(SLOT_A, slotA);988989 990 991 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));992 await stampLocalVaultMutation();993994 touchAutoLock();995}996997export async function hasVault(): Promise<boolean> {998 await ensureMigrated();999 return (await loadSlot(SLOT_A)) !== null;1000}1001100210031004100510061007export async function isDuressEnabled(): Promise<boolean> {1008 return vaultMode === "real" && duressConfigured;1009}10101011export async function setupDuress(duressPassword: string): Promise<void> {1012 if (!derivedKey || vaultMode !== "real") {1013 throw new Error("vault must be unlocked in real mode");1014 }1015 await ensureMigrated();10161017 1018 1019 1020 const slotA = await loadSlot(SLOT_A);1021 if (!slotA) throw new Error("missing slot A");10221023 const decoySalt = generateSalt();1024 const decoyKey = await deriveKey(duressPassword, decoySalt);1025 try {1026 1027 1028 1029 1030 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1031 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1032 await writeSlot(SLOT_B, {1033 version: 1,1034 salt: hex(decoySalt),1035 nonce,1036 ciphertext,1037 tag,1038 });1039 } finally {1040 zeroKey(decoyKey);1041 }10421043 1044 1045 duressConfigured = true;1046 await persistSlotA();1047}10481049export async function disableDuress(): Promise<void> {1050 if (!derivedKey || vaultMode !== "real") {1051 throw new Error("vault must be unlocked in real mode");1052 }1053 await ensureMigrated();10541055 1056 1057 1058 const slotA = await loadSlot(SLOT_A);1059 if (slotA) {1060 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1061 }10621063 duressConfigured = false;1064 await persistSlotA();1065}10661067106810691070107110721073export async function verifyMainPassword(password: string): Promise<boolean> {1074 await ensureMigrated();1075 const slotA = await loadSlot(SLOT_A);1076 if (!slotA) return false;1077 const key = await deriveKey(password, unhex(slotA.salt));1078 try {1079 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1080 } finally {1081 zeroKey(key);1082 }1083}10841085export async function unlock(password: string): Promise<boolean> {1086 1087 1088 1089 if (await attemptUnlockWithDerive(password, deriveKey)) return true;10901091 const nfc = password.normalize("NFC");1092 if (password === nfc) return false; 10931094 if (await attemptUnlockWithDerive(password, deriveKeyRaw)) {1095 1096 1097 1098 if (vaultMode === "real") {1099 try {1100 const newSalt = generateSalt();1101 const newKey = await deriveKey(password, newSalt);1102 await changePasswordWithNewKey(newSalt, newKey);1103 } catch (healErr) {1104 console.warn("[Peach] LV-1 NFC heal failed:", healErr);1105 }1106 }1107 return true;1108 }1109 return false;1110}11111112async function attemptUnlockWithDerive(1113 password: string,1114 derive: typeof deriveKey,1115): Promise<boolean> {1116 await ensureMigrated();11171118 1119 1120 1121 1122 1123 const [slotA, slotB] = await Promise.all([1124 loadSlot(SLOT_A),1125 loadSlot(SLOT_B),1126 ]);11271128 1129 const [keyA, keyB] = await Promise.all([1130 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1131 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1132 ]);11331134 1135 1136 1137 const [resultA, resultB] = await Promise.all([1138 keyA && slotA1139 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1140 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1141 keyB && slotB1142 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1143 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1144 ]);1145 const payloadA = resultA.status === "ok" ? resultA.payload : null;1146 const payloadB = resultB.status === "ok" ? resultB.payload : null;11471148 if (payloadA && keyA) {1149 derivedKey = keyA;1150 void saveSessionKey(keyA, "real");1151 vaultMode = "real";1152 entries = payloadA.entries;1153 duressConfigured = payloadA.duressConfigured;1154 buildDomainIndexSync(entries);1155 await loadSettings();1156 touchAutoLock();1157 if (keyB) zeroKey(keyB);11581159 1160 if (slotA) {1161 const needsRecoverySetup =1162 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1163 if (!needsRecoverySetup) {1164 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1165 }1166 1167 1168 1169 1170 if (needsRecoverySetup || !rootKey) {1171 try {1172 await migrateToRecovery();1173 if (needsRecoverySetup) {1174 1175 1176 1177 await chrome.storage.local.set({ peach_recovery_pending: '1' });1178 }1179 } catch (migrationErr) {1180 console.warn('[Peach] recovery migration failed:', migrationErr);1181 }1182 }1183 }11841185 1186 1187 1188 if (payloadA.migratedFromLegacy) {1189 await persistSlotA();1190 }1191 return true;1192 }11931194 if (payloadB && keyB) {1195 derivedKey = keyB;1196 void saveSessionKey(keyB, "decoy");1197 vaultMode = "decoy";1198 entries = payloadB.entries;1199 duressConfigured = false;1200 buildDomainIndexSync(entries);1201 await loadSettings();1202 touchAutoLock();1203 if (keyA) zeroKey(keyA);1204 return true;1205 }12061207 if (keyA) zeroKey(keyA);1208 if (keyB) zeroKey(keyB);1209 1210 1211 if (resultA.status === "corrupted") {1212 lastUnlockFailureReason = resultA.reason;1213 } else if (resultB.status === "corrupted") {1214 lastUnlockFailureReason = resultB.reason;1215 }1216 return false;1217}12181219122012211222122312241225const SESSION_KEY_STORAGE = "peach_session_dk";12261227async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1228 if (!chrome.storage.session) return;1229 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1230}12311232async function clearSessionKey(): Promise<void> {1233 if (!chrome.storage.session) return;1234 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1235}12361237123812391240124112421243export async function restoreFromSession(): Promise<boolean> {1244 if (derivedKey !== null) return true;1245 if (!chrome.storage.session) return false;1246 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1247 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1248 if (!entry?.keyHex) return false;12491250 const key = unhex(entry.keyHex);1251 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1252 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1253 const slot = await loadSlot(slotKey);1254 if (!slot) { await clearSessionKey(); return false; }12551256 const result = await tryDecryptSlotResult(slot, key, aadTag);1257 if (result.status !== "ok") { await clearSessionKey(); return false; }12581259 derivedKey = key;1260 vaultMode = entry.mode;1261 entries = result.payload.entries;1262 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1263 buildDomainIndexSync(entries);1264 await loadSettings();1265 touchAutoLock();12661267 if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1268 try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { }1269 }12701271 return true;1272}12731274export function lock(): void {1275 void clearSessionKey();1276 if (derivedKey) {1277 zeroKey(derivedKey);1278 derivedKey = null;1279 }1280 if (rootKey) {1281 rootKey.fill(0);1282 rootKey = null;1283 }12841285 entries = [];1286 domainIndex = new Map();1287 vaultMode = "real";1288 duressConfigured = false;1289 clearAutoLock();1290}12911292export function isUnlocked(): boolean {1293 return derivedKey !== null;1294}1295129612971298export async function unlockWithKey(1299 masterKeyHex: string,1300): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1301 try {1302 const masterKeyBytes = unhex(masterKeyHex);1303 const stored = await loadSlot(SLOT_A);1304 if (!stored) return { ok: false, error: "invalid_key" };1305 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1306 if (!payload) return { ok: false, error: "invalid_key" };13071308 derivedKey = masterKeyBytes;1309 vaultMode = "real";1310 entries = payload.entries;1311 duressConfigured = payload.duressConfigured;1312 buildDomainIndexSync(entries);1313 await loadSettings();1314 touchAutoLock();13151316 1317 1318 1319 1320 1321 if (stored.recovery_salt && stored.recovery_wrapped_root_key) {1322 try {1323 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1324 } catch { }1325 }1326 return { ok: true, entries };1327 } catch {1328 return { ok: false, error: "invalid_key" };1329 }1330}13311332133313341335133613371338133913401341134213431344134513461347134813491350export async function unlockFromCodex(params: {1351 slotA: string; 1352 slotB: string | null; 1353 timestamp: number;1354 masterPassphrase: string;1355}): Promise<1356 | { ok: true; entryCount: number }1357 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1358> {1359 let parsedSlotA: StoredVault;1360 let parsedSlotB: StoredVault | null = null;1361 try {1362 const rawA = JSON.parse(params.slotA) as unknown;1363 if (!hasValidVaultSchema(rawA)) {1364 return { ok: false, reason: "invalid_slot_format" };1365 }1366 parsedSlotA = rawA;1367 if (params.slotB !== null) {1368 const rawB = JSON.parse(params.slotB) as unknown;1369 if (!hasValidVaultSchema(rawB)) {1370 return { ok: false, reason: "invalid_slot_format" };1371 }1372 parsedSlotB = rawB;1373 }1374 } catch {1375 return { ok: false, reason: "invalid_slot_format" };1376 }13771378 1379 1380 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1381 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1382 if (!trialPayload) {1383 zeroKey(trialKey);1384 return { ok: false, reason: "wrong_passphrase" };1385 }1386 1387 1388 zeroKey(trialKey);13891390 1391 1392 await takeVaultSnapshot("restore");13931394 await writeSlot(SLOT_A, parsedSlotA);1395 if (parsedSlotB) {1396 await writeSlot(SLOT_B, parsedSlotB);1397 } else {1398 1399 1400 1401 await writeSlot(1402 SLOT_B,1403 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1404 );1405 }1406 await chrome.storage.local.set({1407 peach_last_modified_at: String(params.timestamp),1408 });14091410 1411 1412 const ok = await unlock(params.masterPassphrase);1413 if (!ok) {1414 1415 1416 1417 return { ok: false, reason: "wrong_passphrase" };1418 }1419 return { ok: true, entryCount: getEntries().length };1420}1421142214231424export function getDerivedKeyHex(): string {1425 if (!derivedKey) throw new Error("vault_not_unlocked");1426 return hex(derivedKey);1427}14281429143014311432143314341435143614371438143914401441144214431444export function getRootKeyHex(): string | null {1445 return rootKey ? hex(rootKey) : null;1446}14471448export function getEntries(): VaultEntry[] {1449 if (!derivedKey) throw new Error("vault_locked");1450 return entries.filter((entry) => !entry.deletedAt);1451}14521453const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;14541455export function getIdentities(): SavedIdentity[] {1456 if (!derivedKey) throw new Error("vault_locked");1457 const active = entries.filter((e) => !e.deletedAt && e.type === "login");14581459 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1460 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();14611462 for (const entry of active) {1463 const domain = extractDomain(entry.url ?? "") ?? "";1464 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;14651466 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1467 const existing = map.get(v);1468 if (!existing || modifiedAt > existing.lastUsed) {1469 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1470 } else {1471 map.set(v, { ...existing, count: existing.count + 1 });1472 }1473 }14741475 const uVal = entry.username?.trim();1476 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);14771478 1479 1480 const eVal = entry.email?.trim();1481 if (eVal) tally(emailMap, eVal);1482 }14831484 const toIdentity = (type: "email" | "username") =>1485 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1486 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1487 });14881489 return [1490 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1491 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1492 ];1493}14941495export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1496 if (!derivedKey) throw new Error("vault_locked");14971498 const now = Date.now();1499 const added: VaultEntry[] = [];1500 for (const draft of drafts) {1501 try {1502 const sanitized = sanitizeEntryDraft(draft);1503 const nextEntry: VaultEntry = {1504 ...sanitized,1505 id: crypto.randomUUID(),1506 createdAt: now,1507 modifiedAt: monotonicEntryTimestamp(),1508 };1509 entries.push(nextEntry);1510 added.push(nextEntry);1511 } catch {1512 1513 }1514 }15151516 if (added.length > 0) {1517 await persistEntries();1518 }15191520 return added;1521}152215231524152515261527152815291530export async function restoreEntries(1531 incoming: VaultEntry[],1532 mode: "replace" | "merge"1533): Promise<{ added: number; updated: number; skipped: number }> {1534 if (!derivedKey) throw new Error("vault_locked");15351536 1537 1538 1539 if (mode === "replace" && incoming.length === 0) {1540 throw new Error("refuse to replace with empty entry set");1541 }15421543 let added = 0;1544 let updated = 0;1545 let skipped = 0;15461547 if (mode === "replace") {1548 entries = [];1549 for (const entry of incoming) {1550 const normalized = sanitizeRestoredEntry(entry);1551 if (!normalized) {1552 skipped += 1;1553 continue;1554 }1555 entries.push(normalized);1556 added += 1;1557 }1558 } else {1559 const byId = new Map<string, VaultEntry>();1560 for (const e of entries) byId.set(e.id, e);15611562 for (const entry of incoming) {1563 const normalized = sanitizeRestoredEntry(entry);1564 if (!normalized) {1565 skipped += 1;1566 continue;1567 }1568 const existing = byId.get(normalized.id);1569 if (!existing) {1570 entries.push(normalized);1571 added += 1;1572 } else if (normalized.modifiedAt > existing.modifiedAt) {1573 const idx = entries.findIndex((e) => e.id === existing.id);1574 if (idx !== -1) {1575 entries[idx] = normalized;1576 updated += 1;1577 }1578 } else {1579 skipped += 1;1580 }1581 }1582 }15831584 await persistEntries("import");1585 return { added, updated, skipped };1586}158715881589159015911592159315941595function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1596 if (!entry || typeof entry !== "object") return null;1597 const title = typeof entry.title === "string" ? entry.title.trim() : "";1598 if (!title) return null;1599 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1600 const now = Date.now();1601 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1602 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;16031604 1605 1606 1607 1608 const normalized = normalizeEntry({1609 ...entry,1610 id,1611 title,1612 type: entry.type ?? "login",1613 createdAt,1614 modifiedAt,1615 });16161617 if (normalized.passkey) {1618 normalized.passkey = sanitizePasskey(normalized.passkey);1619 }1620 if (normalized.canary) {1621 normalized.canary = sanitizeCanary(normalized.canary);1622 }1623 1624 normalized.tags = sanitizeTags(normalized.tags);16251626 1627 1628 if (1629 normalized.type === "login" &&1630 !normalized.passkey &&1631 typeof normalized.password !== "string"1632 ) {1633 return null;1634 }1635 if (entry.passkey && !normalized.passkey) {1636 1637 1638 return null;1639 }16401641 return normalized;1642}1643164416451646164716481649function sanitizePasskey(pk: unknown): PasskeyData | undefined {1650 if (!pk || typeof pk !== "object") return undefined;1651 const r = pk as Record<string, unknown>;1652 if (1653 typeof r.rpId !== "string" || !r.rpId ||1654 typeof r.credentialId !== "string" || !r.credentialId ||1655 typeof r.publicKeyCose !== "string" ||1656 typeof r.userHandle !== "string" ||1657 typeof r.userName !== "string"1658 ) {1659 return undefined;1660 }1661 return {1662 rpId: r.rpId,1663 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1664 credentialId: r.credentialId,1665 publicKeyCose: r.publicKeyCose,1666 privateKeyJwk:1667 r.privateKeyJwk && typeof r.privateKeyJwk === "object"1668 ? (r.privateKeyJwk as JsonWebKey)1669 : undefined,1670 userHandle: r.userHandle,1671 userName: r.userName,1672 userDisplayName:1673 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1674 algorithm: r.algorithm === -8 ? -8 : -7,1675 signCount: typeof r.signCount === "number" ? r.signCount : 0,1676 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1677 backupEligible: r.backupEligible === true,1678 backupState: r.backupState === true,1679 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1680 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1681 transports: Array.isArray(r.transports)1682 ? r.transports.filter((t): t is string => typeof t === "string")1683 : undefined,1684 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1685 };1686}16871688168916901691169216931694169516961697function sanitizeCanary(c: unknown): Canary | undefined {1698 if (!c || typeof c !== "object") return undefined;1699 const r = c as Record<string, unknown>;1700 if (typeof r.webhookUrl !== "string") return undefined;1701 let url: URL;1702 try {1703 url = new URL(r.webhookUrl);1704 } catch {1705 return undefined;1706 }1707 if (url.protocol !== "https:") return undefined;1708 return {1709 webhookUrl: url.toString(),1710 label: typeof r.label === "string" ? r.label : undefined,1711 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,1712 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,1713 };1714}17151716171717181719172017211722function sanitizeTags(tags: unknown): string[] | undefined {1723 if (!Array.isArray(tags)) return undefined;1724 const out: string[] = [];1725 for (const t of tags) {1726 if (typeof t !== "string") continue;1727 const trimmed = t.trim();1728 if (!trimmed) continue;1729 out.push(trimmed.slice(0, 256));1730 if (out.length >= 255) break;1731 }1732 return out.length > 0 ? out : undefined;1733}173417351736173717381739export async function clearVaultEntries(1740 confirmation: "REPLACE_ALL"1741): Promise<void> {1742 if (confirmation !== "REPLACE_ALL") {1743 throw new Error("clearVaultEntries requires explicit confirmation");1744 }1745 if (!derivedKey) throw new Error("vault_locked");1746 for (const entry of entries) {1747 if (!entry.deletedAt) {1748 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1749 entry.deletedAt = stamp;1750 entry.modifiedAt = stamp;1751 }1752 }1753 await persistEntries();1754}17551756export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1757 if (!derivedKey) throw new Error("vault_locked");17581759 const sanitized = sanitizeEntryDraft(entry);1760 const now = Date.now();1761 const stamp = monotonicEntryTimestamp();1762 const nextEntry: VaultEntry = {1763 ...sanitized,1764 id: crypto.randomUUID(),1765 createdAt: now,1766 modifiedAt: stamp,1767 1768 1769 passwordChangedAt: sanitized.password ? stamp : undefined,1770 };17711772 1773 1774 1775 const rollback = entries;1776 entries = [...entries, nextEntry];1777 try {1778 await persistEntries();1779 } catch (err) {1780 entries = rollback;1781 throw err;1782 }17831784 1785 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1786 checkBreach(nextEntry.password).then(async (count) => {1787 if (count > 0) {1788 const idx = entries.findIndex((e) => e.id === nextEntry.id);1789 if (idx !== -1) {1790 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };1791 await persistEntries();1792 }1793 }1794 });1795 }17961797 fireSyncTrigger("vault_change");17981799 return nextEntry;1800}18011802180318041805180618071808export async function consolidatePasskeyEntries(): Promise<void> {1809 if (!derivedKey) return;1810 const { merged } = mergePasskeyOnlyIntoLogins(entries);1811 if (merged) {1812 await persistEntries();1813 }1814}18151816export async function updateEntry(1817 id: string,1818 patch: Partial<VaultEntry>1819): Promise<void> {1820 if (!derivedKey) throw new Error("vault_locked");18211822 const idx = entries.findIndex((entry) => entry.id === id);1823 if (idx === -1 || entries[idx].deletedAt) {1824 throw new Error("entry_not_found");1825 }18261827 const current = entries[idx];1828 const sanitizedPatch = sanitizeEntryPatch(patch);1829 1830 1831 1832 const passwordChanged =1833 "password" in patch &&1834 typeof sanitizedPatch.password === "string" &&1835 sanitizedPatch.password !== current.password;1836 const stamp = monotonicEntryTimestamp(current.modifiedAt);1837 1838 1839 1840 1841 1842 1843 1844 const priorPassword = current.password;1845 const shouldCapture =1846 passwordChanged &&1847 typeof priorPassword === "string" &&1848 priorPassword.length > 0 &&1849 current.passwordHistory?.[0]?.password !== priorPassword;1850 const nextHistory = shouldCapture1851 ? [1852 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },1853 ...(current.passwordHistory ?? []),1854 ].slice(0, PASSWORD_HISTORY_MAX)1855 : current.passwordHistory;1856 entries[idx] = {1857 ...current,1858 ...sanitizedPatch,1859 modifiedAt: stamp,1860 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),1861 };1862186318641865 1866 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1867 entries[idx] = current;1868 throw new Error("card number is required for card entries");1869 }18701871 1872 1873 1874 1875 try {1876 await persistEntries();1877 } catch (err) {1878 entries[idx] = current;1879 throw err;1880 }18811882 1883 1884 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1885 entries[idx] = { ...entries[idx], breach: undefined };1886 await persistEntries();1887 const password = entries[idx].password;1888 if (password) {1889 checkBreach(password).then(async (count) => {1890 const i = entries.findIndex((e) => e.id === id);1891 if (i !== -1 && count > 0) {1892 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };1893 await persistEntries();1894 }1895 });1896 }1897 }18981899 fireSyncTrigger("vault_change");1900}19011902export async function deleteEntry(id: string): Promise<void> {1903 if (!derivedKey) throw new Error("vault_locked");19041905 const entry = entries.find((item) => item.id === id);1906 if (!entry || entry.deletedAt) {1907 throw new Error("entry_not_found");1908 }19091910 1911 1912 1913 const previousModifiedAt = entry.modifiedAt;1914 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1915 entry.deletedAt = stamp;1916 entry.modifiedAt = stamp;1917 1918 1919 try {1920 await persistEntries();1921 } catch (err) {1922 delete entry.deletedAt;1923 entry.modifiedAt = previousModifiedAt;1924 throw err;1925 }1926 fireSyncTrigger("vault_change");1927}19281929export function getDeletedEntries(): VaultEntry[] {1930 if (!derivedKey) return [];1931 return entries.filter((e) => !!e.deletedAt);1932}1933193419351936193719381939export async function getVaultVersion(): Promise<number> {1940 try {1941 const raw = await chrome.storage.local.get("peach_last_modified_at");1942 const v = (raw as Record<string, unknown>).peach_last_modified_at;1943 if (typeof v === "number") return v;1944 if (typeof v === "string" && v !== "") {1945 const n = Number.parseInt(v, 10);1946 return Number.isFinite(n) ? n : 0;1947 }1948 return 0;1949 } catch {1950 return 0;1951 }1952}195319541955195619571958195919601961196219631964export async function addEntryDirect(entry: VaultEntry): Promise<void> {1965 if (!derivedKey) throw new Error("vault_locked");1966 entries = [...entries, entry];1967 await persistEntries("sync_received");1968}19691970export async function updateEntryDirect(patch: VaultEntry): Promise<void> {1971 if (!derivedKey) throw new Error("vault_locked");1972 const idx = entries.findIndex((e) => e.id === patch.id);1973 if (idx === -1) throw new Error("entry_not_found");1974 entries[idx] = { ...entries[idx], ...patch };1975 await persistEntries("sync_received");1976}19771978export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {1979 if (!derivedKey) throw new Error("vault_locked");1980 const idx = entries.findIndex((e) => e.id === id);1981 if (idx === -1) throw new Error("entry_not_found");1982 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };1983 await persistEntries("sync_received");1984}1985198619871988198919901991199219931994199519961997export async function applyMergedRemoteEntries(1998 remoteEntries: VaultEntry[],1999 remoteTombstones: Record<string, number>,2000 context: { deviceName?: string } = {},2001): Promise<{ received: number; applied: number; conflicts: number }> {2002 2003 2004 2005 2006 2007 2008 if (!derivedKey) throw new Error("vault_locked");20092010 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2011 let applied = 0;2012 let conflicts = 0;20132014 const byId = new Map<string, VaultEntry>();2015 for (const e of entries) byId.set(e.id, e);20162017 const alerts: SyncAlert[] = [];2018 const deviceName = context.deviceName ?? "paired device";2019 const mergeNow = Date.now();20202021 for (const remote of remoteEntries) {2022 const sanitized = sanitizeRestoredEntry(remote);2023 if (!sanitized) continue;2024 2025 2026 2027 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2028 if (typeof sanitized.deletedAt === "number") {2029 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2030 }2031 const tombstoneTs = remoteTombstones[sanitized.id];2032 if (tombstoneTs !== undefined && clampSyncTimestamp(tombstoneTs, mergeNow) >= sanitized.modifiedAt) {2033 continue;2034 }2035 const local = byId.get(sanitized.id);2036 if (!local) {2037 entries.push(sanitized);2038 byId.set(sanitized.id, sanitized);2039 applied += 1;2040 } else if (sanitized.modifiedAt > local.modifiedAt) {2041 const idx = entries.findIndex((e) => e.id === local.id);2042 if (idx !== -1) {2043 2044 2045 if (2046 !local.deletedAt &&2047 typeof sanitized.password === "string" &&2048 typeof local.password === "string" &&2049 sanitized.password !== local.password2050 ) {2051 alerts.push({2052 type: "REMOTE_PASSWORD_CHANGE",2053 entryId: sanitized.id,2054 entryTitle: sanitized.title,2055 deviceName,2056 timestamp: mergeNow,2057 });2058 }2059 entries[idx] = sanitized;2060 byId.set(sanitized.id, sanitized);2061 applied += 1;2062 if (!local.deletedAt) conflicts += 1;2063 }2064 }2065 }20662067 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2068 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt)) continue;2069 2070 const deletedAt = clampSyncTimestamp(rawDeletedAt, mergeNow);2071 const local = byId.get(id);2072 if (!local) continue;2073 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2074 if (deletedAt < local.modifiedAt) continue;2075 const idx = entries.findIndex((e) => e.id === id);2076 if (idx !== -1) {2077 2078 if (!local.deletedAt) {2079 alerts.push({2080 type: "REMOTE_DELETE",2081 entryId: id,2082 entryTitle: local.title,2083 deviceName,2084 timestamp: mergeNow,2085 });2086 }2087 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2088 applied += 1;2089 }2090 }20912092 if (applied > 0) {2093 await persistEntries("sync_received");2094 buildDomainIndexSync(entries);2095 }2096 2097 2098 await recordSyncAlerts(alerts);2099 return { received, applied, conflicts };2100}21012102export async function restoreEntry(id: string): Promise<void> {2103 if (!derivedKey) throw new Error("vault_locked");2104 const entry = entries.find((e) => e.id === id && e.deletedAt);2105 if (!entry) throw new Error("deleted entry not found");2106 delete entry.deletedAt;2107 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);2108 await persistEntries();2109 buildDomainIndexSync(entries);2110}21112112export async function purgeEntry(id: string): Promise<void> {2113 if (!derivedKey) throw new Error("vault_locked");2114 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2115 if (idx === -1) throw new Error("deleted entry not found");2116 entries.splice(idx, 1);2117 await persistEntries();2118}211921202121export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2122 if (!derivedKey || retentionMs === 0) return 0;2123 const cutoff = Date.now() - retentionMs;2124 const before = entries.length;2125 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2126 const purged = before - entries.length;2127 if (purged > 0) await persistEntries();2128 return purged;2129}21302131export function queryDomain(domain: string): VaultEntry[] {2132 return domainIndex.get(domain) ?? [];2133}21342135export function getEntryById(id: string): VaultEntry | undefined {2136 return entries.find((entry) => entry.id === id && !entry.deletedAt);2137}21382139async function persistSlotA(2140 reason: VaultSnapshot["reason"] = "local_save",2141): Promise<StoredVault> {2142 if (!derivedKey) throw new Error("vault_locked");21432144 const slotA = await loadSlot(SLOT_A);2145 if (!slotA) throw new Error("missing slot A");21462147 2148 2149 await takeVaultSnapshot(reason);21502151 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2152 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");21532154 2155 const nextSlotA: StoredVault = {2156 ...slotA,2157 nonce,2158 ciphertext,2159 tag,2160 2161 };2162 await writeSlot(SLOT_A, nextSlotA);21632164 2165 2166 2167 2168 2169 2170 try {2171 2172 2173 2174 if (!duressConfigured) {2175 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2176 }2177 2178 2179 2180 2181 2182 if (reason !== "sync_received") {2183 await stampLocalVaultMutation();2184 }2185 } catch (err) {2186 2187 2188 await writeSlot(SLOT_A, slotA).catch(() => {});2189 throw err;2190 }21912192 return nextSlotA;2193}21942195async function persistSlotB(): Promise<void> {2196 if (!derivedKey) throw new Error("vault_locked");21972198 const slotB = await loadSlot(SLOT_B);2199 if (!slotB) throw new Error("missing slot B");22002201 2202 2203 2204 2205 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2206 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");22072208 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2209 2210 2211 2212 try {2213 await stampLocalVaultMutation();2214 } catch (err) {2215 await writeSlot(SLOT_B, slotB).catch(() => {});2216 throw err;2217 }2218}22192220async function persistEntries(2221 reason: VaultSnapshot["reason"] = "local_save",2222): Promise<void> {2223 if (!derivedKey) throw new Error("vault_locked");22242225 if (vaultMode === "decoy") {2226 await persistSlotB();2227 } else {2228 await persistSlotA(reason);2229 }2230 buildDomainIndexSync(entries);2231}22322233function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2234 if (!Array.isArray(values)) return undefined;2235 const seen = new Set<string>();2236 const cleaned: string[] = [];2237 for (const value of values) {2238 const trimmed = typeof value === "string" ? value.trim() : "";2239 if (!trimmed) continue;2240 const key = trimmed.toLowerCase();2241 if (seen.has(key)) continue;2242 seen.add(key);2243 cleaned.push(trimmed);2244 }2245 return cleaned.length > 0 ? cleaned : undefined;2246}22472248function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2249 const title = entry.title.trim();2250 2251 2252 2253 let type: VaultEntry["type"] = entry.type ?? "login";2254 if (type === "passkey") type = "login";22552256 if (!title) throw new Error("title is required");22572258 if (entry.passkey) {2259 const p = entry.passkey;2260 if (2261 !p.rpId ||2262 !p.credentialId ||2263 !p.publicKeyCose ||2264 !p.userHandle ||2265 !p.userName ||2266 (p.algorithm !== -7 && p.algorithm !== -8)2267 ) {2268 throw new Error("invalid passkey credential");2269 }2270 }2271 if (type === "card") {2272 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2273 throw new Error("cardholder name and card number are required");2274 }2275 }22762277 return {2278 type,2279 title,2280 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2281 email: type === "login" ? entry.email?.trim() || undefined : undefined,2282 password: type === "login" ? entry.password : undefined,2283 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2284 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2285 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2286 notes: entry.notes?.trim() || undefined,2287 breach: type === "login" ? entry.breach : undefined,2288 2289 2290 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2291 tags: sanitizeTags(entry.tags),2292 passkey: entry.passkey,2293 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2294 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2295 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2296 expYear: type === "card" ? entry.expYear?.trim() : undefined,2297 cvc: type === "card" ? entry.cvc?.trim() : undefined,2298 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2299 network: type === "card" ? entry.network : undefined,2300 last4: type === "card" ? entry.last4?.trim() : undefined,2301 };2302}23032304function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2305 const nextPatch: Partial<VaultEntry> = {};23062307 if ("type" in patch) {2308 nextPatch.type = patch.type;2309 }23102311 if ("title" in patch) {2312 const title = patch.title?.trim() ?? "";2313 if (!title) throw new Error("title is required");2314 nextPatch.title = title;2315 }23162317 if ("url" in patch) {2318 nextPatch.url = patch.url?.trim() ?? "";2319 }23202321 if ("additionalUrls" in patch) {2322 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2323 const seen = new Set<string>();2324 const cleaned: string[] = [];2325 for (const value of incoming) {2326 const trimmed = typeof value === "string" ? value.trim() : "";2327 if (!trimmed) continue;2328 const key = trimmed.toLowerCase();2329 if (seen.has(key)) continue;2330 seen.add(key);2331 cleaned.push(trimmed);2332 }2333 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2334 }23352336 if ("username" in patch) {2337 nextPatch.username = patch.username?.trim() ?? "";2338 }23392340 if ("email" in patch) {2341 nextPatch.email = patch.email?.trim() || undefined;2342 }23432344 if ("password" in patch) {2345 const password = patch.password ?? "";2346 2347 nextPatch.password = password || undefined;2348 }23492350 if ("totp" in patch) {2351 nextPatch.totp = patch.totp?.trim() || undefined;2352 }23532354 if ("notes" in patch) {2355 nextPatch.notes = patch.notes?.trim() || undefined;2356 }23572358 if ("breach" in patch) {2359 nextPatch.breach = patch.breach;2360 }23612362 if ("canary" in patch) {2363 nextPatch.canary = patch.canary;2364 }23652366 if ("passkey" in patch) {2367 2368 2369 2370 nextPatch.passkey = patch.passkey;2371 }23722373 if ("tags" in patch) {2374 nextPatch.tags = patch.tags;2375 }23762377 if ("cardholderName" in patch) {2378 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2379 }2380 if ("cardNumber" in patch) {2381 const n = patch.cardNumber ?? "";2382 nextPatch.cardNumber = n || undefined;2383 }2384 if ("expMonth" in patch) {2385 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2386 }2387 if ("expYear" in patch) {2388 nextPatch.expYear = patch.expYear?.trim() || undefined;2389 }2390 if ("cvc" in patch) {2391 nextPatch.cvc = patch.cvc?.trim() || undefined;2392 }2393 if ("cardNickname" in patch) {2394 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2395 }2396 if ("network" in patch) {2397 nextPatch.network = patch.network;2398 }2399 if ("last4" in patch) {2400 nextPatch.last4 = patch.last4?.trim() || undefined;2401 }24022403 return nextPatch;2404}24052406function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2407 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2408 const modifiedAt =2409 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;24102411 const base: VaultEntry = {2412 id: entry.id,2413 type: "type" in entry ? entry.type : "login",2414 title: entry.title,2415 username: entry.username,2416 email: "email" in entry ? entry.email : undefined,2417 password: entry.password,2418 url: entry.url,2419 additionalUrls:2420 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2421 ? entry.additionalUrls2422 : undefined,2423 totp: entry.totp,2424 notes: entry.notes,2425 breach: entry.breach,2426 2427 2428 2429 passwordChangedAt:2430 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2431 2432 2433 2434 passwordHistory:2435 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2436 ? entry.passwordHistory2437 : undefined,2438 canary: "canary" in entry ? entry.canary : undefined,2439 passkey: "passkey" in entry ? entry.passkey : undefined,2440 createdAt,2441 modifiedAt,2442 deletedAt: entry.deletedAt,2443 tags: "tags" in entry ? entry.tags : undefined,2444 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2445 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2446 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2447 expYear: "expYear" in entry ? entry.expYear : undefined,2448 cvc: "cvc" in entry ? entry.cvc : undefined,2449 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2450 network: "network" in entry ? entry.network : undefined,2451 last4: "last4" in entry ? entry.last4 : undefined,2452 };24532454 return migrateLegacyPasskey(base, entry);2455}245624572458245924602461246224632464function migrateLegacyPasskey(2465 base: VaultEntry,2466 raw: VaultEntry | LegacyVaultEntry2467): VaultEntry {2468 if (base.passkey) return base;2469 const flat = raw as Partial<VaultEntry>;2470 const isPasskeyType = base.type === "passkey";2471 const hasFlatPasskeyFields =2472 typeof flat.rpId === "string" &&2473 typeof flat.credentialId === "string" &&2474 typeof flat.publicKeyCose === "string" &&2475 typeof flat.userHandle === "string" &&2476 typeof flat.userName === "string";2477 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2478 if (!hasFlatPasskeyFields) return base;24792480 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2481 base.passkey = {2482 rpId: flat.rpId!,2483 rpName: flat.rpName ?? flat.rpId!,2484 credentialId: flat.credentialId!,2485 publicKeyCose: flat.publicKeyCose!,2486 privateKeyJwk: flat.privateKeyJwk,2487 userHandle: flat.userHandle!,2488 userName: flat.userName!,2489 userDisplayName: flat.userDisplayName ?? flat.userName!,2490 algorithm,2491 signCount: flat.signCount ?? 0,2492 aaguid: flat.aaguid ?? "",2493 backupEligible: flat.backupEligible === true,2494 backupState: flat.backupState === true,2495 lastUsedAt: flat.lastUsedAt,2496 browser: flat.browser,2497 transports: flat.transports,2498 createdAt: base.createdAt,2499 };2500 2501 2502 2503 if (isPasskeyType) base.type = "login";2504 if (!base.username) base.username = flat.userName;2505 if (!base.url) base.url = `https://${flat.rpId}`;2506 return base;2507}2508250925102511251225132514function entryDupeKey(e: VaultEntry): string {2515 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2516 2517 2518 2519 2520 if (e.passkey) {2521 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2522 }2523 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2524}252525262527export function countDuplicates(): number {2528 const best = new Map<string, VaultEntry>();2529 let dupeCount = 0;2530 for (const e of entries.filter((e) => !e.deletedAt)) {2531 const key = entryDupeKey(e);2532 const existing = best.get(key);2533 if (!existing) {2534 best.set(key, e);2535 } else {2536 dupeCount++;2537 2538 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2539 best.set(key, e);2540 }2541 }2542 }2543 return dupeCount;2544}254525462547254825492550export async function deduplicateEntries(): Promise<number> {2551 if (!derivedKey) throw new Error("vault_locked");2552 2553 2554 if (vaultMode === "decoy") {2555 throw new Error("deduplicate disabled in decoy mode");2556 }25572558 const best = new Map<string, VaultEntry>();2559 for (const e of entries.filter((en) => !en.deletedAt)) {2560 const key = entryDupeKey(e);2561 const existing = best.get(key);2562 if (!existing) {2563 best.set(key, e);2564 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2565 best.set(key, e);2566 }2567 }25682569 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2570 let removed = 0;2571 entries = entries.map((e) => {2572 if (!e.deletedAt && !keepIds.has(e.id)) {2573 removed++;2574 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2575 }2576 return e;2577 });25782579 if (removed > 0) await persistEntries();2580 return removed;2581}258225832584258525862587258825892590const HISTORY_KEY = "peach_vault_history";2591const HISTORY_MAX = 5;25922593259425952596259725982599export async function takeVaultSnapshot(2600 reason: VaultSnapshot["reason"],2601): Promise<void> {2602 try {2603 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2604 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2605 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];26062607 2608 2609 2610 if (!slotA) return;26112612 const vault_a = JSON.stringify(slotA);2613 const vault_b = JSON.stringify(slotB ?? slotA);26142615 2616 2617 2618 let entryCount: number | null = null;2619 if (derivedKey && vaultMode === "real") {2620 try {2621 entryCount = entries.filter((e) => !e.deletedAt).length;2622 } catch {2623 entryCount = null;2624 }2625 }26262627 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2628 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2629 ? existingRaw2630 : { snapshots: [] };26312632 2633 2634 2635 const latest = history.snapshots[0];2636 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2637 return;2638 }26392640 const snapshot: VaultSnapshot = {2641 id: crypto.randomUUID(),2642 savedAt: Date.now(),2643 reason,2644 vault_a,2645 vault_b,2646 entryCount,2647 };26482649 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2650 await chrome.storage.local.set({ [HISTORY_KEY]: history });2651 } catch (err) {2652 2653 2654 2655 2656 console.warn('[Peach] takeVaultSnapshot failed:', err);2657 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2658 try {2659 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2660 } catch {2661 2662 }2663 }2664 }2665}266626672668export async function clearVaultHistory(): Promise<void> {2669 try {2670 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2671 } catch {2672 2673 }2674}267526762677export async function getVaultHistory(): Promise<VaultSnapshot[]> {2678 const raw = await chrome.storage.local.get(HISTORY_KEY);2679 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2680 if (!history || !Array.isArray(history.snapshots)) return [];2681 return history.snapshots;2682}26832684268526862687268826892690export async function restoreVaultSnapshot(2691 snapshotId: string,2692): Promise<{ ok: true } | { ok: false; error: string }> {2693 const history = await getVaultHistory();2694 const snapshot = history.find((s) => s.id === snapshotId);2695 if (!snapshot) return { ok: false, error: "snapshot_not_found" };26962697 2698 2699 2700 await takeVaultSnapshot("restore");27012702 let restoredA: StoredVault;2703 let restoredB: StoredVault;2704 try {2705 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2706 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2707 } catch {2708 return { ok: false, error: "snapshot_corrupt" };2709 }27102711 await writeSlot(SLOT_A, restoredA);2712 await writeSlot(SLOT_B, restoredB);2713 await stampLocalVaultMutation();27142715 2716 2717 2718 lock();27192720 return { ok: true };2721}272227232724272527262727272827292730273127322733273427352736export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {2737 if (!derivedKey || vaultMode !== "real") {2738 return { ok: false, error: "vault_locked" };2739 }2740 const slotA = await loadSlot(SLOT_A);2741 if (!slotA) return { ok: false, error: "missing_slot_a" };27422743 2744 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =2745 await generateAndWrapRootKey();27462747 2748 const newRootKey = unhex(rawRootKey);2749 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);27502751 2752 2753 2754 2755 await takeVaultSnapshot("local_save");2756 const nextSlotA: StoredVault = {2757 ...slotA,2758 recovery_salt: recoverySalt,2759 recovery_wrapped_root_key: wrappedRootKey,2760 recovery_nonce: wrappedNonce,2761 recovery_tag: wrappedTag,2762 ...pwRoot,2763 };2764 await writeSlot(SLOT_A, nextSlotA);27652766 2767 if (rootKey) zeroKey(rootKey);2768 rootKey = newRootKey;27692770 2771 const { clearPairedDevices } = await import("./paired-devices");2772 await clearPairedDevices();27732774 2775 await setRecoveryConfirmPending(); 27762777 await stampLocalVaultMutation();2778 return { ok: true };2779}27802781278227832784278527862787export async function changePassword(2788 currentPassword: string,2789 newPassword: string2790): Promise<boolean> {2791 if (!derivedKey || vaultMode !== "real") return false;27922793 2794 const valid = await verifyMainPassword(currentPassword);2795 if (!valid) return false;27962797 const slotA = await loadSlot(SLOT_A);2798 if (!slotA) return false;2799 if (!rootKey) return false;28002801 2802 const newSalt = generateSalt();2803 const newKey = await deriveKey(newPassword, newSalt);28042805 2806 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2807 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");28082809 2810 2811 2812 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);28132814 const finalSlotA: StoredVault = {2815 ...slotA,2816 salt: hex(newSalt),2817 nonce,2818 ciphertext,2819 tag,2820 ...pwRoot,2821 };28222823 2824 2825 2826 await takeVaultSnapshot("local_save");28272828 2829 2830 2831 2832 await writeSlot(SLOT_A, finalSlotA);28332834 2835 2836 2837 zeroKey(derivedKey);2838 derivedKey = newKey;28392840 2841 2842 2843 2844 void saveSessionKey(newKey, "real");28452846 2847 2848 2849 if (!duressConfigured) {2850 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2851 }2852 await stampLocalVaultMutation();28532854 return true;2855}