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 }16231624 1625 1626 if (1627 normalized.type === "login" &&1628 !normalized.passkey &&1629 typeof normalized.password !== "string"1630 ) {1631 return null;1632 }1633 if (entry.passkey && !normalized.passkey) {1634 1635 1636 return null;1637 }16381639 return normalized;1640}1641164216431644164516461647function sanitizePasskey(pk: unknown): PasskeyData | undefined {1648 if (!pk || typeof pk !== "object") return undefined;1649 const r = pk as Record<string, unknown>;1650 if (1651 typeof r.rpId !== "string" || !r.rpId ||1652 typeof r.credentialId !== "string" || !r.credentialId ||1653 typeof r.publicKeyCose !== "string" ||1654 typeof r.userHandle !== "string" ||1655 typeof r.userName !== "string"1656 ) {1657 return undefined;1658 }1659 return {1660 rpId: r.rpId,1661 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1662 credentialId: r.credentialId,1663 publicKeyCose: r.publicKeyCose,1664 privateKeyJwk:1665 r.privateKeyJwk && typeof r.privateKeyJwk === "object"1666 ? (r.privateKeyJwk as JsonWebKey)1667 : undefined,1668 userHandle: r.userHandle,1669 userName: r.userName,1670 userDisplayName:1671 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1672 algorithm: r.algorithm === -8 ? -8 : -7,1673 signCount: typeof r.signCount === "number" ? r.signCount : 0,1674 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1675 backupEligible: r.backupEligible === true,1676 backupState: r.backupState === true,1677 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1678 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1679 transports: Array.isArray(r.transports)1680 ? r.transports.filter((t): t is string => typeof t === "string")1681 : undefined,1682 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1683 };1684}16851686168716881689169016911692169316941695function sanitizeCanary(c: unknown): Canary | undefined {1696 if (!c || typeof c !== "object") return undefined;1697 const r = c as Record<string, unknown>;1698 if (typeof r.webhookUrl !== "string") return undefined;1699 let url: URL;1700 try {1701 url = new URL(r.webhookUrl);1702 } catch {1703 return undefined;1704 }1705 if (url.protocol !== "https:") return undefined;1706 return {1707 webhookUrl: url.toString(),1708 label: typeof r.label === "string" ? r.label : undefined,1709 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,1710 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,1711 };1712}171317141715171617171718export async function clearVaultEntries(1719 confirmation: "REPLACE_ALL"1720): Promise<void> {1721 if (confirmation !== "REPLACE_ALL") {1722 throw new Error("clearVaultEntries requires explicit confirmation");1723 }1724 if (!derivedKey) throw new Error("vault_locked");1725 for (const entry of entries) {1726 if (!entry.deletedAt) {1727 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1728 entry.deletedAt = stamp;1729 entry.modifiedAt = stamp;1730 }1731 }1732 await persistEntries();1733}17341735export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1736 if (!derivedKey) throw new Error("vault_locked");17371738 const sanitized = sanitizeEntryDraft(entry);1739 const now = Date.now();1740 const stamp = monotonicEntryTimestamp();1741 const nextEntry: VaultEntry = {1742 ...sanitized,1743 id: crypto.randomUUID(),1744 createdAt: now,1745 modifiedAt: stamp,1746 1747 1748 passwordChangedAt: sanitized.password ? stamp : undefined,1749 };17501751 1752 1753 1754 const rollback = entries;1755 entries = [...entries, nextEntry];1756 try {1757 await persistEntries();1758 } catch (err) {1759 entries = rollback;1760 throw err;1761 }17621763 1764 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1765 checkBreach(nextEntry.password).then(async (count) => {1766 if (count > 0) {1767 const idx = entries.findIndex((e) => e.id === nextEntry.id);1768 if (idx !== -1) {1769 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };1770 await persistEntries();1771 }1772 }1773 });1774 }17751776 fireSyncTrigger("vault_change");17771778 return nextEntry;1779}17801781178217831784178517861787export async function consolidatePasskeyEntries(): Promise<void> {1788 if (!derivedKey) return;1789 const { merged } = mergePasskeyOnlyIntoLogins(entries);1790 if (merged) {1791 await persistEntries();1792 }1793}17941795export async function updateEntry(1796 id: string,1797 patch: Partial<VaultEntry>1798): Promise<void> {1799 if (!derivedKey) throw new Error("vault_locked");18001801 const idx = entries.findIndex((entry) => entry.id === id);1802 if (idx === -1 || entries[idx].deletedAt) {1803 throw new Error("entry_not_found");1804 }18051806 const current = entries[idx];1807 const sanitizedPatch = sanitizeEntryPatch(patch);1808 1809 1810 1811 const passwordChanged =1812 "password" in patch &&1813 typeof sanitizedPatch.password === "string" &&1814 sanitizedPatch.password !== current.password;1815 const stamp = monotonicEntryTimestamp(current.modifiedAt);1816 1817 1818 1819 1820 1821 1822 1823 const priorPassword = current.password;1824 const shouldCapture =1825 passwordChanged &&1826 typeof priorPassword === "string" &&1827 priorPassword.length > 0 &&1828 current.passwordHistory?.[0]?.password !== priorPassword;1829 const nextHistory = shouldCapture1830 ? [1831 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },1832 ...(current.passwordHistory ?? []),1833 ].slice(0, PASSWORD_HISTORY_MAX)1834 : current.passwordHistory;1835 entries[idx] = {1836 ...current,1837 ...sanitizedPatch,1838 modifiedAt: stamp,1839 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),1840 };1841184218431844 1845 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1846 entries[idx] = current;1847 throw new Error("card number is required for card entries");1848 }18491850 1851 1852 1853 1854 try {1855 await persistEntries();1856 } catch (err) {1857 entries[idx] = current;1858 throw err;1859 }18601861 1862 1863 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1864 entries[idx] = { ...entries[idx], breach: undefined };1865 await persistEntries();1866 const password = entries[idx].password;1867 if (password) {1868 checkBreach(password).then(async (count) => {1869 const i = entries.findIndex((e) => e.id === id);1870 if (i !== -1 && count > 0) {1871 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };1872 await persistEntries();1873 }1874 });1875 }1876 }18771878 fireSyncTrigger("vault_change");1879}18801881export async function deleteEntry(id: string): Promise<void> {1882 if (!derivedKey) throw new Error("vault_locked");18831884 const entry = entries.find((item) => item.id === id);1885 if (!entry || entry.deletedAt) {1886 throw new Error("entry_not_found");1887 }18881889 1890 1891 1892 const previousModifiedAt = entry.modifiedAt;1893 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1894 entry.deletedAt = stamp;1895 entry.modifiedAt = stamp;1896 1897 1898 try {1899 await persistEntries();1900 } catch (err) {1901 delete entry.deletedAt;1902 entry.modifiedAt = previousModifiedAt;1903 throw err;1904 }1905 fireSyncTrigger("vault_change");1906}19071908export function getDeletedEntries(): VaultEntry[] {1909 if (!derivedKey) return [];1910 return entries.filter((e) => !!e.deletedAt);1911}1912191319141915191619171918export async function getVaultVersion(): Promise<number> {1919 try {1920 const raw = await chrome.storage.local.get("peach_last_modified_at");1921 const v = (raw as Record<string, unknown>).peach_last_modified_at;1922 if (typeof v === "number") return v;1923 if (typeof v === "string" && v !== "") {1924 const n = Number.parseInt(v, 10);1925 return Number.isFinite(n) ? n : 0;1926 }1927 return 0;1928 } catch {1929 return 0;1930 }1931}193219331934193519361937193819391940194119421943export async function addEntryDirect(entry: VaultEntry): Promise<void> {1944 if (!derivedKey) throw new Error("vault_locked");1945 entries = [...entries, entry];1946 await persistEntries("sync_received");1947}19481949export async function updateEntryDirect(patch: VaultEntry): Promise<void> {1950 if (!derivedKey) throw new Error("vault_locked");1951 const idx = entries.findIndex((e) => e.id === patch.id);1952 if (idx === -1) throw new Error("entry_not_found");1953 entries[idx] = { ...entries[idx], ...patch };1954 await persistEntries("sync_received");1955}19561957export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {1958 if (!derivedKey) throw new Error("vault_locked");1959 const idx = entries.findIndex((e) => e.id === id);1960 if (idx === -1) throw new Error("entry_not_found");1961 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };1962 await persistEntries("sync_received");1963}1964196519661967196819691970197119721973197419751976export async function applyMergedRemoteEntries(1977 remoteEntries: VaultEntry[],1978 remoteTombstones: Record<string, number>,1979 context: { deviceName?: string } = {},1980): Promise<{ received: number; applied: number; conflicts: number }> {1981 1982 1983 1984 1985 1986 1987 if (!derivedKey) throw new Error("vault_locked");19881989 const received = remoteEntries.length + Object.keys(remoteTombstones).length;1990 let applied = 0;1991 let conflicts = 0;19921993 const byId = new Map<string, VaultEntry>();1994 for (const e of entries) byId.set(e.id, e);19951996 const alerts: SyncAlert[] = [];1997 const deviceName = context.deviceName ?? "paired device";1998 const mergeNow = Date.now();19992000 for (const remote of remoteEntries) {2001 const sanitized = sanitizeRestoredEntry(remote);2002 if (!sanitized) continue;2003 2004 2005 2006 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2007 if (typeof sanitized.deletedAt === "number") {2008 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2009 }2010 const tombstoneTs = remoteTombstones[sanitized.id];2011 if (tombstoneTs !== undefined && clampSyncTimestamp(tombstoneTs, mergeNow) >= sanitized.modifiedAt) {2012 continue;2013 }2014 const local = byId.get(sanitized.id);2015 if (!local) {2016 entries.push(sanitized);2017 byId.set(sanitized.id, sanitized);2018 applied += 1;2019 } else if (sanitized.modifiedAt > local.modifiedAt) {2020 const idx = entries.findIndex((e) => e.id === local.id);2021 if (idx !== -1) {2022 2023 2024 if (2025 !local.deletedAt &&2026 typeof sanitized.password === "string" &&2027 typeof local.password === "string" &&2028 sanitized.password !== local.password2029 ) {2030 alerts.push({2031 type: "REMOTE_PASSWORD_CHANGE",2032 entryId: sanitized.id,2033 entryTitle: sanitized.title,2034 deviceName,2035 timestamp: mergeNow,2036 });2037 }2038 entries[idx] = sanitized;2039 byId.set(sanitized.id, sanitized);2040 applied += 1;2041 if (!local.deletedAt) conflicts += 1;2042 }2043 }2044 }20452046 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2047 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt)) continue;2048 2049 const deletedAt = clampSyncTimestamp(rawDeletedAt, mergeNow);2050 const local = byId.get(id);2051 if (!local) continue;2052 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2053 if (deletedAt < local.modifiedAt) continue;2054 const idx = entries.findIndex((e) => e.id === id);2055 if (idx !== -1) {2056 2057 if (!local.deletedAt) {2058 alerts.push({2059 type: "REMOTE_DELETE",2060 entryId: id,2061 entryTitle: local.title,2062 deviceName,2063 timestamp: mergeNow,2064 });2065 }2066 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2067 applied += 1;2068 }2069 }20702071 if (applied > 0) {2072 await persistEntries("sync_received");2073 buildDomainIndexSync(entries);2074 }2075 2076 2077 await recordSyncAlerts(alerts);2078 return { received, applied, conflicts };2079}20802081export async function restoreEntry(id: string): Promise<void> {2082 if (!derivedKey) throw new Error("vault_locked");2083 const entry = entries.find((e) => e.id === id && e.deletedAt);2084 if (!entry) throw new Error("deleted entry not found");2085 delete entry.deletedAt;2086 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);2087 await persistEntries();2088 buildDomainIndexSync(entries);2089}20902091export async function purgeEntry(id: string): Promise<void> {2092 if (!derivedKey) throw new Error("vault_locked");2093 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2094 if (idx === -1) throw new Error("deleted entry not found");2095 entries.splice(idx, 1);2096 await persistEntries();2097}209820992100export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2101 if (!derivedKey || retentionMs === 0) return 0;2102 const cutoff = Date.now() - retentionMs;2103 const before = entries.length;2104 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2105 const purged = before - entries.length;2106 if (purged > 0) await persistEntries();2107 return purged;2108}21092110export function queryDomain(domain: string): VaultEntry[] {2111 return domainIndex.get(domain) ?? [];2112}21132114export function getEntryById(id: string): VaultEntry | undefined {2115 return entries.find((entry) => entry.id === id && !entry.deletedAt);2116}21172118async function persistSlotA(2119 reason: VaultSnapshot["reason"] = "local_save",2120): Promise<StoredVault> {2121 if (!derivedKey) throw new Error("vault_locked");21222123 const slotA = await loadSlot(SLOT_A);2124 if (!slotA) throw new Error("missing slot A");21252126 2127 2128 await takeVaultSnapshot(reason);21292130 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2131 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");21322133 2134 const nextSlotA: StoredVault = {2135 ...slotA,2136 nonce,2137 ciphertext,2138 tag,2139 2140 };2141 await writeSlot(SLOT_A, nextSlotA);21422143 2144 2145 2146 2147 2148 2149 try {2150 2151 2152 2153 if (!duressConfigured) {2154 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2155 }2156 2157 2158 2159 2160 2161 if (reason !== "sync_received") {2162 await stampLocalVaultMutation();2163 }2164 } catch (err) {2165 2166 2167 await writeSlot(SLOT_A, slotA).catch(() => {});2168 throw err;2169 }21702171 return nextSlotA;2172}21732174async function persistSlotB(): Promise<void> {2175 if (!derivedKey) throw new Error("vault_locked");21762177 const slotB = await loadSlot(SLOT_B);2178 if (!slotB) throw new Error("missing slot B");21792180 2181 2182 2183 2184 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2185 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");21862187 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2188 2189 2190 2191 try {2192 await stampLocalVaultMutation();2193 } catch (err) {2194 await writeSlot(SLOT_B, slotB).catch(() => {});2195 throw err;2196 }2197}21982199async function persistEntries(2200 reason: VaultSnapshot["reason"] = "local_save",2201): Promise<void> {2202 if (!derivedKey) throw new Error("vault_locked");22032204 if (vaultMode === "decoy") {2205 await persistSlotB();2206 } else {2207 await persistSlotA(reason);2208 }2209 buildDomainIndexSync(entries);2210}22112212function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2213 if (!Array.isArray(values)) return undefined;2214 const seen = new Set<string>();2215 const cleaned: string[] = [];2216 for (const value of values) {2217 const trimmed = typeof value === "string" ? value.trim() : "";2218 if (!trimmed) continue;2219 const key = trimmed.toLowerCase();2220 if (seen.has(key)) continue;2221 seen.add(key);2222 cleaned.push(trimmed);2223 }2224 return cleaned.length > 0 ? cleaned : undefined;2225}22262227function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2228 const title = entry.title.trim();2229 2230 2231 2232 let type: VaultEntry["type"] = entry.type ?? "login";2233 if (type === "passkey") type = "login";22342235 if (!title) throw new Error("title is required");22362237 if (entry.passkey) {2238 const p = entry.passkey;2239 if (2240 !p.rpId ||2241 !p.credentialId ||2242 !p.publicKeyCose ||2243 !p.userHandle ||2244 !p.userName ||2245 (p.algorithm !== -7 && p.algorithm !== -8)2246 ) {2247 throw new Error("invalid passkey credential");2248 }2249 }2250 if (type === "card") {2251 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2252 throw new Error("cardholder name and card number are required");2253 }2254 }22552256 return {2257 type,2258 title,2259 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2260 email: type === "login" ? entry.email?.trim() || undefined : undefined,2261 password: type === "login" ? entry.password : undefined,2262 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2263 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2264 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2265 notes: entry.notes?.trim() || undefined,2266 breach: type === "login" ? entry.breach : undefined,2267 canary: type === "login" ? entry.canary : undefined,2268 tags: entry.tags,2269 passkey: entry.passkey,2270 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2271 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2272 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2273 expYear: type === "card" ? entry.expYear?.trim() : undefined,2274 cvc: type === "card" ? entry.cvc?.trim() : undefined,2275 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2276 network: type === "card" ? entry.network : undefined,2277 last4: type === "card" ? entry.last4?.trim() : undefined,2278 };2279}22802281function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2282 const nextPatch: Partial<VaultEntry> = {};22832284 if ("type" in patch) {2285 nextPatch.type = patch.type;2286 }22872288 if ("title" in patch) {2289 const title = patch.title?.trim() ?? "";2290 if (!title) throw new Error("title is required");2291 nextPatch.title = title;2292 }22932294 if ("url" in patch) {2295 nextPatch.url = patch.url?.trim() ?? "";2296 }22972298 if ("additionalUrls" in patch) {2299 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2300 const seen = new Set<string>();2301 const cleaned: string[] = [];2302 for (const value of incoming) {2303 const trimmed = typeof value === "string" ? value.trim() : "";2304 if (!trimmed) continue;2305 const key = trimmed.toLowerCase();2306 if (seen.has(key)) continue;2307 seen.add(key);2308 cleaned.push(trimmed);2309 }2310 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2311 }23122313 if ("username" in patch) {2314 nextPatch.username = patch.username?.trim() ?? "";2315 }23162317 if ("email" in patch) {2318 nextPatch.email = patch.email?.trim() || undefined;2319 }23202321 if ("password" in patch) {2322 const password = patch.password ?? "";2323 2324 nextPatch.password = password || undefined;2325 }23262327 if ("totp" in patch) {2328 nextPatch.totp = patch.totp?.trim() || undefined;2329 }23302331 if ("notes" in patch) {2332 nextPatch.notes = patch.notes?.trim() || undefined;2333 }23342335 if ("breach" in patch) {2336 nextPatch.breach = patch.breach;2337 }23382339 if ("canary" in patch) {2340 nextPatch.canary = patch.canary;2341 }23422343 if ("passkey" in patch) {2344 2345 2346 2347 nextPatch.passkey = patch.passkey;2348 }23492350 if ("tags" in patch) {2351 nextPatch.tags = patch.tags;2352 }23532354 if ("cardholderName" in patch) {2355 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2356 }2357 if ("cardNumber" in patch) {2358 const n = patch.cardNumber ?? "";2359 nextPatch.cardNumber = n || undefined;2360 }2361 if ("expMonth" in patch) {2362 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2363 }2364 if ("expYear" in patch) {2365 nextPatch.expYear = patch.expYear?.trim() || undefined;2366 }2367 if ("cvc" in patch) {2368 nextPatch.cvc = patch.cvc?.trim() || undefined;2369 }2370 if ("cardNickname" in patch) {2371 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2372 }2373 if ("network" in patch) {2374 nextPatch.network = patch.network;2375 }2376 if ("last4" in patch) {2377 nextPatch.last4 = patch.last4?.trim() || undefined;2378 }23792380 return nextPatch;2381}23822383function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2384 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2385 const modifiedAt =2386 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;23872388 const base: VaultEntry = {2389 id: entry.id,2390 type: "type" in entry ? entry.type : "login",2391 title: entry.title,2392 username: entry.username,2393 email: "email" in entry ? entry.email : undefined,2394 password: entry.password,2395 url: entry.url,2396 additionalUrls:2397 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2398 ? entry.additionalUrls2399 : undefined,2400 totp: entry.totp,2401 notes: entry.notes,2402 breach: entry.breach,2403 2404 2405 2406 passwordChangedAt:2407 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2408 2409 2410 2411 passwordHistory:2412 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2413 ? entry.passwordHistory2414 : undefined,2415 canary: "canary" in entry ? entry.canary : undefined,2416 passkey: "passkey" in entry ? entry.passkey : undefined,2417 createdAt,2418 modifiedAt,2419 deletedAt: entry.deletedAt,2420 tags: "tags" in entry ? entry.tags : undefined,2421 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2422 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2423 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2424 expYear: "expYear" in entry ? entry.expYear : undefined,2425 cvc: "cvc" in entry ? entry.cvc : undefined,2426 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2427 network: "network" in entry ? entry.network : undefined,2428 last4: "last4" in entry ? entry.last4 : undefined,2429 };24302431 return migrateLegacyPasskey(base, entry);2432}243324342435243624372438243924402441function migrateLegacyPasskey(2442 base: VaultEntry,2443 raw: VaultEntry | LegacyVaultEntry2444): VaultEntry {2445 if (base.passkey) return base;2446 const flat = raw as Partial<VaultEntry>;2447 const isPasskeyType = base.type === "passkey";2448 const hasFlatPasskeyFields =2449 typeof flat.rpId === "string" &&2450 typeof flat.credentialId === "string" &&2451 typeof flat.publicKeyCose === "string" &&2452 typeof flat.userHandle === "string" &&2453 typeof flat.userName === "string";2454 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2455 if (!hasFlatPasskeyFields) return base;24562457 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2458 base.passkey = {2459 rpId: flat.rpId!,2460 rpName: flat.rpName ?? flat.rpId!,2461 credentialId: flat.credentialId!,2462 publicKeyCose: flat.publicKeyCose!,2463 privateKeyJwk: flat.privateKeyJwk,2464 userHandle: flat.userHandle!,2465 userName: flat.userName!,2466 userDisplayName: flat.userDisplayName ?? flat.userName!,2467 algorithm,2468 signCount: flat.signCount ?? 0,2469 aaguid: flat.aaguid ?? "",2470 backupEligible: flat.backupEligible === true,2471 backupState: flat.backupState === true,2472 lastUsedAt: flat.lastUsedAt,2473 browser: flat.browser,2474 transports: flat.transports,2475 createdAt: base.createdAt,2476 };2477 2478 2479 2480 if (isPasskeyType) base.type = "login";2481 if (!base.username) base.username = flat.userName;2482 if (!base.url) base.url = `https://${flat.rpId}`;2483 return base;2484}2485248624872488248924902491function entryDupeKey(e: VaultEntry): string {2492 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2493 2494 2495 2496 2497 if (e.passkey) {2498 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2499 }2500 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2501}250225032504export function countDuplicates(): number {2505 const best = new Map<string, VaultEntry>();2506 let dupeCount = 0;2507 for (const e of entries.filter((e) => !e.deletedAt)) {2508 const key = entryDupeKey(e);2509 const existing = best.get(key);2510 if (!existing) {2511 best.set(key, e);2512 } else {2513 dupeCount++;2514 2515 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2516 best.set(key, e);2517 }2518 }2519 }2520 return dupeCount;2521}252225232524252525262527export async function deduplicateEntries(): Promise<number> {2528 if (!derivedKey) throw new Error("vault_locked");2529 2530 2531 if (vaultMode === "decoy") {2532 throw new Error("deduplicate disabled in decoy mode");2533 }25342535 const best = new Map<string, VaultEntry>();2536 for (const e of entries.filter((en) => !en.deletedAt)) {2537 const key = entryDupeKey(e);2538 const existing = best.get(key);2539 if (!existing) {2540 best.set(key, e);2541 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2542 best.set(key, e);2543 }2544 }25452546 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2547 let removed = 0;2548 entries = entries.map((e) => {2549 if (!e.deletedAt && !keepIds.has(e.id)) {2550 removed++;2551 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2552 }2553 return e;2554 });25552556 if (removed > 0) await persistEntries();2557 return removed;2558}255925602561256225632564256525662567const HISTORY_KEY = "peach_vault_history";2568const HISTORY_MAX = 5;25692570257125722573257425752576export async function takeVaultSnapshot(2577 reason: VaultSnapshot["reason"],2578): Promise<void> {2579 try {2580 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2581 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2582 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];25832584 2585 2586 2587 if (!slotA) return;25882589 const vault_a = JSON.stringify(slotA);2590 const vault_b = JSON.stringify(slotB ?? slotA);25912592 2593 2594 2595 let entryCount: number | null = null;2596 if (derivedKey && vaultMode === "real") {2597 try {2598 entryCount = entries.filter((e) => !e.deletedAt).length;2599 } catch {2600 entryCount = null;2601 }2602 }26032604 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2605 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2606 ? existingRaw2607 : { snapshots: [] };26082609 2610 2611 2612 const latest = history.snapshots[0];2613 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2614 return;2615 }26162617 const snapshot: VaultSnapshot = {2618 id: crypto.randomUUID(),2619 savedAt: Date.now(),2620 reason,2621 vault_a,2622 vault_b,2623 entryCount,2624 };26252626 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2627 await chrome.storage.local.set({ [HISTORY_KEY]: history });2628 } catch (err) {2629 2630 2631 2632 2633 console.warn('[Peach] takeVaultSnapshot failed:', err);2634 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2635 try {2636 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2637 } catch {2638 2639 }2640 }2641 }2642}264326442645export async function clearVaultHistory(): Promise<void> {2646 try {2647 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2648 } catch {2649 2650 }2651}265226532654export async function getVaultHistory(): Promise<VaultSnapshot[]> {2655 const raw = await chrome.storage.local.get(HISTORY_KEY);2656 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2657 if (!history || !Array.isArray(history.snapshots)) return [];2658 return history.snapshots;2659}26602661266226632664266526662667export async function restoreVaultSnapshot(2668 snapshotId: string,2669): Promise<{ ok: true } | { ok: false; error: string }> {2670 const history = await getVaultHistory();2671 const snapshot = history.find((s) => s.id === snapshotId);2672 if (!snapshot) return { ok: false, error: "snapshot_not_found" };26732674 2675 2676 2677 await takeVaultSnapshot("restore");26782679 let restoredA: StoredVault;2680 let restoredB: StoredVault;2681 try {2682 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2683 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2684 } catch {2685 return { ok: false, error: "snapshot_corrupt" };2686 }26872688 await writeSlot(SLOT_A, restoredA);2689 await writeSlot(SLOT_B, restoredB);2690 await stampLocalVaultMutation();26912692 2693 2694 2695 lock();26962697 return { ok: true };2698}269927002701270227032704270527062707270827092710271127122713export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {2714 if (!derivedKey || vaultMode !== "real") {2715 return { ok: false, error: "vault_locked" };2716 }2717 const slotA = await loadSlot(SLOT_A);2718 if (!slotA) return { ok: false, error: "missing_slot_a" };27192720 2721 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =2722 await generateAndWrapRootKey();27232724 2725 const newRootKey = unhex(rawRootKey);2726 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);27272728 2729 2730 2731 2732 await takeVaultSnapshot("local_save");2733 const nextSlotA: StoredVault = {2734 ...slotA,2735 recovery_salt: recoverySalt,2736 recovery_wrapped_root_key: wrappedRootKey,2737 recovery_nonce: wrappedNonce,2738 recovery_tag: wrappedTag,2739 ...pwRoot,2740 };2741 await writeSlot(SLOT_A, nextSlotA);27422743 2744 if (rootKey) zeroKey(rootKey);2745 rootKey = newRootKey;27462747 2748 const { clearPairedDevices } = await import("./paired-devices");2749 await clearPairedDevices();27502751 2752 await setRecoveryConfirmPending(); 27532754 await stampLocalVaultMutation();2755 return { ok: true };2756}27572758275927602761276227632764export async function changePassword(2765 currentPassword: string,2766 newPassword: string2767): Promise<boolean> {2768 if (!derivedKey || vaultMode !== "real") return false;27692770 2771 const valid = await verifyMainPassword(currentPassword);2772 if (!valid) return false;27732774 const slotA = await loadSlot(SLOT_A);2775 if (!slotA) return false;2776 if (!rootKey) return false;27772778 2779 const newSalt = generateSalt();2780 const newKey = await deriveKey(newPassword, newSalt);27812782 2783 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2784 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");27852786 2787 2788 2789 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);27902791 const finalSlotA: StoredVault = {2792 ...slotA,2793 salt: hex(newSalt),2794 nonce,2795 ciphertext,2796 tag,2797 ...pwRoot,2798 };27992800 2801 2802 2803 await takeVaultSnapshot("local_save");28042805 2806 2807 2808 2809 await writeSlot(SLOT_A, finalSlotA);28102811 2812 2813 2814 zeroKey(derivedKey);2815 derivedKey = newKey;28162817 2818 2819 2820 2821 void saveSessionKey(newKey, "real");28222823 2824 2825 2826 if (!duressConfigured) {2827 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2828 }2829 await stampLocalVaultMutation();28302831 return true;2832}