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 430 if (rootKey) {431 const slotAfter = await loadSlot(SLOT_A);432 if (slotAfter) {433 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);434 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });435 }436 }437 if (!duressConfigured) {438 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));439 }440 await stampLocalVaultMutation();441}442443444445446447448export { deriveKey, generateSalt };449450export async function loadSlotA(): Promise<StoredVault | null> {451 return loadSlot(SLOT_A);452}453454455456457458459460461462463464465466async function deriveRootKeyFromDerivedKey(467 derivedKeyBytes: Uint8Array,468 slotA: StoredVault,469): Promise<Uint8Array | null> {470 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {471 return null;472 }473 try {474 const rootHex = await decrypt(475 derivedKeyBytes,476 slotA.pw_root_nonce,477 slotA.pw_root_ciphertext,478 slotA.pw_root_tag,479 "pw_root_v1",480 );481 return unhex(rootHex);482 } catch {483 return null;484 }485}486487488489async function wrapRootKeyForDerivedKey(490 derivedKeyBytes: Uint8Array,491 rootKeyBytes: Uint8Array,492): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {493 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");494 return {495 pw_root_ciphertext: ciphertext,496 pw_root_nonce: nonce,497 pw_root_tag: tag,498 };499}500501502503504505506507508export async function migrateToRecovery(): Promise<void> {509 if (!derivedKey || vaultMode !== "real") {510 throw new Error("vault must be unlocked in real mode");511 }512 const slotA = await loadSlot(SLOT_A);513 if (!slotA) throw new Error("missing slot A");514 const hasRecoveryEscrow =515 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;516 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;517 if (hasRecoveryEscrow && hasPwRoot) return; 518519 520 521 522 523 524 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =525 await generateAndWrapRootKey();526 rootKey = unhex(rawRootKey);527528 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);529 const nextSlotA: StoredVault = {530 ...slotA,531 recovery_salt: recoverySalt,532 recovery_wrapped_root_key: wrappedRootKey,533 recovery_nonce: wrappedNonce,534 recovery_tag: wrappedTag,535 ...pwRoot,536 };537 await writeSlot(SLOT_A, nextSlotA);538 await stampLocalVaultMutation();539}540541542543544545546export async function getRecoveryWords(): Promise<string[]> {547 if (!rootKey) {548 throw new Error("recovery not set up — vault may need migration");549 }550 return rootKeyToWords(rootKey);551}552553async function loadSlot(key: string): Promise<StoredVault | null> {554 const result = await chrome.storage.local.get(key);555 return (result as Record<string, StoredVault | undefined>)[key] ?? null;556}557558async function writeSlot(key: string, vault: StoredVault): Promise<void> {559 try {560 await chrome.storage.local.set({ [key]: vault });561 } catch (err) {562 throw mapStorageError(err);563 }564}565566567568569570571572573let lastEntryTimestamp = 0;574function monotonicEntryTimestamp(currentModifiedAt?: number): number {575 const base = Math.max(576 Date.now(),577 (currentModifiedAt ?? 0) + 1,578 lastEntryTimestamp + 1,579 );580 lastEntryTimestamp = base;581 return base;582}583584async function stampLocalVaultMutation(): Promise<void> {585 const raw = await chrome.storage.local.get("peach_last_modified_at");586 const previous = Number(587 (raw as Record<string, unknown>).peach_last_modified_at,588 );589 const next = Math.max(590 Date.now(),591 Number.isFinite(previous) ? previous + 1 : 0,592 );593 await chrome.storage.local.set({594 peach_last_modified_at: String(next),595 });596}597598599600601602603async function ensureMigrated(): Promise<void> {604 if (migrationRan) return;605 migrationRan = true;606607 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];608 const raw = await chrome.storage.local.get(keys);609 const slotAExists = raw[SLOT_A] !== undefined;610 const slotBExists = raw[SLOT_B] !== undefined;611 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;612 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;613614 615 if (!slotAExists && !legacyMain) return;616617 618 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;619620 let didMigrate = false;621 if (legacyMain && !slotAExists) {622 await writeSlot(SLOT_A, legacyMain);623 didMigrate = true;624 }625626 627 if (!slotBExists) {628 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;629 if (legacyDecoy) {630 await writeSlot(SLOT_B, legacyDecoy);631 didMigrate = true;632 } else if (target) {633 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));634 didMigrate = true;635 }636 }637638 const toRemove: string[] = [];639 if (legacyMain) toRemove.push(LEGACY_MAIN);640 if (legacyDecoy) toRemove.push(LEGACY_DECOY);641 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);642 if (toRemove.length > 0) {643 await chrome.storage.local.remove(toRemove);644 didMigrate = true;645 }646 if (didMigrate) {647 await stampLocalVaultMutation();648 }649}650651652653654655656657658659660661function padPlaintext(json: string, minBytes = 0): string {662 const encoded = new TextEncoder().encode(json);663 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);664 665 const target = Math.max(bucket, minBytes);666 const padLen = target - encoded.length;667 return json + " ".repeat(padLen);668}669670671672673674675676677678679680const CSPRNG_CHUNK = 65_536;681682function fillRandom(buf: Uint8Array): Uint8Array {683 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {684 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);685 const chunk = new Uint8Array(end - offset);686 crypto.getRandomValues(chunk);687 buf.set(chunk, offset);688 }689 return buf;690}691692function generateNoiseBlob(ciphertextBytes: number): StoredVault {693 const salt = crypto.getRandomValues(new Uint8Array(16));694 const nonce = crypto.getRandomValues(new Uint8Array(12));695 const cipher = fillRandom(new Uint8Array(ciphertextBytes));696 const tag = crypto.getRandomValues(new Uint8Array(16));697 return {698 version: 1,699 salt: hex(salt),700 nonce: hex(nonce),701 ciphertext: hex(cipher),702 tag: hex(tag),703 };704}705706707708709710type VaultPayload = {711 entries: VaultEntry[];712 duressConfigured?: boolean;713};714715type DecryptedPayload = {716 entries: VaultEntry[];717 duressConfigured: boolean;718 719720721722723724725 migratedFromLegacy: boolean;726};727728function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {729 const envelope: VaultPayload = { entries, duressConfigured: duress };730 return padPlaintext(JSON.stringify(envelope), minBytes);731}732733function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {734 if ("created" in raw || "updated" in raw) return true;735 const flat = raw as Partial<VaultEntry>;736 if (flat.type === "passkey") return true;737 738 if (flat.rpId !== undefined && flat.passkey === undefined) return true;739 return false;740}741742function decodePayload(plain: string): DecryptedPayload {743 const parsed = JSON.parse(plain) as744 | Array<VaultEntry | LegacyVaultEntry>745 | VaultPayload;746747 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];748 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);749750 const normalized = rawEntries.map(normalizeEntry);751 const { merged } = mergePasskeyOnlyIntoLogins(normalized);752753 if (Array.isArray(parsed)) {754 return {755 entries: normalized,756 duressConfigured: false,757 migratedFromLegacy: true, 758 };759 }760761 return {762 entries: normalized,763 duressConfigured: parsed.duressConfigured === true,764 migratedFromLegacy: flatMigrationNeeded || merged,765 };766}767768769770771772773774775776777778779780781782783784785786787788789790791function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {792 let merged = false;793 const now = Date.now();794795 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);796 const byRpId = new Map<string, VaultEntry[]>();797 for (const e of candidates) {798 const rpId = e.passkey!.rpId;799 const list = byRpId.get(rpId);800 if (list) list.push(e);801 else byRpId.set(rpId, [e]);802 }803804 for (const [rpId, group] of byRpId) {805 if (group.length === 0) continue;806 group.sort((a, b) => {807 const aT =808 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;809 const bT =810 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;811 return bT - aT;812 });813 const winner = group[0]!;814815 816 817 818 const loginTarget = entries.find((e) => {819 if (e.deletedAt || e.passkey || e.type !== "login") return false;820 if (e.url && extractDomain(e.url) === rpId) return true;821 for (const extra of e.additionalUrls ?? []) {822 if (extractDomain(extra) === rpId) return true;823 }824 return false;825 });826827 if (loginTarget) {828 loginTarget.passkey = winner.passkey;829 loginTarget.modifiedAt = now;830 for (const dup of group) {831 if (!dup.deletedAt) {832 dup.deletedAt = now;833 dup.modifiedAt = now;834 }835 }836 merged = true;837 } else if (group.length > 1) {838 for (let i = 1; i < group.length; i += 1) {839 const dup = group[i]!;840 if (!dup.deletedAt) {841 dup.deletedAt = now;842 dup.modifiedAt = now;843 }844 }845 merged = true;846 }847 }848849 return { merged };850}851852853854855856857858function hasValidVaultSchema(v: unknown): v is StoredVault {859 if (typeof v !== "object" || v === null) return false;860 const r = v as Record<string, unknown>;861 return (862 r.version === 1 &&863 typeof r.salt === "string" &&864 typeof r.nonce === "string" &&865 typeof r.ciphertext === "string" &&866 typeof r.tag === "string"867 );868}869870export type TryDecryptResult =871 | { status: "ok"; payload: DecryptedPayload }872 | { status: "wrong_key" }873 | { status: "corrupted"; reason: string };874875876877878879880export async function tryDecryptSlot(881 vault: StoredVault,882 key: Uint8Array,883 aadTag?: string,884): Promise<DecryptedPayload | null> {885 const res = await tryDecryptSlotResult(vault, key, aadTag);886 return res.status === "ok" ? res.payload : null;887}888889export async function tryDecryptSlotResult(890 vault: StoredVault,891 key: Uint8Array,892 aadTag?: string,893): Promise<TryDecryptResult> {894 if (!hasValidVaultSchema(vault)) {895 return { status: "corrupted", reason: "invalid_slot_schema" };896 }897 let plain: string;898 try {899 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);900 } catch {901 return { status: "wrong_key" };902 }903 try {904 const parsed = JSON.parse(plain) as unknown;905 if (typeof parsed !== "object" || parsed === null) {906 return { status: "corrupted", reason: "invalid_payload_shape" };907 }908 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {909 return { status: "corrupted", reason: "invalid_payload_shape" };910 }911 return { status: "ok", payload: decodePayload(plain) };912 } catch {913 return { status: "corrupted", reason: "invalid_payload_json" };914 }915}916917918919920921function buildDomainIndexSync(all: VaultEntry[]): void {922 domainIndex = new Map();923924 for (const entry of all) {925 if (entry.deletedAt) continue;926 if (entry.type === "note" || entry.type === "card") continue;927928 929 930 931 932 const domains = new Set<string>();933 const primary = extractDomain(entry.url ?? "");934 if (primary) domains.add(primary);935 for (const extra of entry.additionalUrls ?? []) {936 const d = extractDomain(extra);937 if (d) domains.add(d);938 }939 if (domains.size === 0) continue;940941 for (const domain of domains) {942 const bucket = domainIndex.get(domain) ?? [];943 bucket.push(entry);944 domainIndex.set(domain, bucket);945 }946 }947}948949export async function createVault(password: string): Promise<void> {950 await ensureMigrated();951952 const salt = generateSalt();953 derivedKey = await deriveKey(password, salt);954 entries = [];955 vaultMode = "real";956 duressConfigured = false;957 buildDomainIndexSync(entries);958959 960 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =961 await generateAndWrapRootKey();962 rootKey = unhex(rawRootKey);963964 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);965 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");966967 968 969 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);970971 const slotA: StoredVault = {972 version: 1,973 salt: hex(salt),974 nonce,975 ciphertext,976 tag,977 recovery_salt: recoverySalt,978 recovery_wrapped_root_key: wrappedRootKey,979 recovery_nonce: wrappedNonce,980 recovery_tag: wrappedTag,981 ...pwRoot,982 };983 await writeSlot(SLOT_A, slotA);984985 986 987 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));988 await stampLocalVaultMutation();989990 touchAutoLock();991}992993export async function hasVault(): Promise<boolean> {994 await ensureMigrated();995 return (await loadSlot(SLOT_A)) !== null;996}9979989991000100110021003export async function isDuressEnabled(): Promise<boolean> {1004 return vaultMode === "real" && duressConfigured;1005}10061007export async function setupDuress(duressPassword: string): Promise<void> {1008 if (!derivedKey || vaultMode !== "real") {1009 throw new Error("vault must be unlocked in real mode");1010 }1011 await ensureMigrated();10121013 1014 1015 1016 const slotA = await loadSlot(SLOT_A);1017 if (!slotA) throw new Error("missing slot A");10181019 const decoySalt = generateSalt();1020 const decoyKey = await deriveKey(duressPassword, decoySalt);1021 try {1022 1023 1024 1025 1026 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1027 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1028 await writeSlot(SLOT_B, {1029 version: 1,1030 salt: hex(decoySalt),1031 nonce,1032 ciphertext,1033 tag,1034 });1035 } finally {1036 zeroKey(decoyKey);1037 }10381039 1040 1041 duressConfigured = true;1042 await persistSlotA();1043}10441045export async function disableDuress(): Promise<void> {1046 if (!derivedKey || vaultMode !== "real") {1047 throw new Error("vault must be unlocked in real mode");1048 }1049 await ensureMigrated();10501051 1052 1053 1054 const slotA = await loadSlot(SLOT_A);1055 if (slotA) {1056 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1057 }10581059 duressConfigured = false;1060 await persistSlotA();1061}10621063106410651066106710681069export async function verifyMainPassword(password: string): Promise<boolean> {1070 await ensureMigrated();1071 const slotA = await loadSlot(SLOT_A);1072 if (!slotA) return false;1073 const key = await deriveKey(password, unhex(slotA.salt));1074 try {1075 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1076 } finally {1077 zeroKey(key);1078 }1079}10801081export async function unlock(password: string): Promise<boolean> {1082 1083 1084 1085 if (await attemptUnlockWithDerive(password, deriveKey)) return true;10861087 const nfc = password.normalize("NFC");1088 if (password === nfc) return false; 10891090 if (await attemptUnlockWithDerive(password, deriveKeyRaw)) {1091 1092 1093 1094 if (vaultMode === "real") {1095 try {1096 const newSalt = generateSalt();1097 const newKey = await deriveKey(password, newSalt);1098 await changePasswordWithNewKey(newSalt, newKey);1099 } catch (healErr) {1100 console.warn("[Peach] LV-1 NFC heal failed:", healErr);1101 }1102 }1103 return true;1104 }1105 return false;1106}11071108async function attemptUnlockWithDerive(1109 password: string,1110 derive: typeof deriveKey,1111): Promise<boolean> {1112 await ensureMigrated();11131114 1115 1116 1117 1118 1119 const [slotA, slotB] = await Promise.all([1120 loadSlot(SLOT_A),1121 loadSlot(SLOT_B),1122 ]);11231124 1125 const [keyA, keyB] = await Promise.all([1126 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1127 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1128 ]);11291130 1131 1132 1133 const [resultA, resultB] = await Promise.all([1134 keyA && slotA1135 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1136 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1137 keyB && slotB1138 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1139 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1140 ]);1141 const payloadA = resultA.status === "ok" ? resultA.payload : null;1142 const payloadB = resultB.status === "ok" ? resultB.payload : null;11431144 if (payloadA && keyA) {1145 derivedKey = keyA;1146 void saveSessionKey(keyA, "real");1147 vaultMode = "real";1148 entries = payloadA.entries;1149 duressConfigured = payloadA.duressConfigured;1150 buildDomainIndexSync(entries);1151 await loadSettings();1152 touchAutoLock();1153 if (keyB) zeroKey(keyB);11541155 1156 if (slotA) {1157 const needsRecoverySetup =1158 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1159 if (!needsRecoverySetup) {1160 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1161 }1162 1163 1164 1165 1166 if (needsRecoverySetup || !rootKey) {1167 try {1168 await migrateToRecovery();1169 if (needsRecoverySetup) {1170 1171 1172 1173 await chrome.storage.local.set({ peach_recovery_pending: '1' });1174 }1175 } catch (migrationErr) {1176 console.warn('[Peach] recovery migration failed:', migrationErr);1177 }1178 }1179 }11801181 1182 1183 1184 if (payloadA.migratedFromLegacy) {1185 await persistSlotA();1186 }1187 return true;1188 }11891190 if (payloadB && keyB) {1191 derivedKey = keyB;1192 void saveSessionKey(keyB, "decoy");1193 vaultMode = "decoy";1194 entries = payloadB.entries;1195 duressConfigured = false;1196 buildDomainIndexSync(entries);1197 await loadSettings();1198 touchAutoLock();1199 if (keyA) zeroKey(keyA);1200 return true;1201 }12021203 if (keyA) zeroKey(keyA);1204 if (keyB) zeroKey(keyB);1205 1206 1207 if (resultA.status === "corrupted") {1208 lastUnlockFailureReason = resultA.reason;1209 } else if (resultB.status === "corrupted") {1210 lastUnlockFailureReason = resultB.reason;1211 }1212 return false;1213}12141215121612171218121912201221const SESSION_KEY_STORAGE = "peach_session_dk";12221223async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1224 if (!chrome.storage.session) return;1225 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1226}12271228async function clearSessionKey(): Promise<void> {1229 if (!chrome.storage.session) return;1230 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1231}12321233123412351236123712381239export async function restoreFromSession(): Promise<boolean> {1240 if (derivedKey !== null) return true;1241 if (!chrome.storage.session) return false;1242 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1243 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1244 if (!entry?.keyHex) return false;12451246 const key = unhex(entry.keyHex);1247 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1248 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1249 const slot = await loadSlot(slotKey);1250 if (!slot) { await clearSessionKey(); return false; }12511252 const result = await tryDecryptSlotResult(slot, key, aadTag);1253 if (result.status !== "ok") { await clearSessionKey(); return false; }12541255 derivedKey = key;1256 vaultMode = entry.mode;1257 entries = result.payload.entries;1258 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1259 buildDomainIndexSync(entries);1260 await loadSettings();1261 touchAutoLock();12621263 if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1264 try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { }1265 }12661267 return true;1268}12691270export function lock(): void {1271 void clearSessionKey();1272 if (derivedKey) {1273 zeroKey(derivedKey);1274 derivedKey = null;1275 }1276 if (rootKey) {1277 rootKey.fill(0);1278 rootKey = null;1279 }12801281 entries = [];1282 domainIndex = new Map();1283 vaultMode = "real";1284 duressConfigured = false;1285 clearAutoLock();1286}12871288export function isUnlocked(): boolean {1289 return derivedKey !== null;1290}1291129212931294export async function unlockWithKey(1295 masterKeyHex: string,1296): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1297 try {1298 const masterKeyBytes = unhex(masterKeyHex);1299 const stored = await loadSlot(SLOT_A);1300 if (!stored) return { ok: false, error: "invalid_key" };1301 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1302 if (!payload) return { ok: false, error: "invalid_key" };13031304 derivedKey = masterKeyBytes;1305 vaultMode = "real";1306 entries = payload.entries;1307 duressConfigured = payload.duressConfigured;1308 buildDomainIndexSync(entries);1309 await loadSettings();1310 touchAutoLock();1311 return { ok: true, entries };1312 } catch {1313 return { ok: false, error: "invalid_key" };1314 }1315}13161317131813191320132113221323132413251326132713281329133013311332133313341335export async function unlockFromCodex(params: {1336 slotA: string; 1337 slotB: string | null; 1338 timestamp: number;1339 masterPassphrase: string;1340}): Promise<1341 | { ok: true; entryCount: number }1342 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1343> {1344 let parsedSlotA: StoredVault;1345 let parsedSlotB: StoredVault | null = null;1346 try {1347 const rawA = JSON.parse(params.slotA) as unknown;1348 if (!hasValidVaultSchema(rawA)) {1349 return { ok: false, reason: "invalid_slot_format" };1350 }1351 parsedSlotA = rawA;1352 if (params.slotB !== null) {1353 const rawB = JSON.parse(params.slotB) as unknown;1354 if (!hasValidVaultSchema(rawB)) {1355 return { ok: false, reason: "invalid_slot_format" };1356 }1357 parsedSlotB = rawB;1358 }1359 } catch {1360 return { ok: false, reason: "invalid_slot_format" };1361 }13621363 1364 1365 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1366 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1367 if (!trialPayload) {1368 zeroKey(trialKey);1369 return { ok: false, reason: "wrong_passphrase" };1370 }1371 1372 1373 zeroKey(trialKey);13741375 1376 1377 await takeVaultSnapshot("restore");13781379 await writeSlot(SLOT_A, parsedSlotA);1380 if (parsedSlotB) {1381 await writeSlot(SLOT_B, parsedSlotB);1382 } else {1383 1384 1385 1386 await writeSlot(1387 SLOT_B,1388 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1389 );1390 }1391 await chrome.storage.local.set({1392 peach_last_modified_at: String(params.timestamp),1393 });13941395 1396 1397 const ok = await unlock(params.masterPassphrase);1398 if (!ok) {1399 1400 1401 1402 return { ok: false, reason: "wrong_passphrase" };1403 }1404 return { ok: true, entryCount: getEntries().length };1405}1406140714081409export function getDerivedKeyHex(): string {1410 if (!derivedKey) throw new Error("vault_not_unlocked");1411 return hex(derivedKey);1412}14131414141514161417141814191420142114221423142414251426142714281429export function getRootKeyHex(): string | null {1430 return rootKey ? hex(rootKey) : null;1431}14321433export function getEntries(): VaultEntry[] {1434 if (!derivedKey) throw new Error("vault_locked");1435 return entries.filter((entry) => !entry.deletedAt);1436}14371438const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;14391440export function getIdentities(): SavedIdentity[] {1441 if (!derivedKey) throw new Error("vault_locked");1442 const active = entries.filter((e) => !e.deletedAt && e.type === "login");14431444 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1445 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();14461447 for (const entry of active) {1448 const domain = extractDomain(entry.url ?? "") ?? "";1449 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;14501451 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1452 const existing = map.get(v);1453 if (!existing || modifiedAt > existing.lastUsed) {1454 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1455 } else {1456 map.set(v, { ...existing, count: existing.count + 1 });1457 }1458 }14591460 const uVal = entry.username?.trim();1461 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);14621463 1464 1465 const eVal = entry.email?.trim();1466 if (eVal) tally(emailMap, eVal);1467 }14681469 const toIdentity = (type: "email" | "username") =>1470 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1471 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1472 });14731474 return [1475 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1476 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1477 ];1478}14791480export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1481 if (!derivedKey) throw new Error("vault_locked");14821483 const now = Date.now();1484 const added: VaultEntry[] = [];1485 for (const draft of drafts) {1486 try {1487 const sanitized = sanitizeEntryDraft(draft);1488 const nextEntry: VaultEntry = {1489 ...sanitized,1490 id: crypto.randomUUID(),1491 createdAt: now,1492 modifiedAt: monotonicEntryTimestamp(),1493 };1494 entries.push(nextEntry);1495 added.push(nextEntry);1496 } catch {1497 1498 }1499 }15001501 if (added.length > 0) {1502 await persistEntries();1503 }15041505 return added;1506}150715081509151015111512151315141515export async function restoreEntries(1516 incoming: VaultEntry[],1517 mode: "replace" | "merge"1518): Promise<{ added: number; updated: number; skipped: number }> {1519 if (!derivedKey) throw new Error("vault_locked");15201521 1522 1523 1524 if (mode === "replace" && incoming.length === 0) {1525 throw new Error("refuse to replace with empty entry set");1526 }15271528 let added = 0;1529 let updated = 0;1530 let skipped = 0;15311532 if (mode === "replace") {1533 entries = [];1534 for (const entry of incoming) {1535 const normalized = sanitizeRestoredEntry(entry);1536 if (!normalized) {1537 skipped += 1;1538 continue;1539 }1540 entries.push(normalized);1541 added += 1;1542 }1543 } else {1544 const byId = new Map<string, VaultEntry>();1545 for (const e of entries) byId.set(e.id, e);15461547 for (const entry of incoming) {1548 const normalized = sanitizeRestoredEntry(entry);1549 if (!normalized) {1550 skipped += 1;1551 continue;1552 }1553 const existing = byId.get(normalized.id);1554 if (!existing) {1555 entries.push(normalized);1556 added += 1;1557 } else if (normalized.modifiedAt > existing.modifiedAt) {1558 const idx = entries.findIndex((e) => e.id === existing.id);1559 if (idx !== -1) {1560 entries[idx] = normalized;1561 updated += 1;1562 }1563 } else {1564 skipped += 1;1565 }1566 }1567 }15681569 await persistEntries("import");1570 return { added, updated, skipped };1571}157215731574157515761577157815791580function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1581 if (!entry || typeof entry !== "object") return null;1582 const title = typeof entry.title === "string" ? entry.title.trim() : "";1583 if (!title) return null;1584 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1585 const now = Date.now();1586 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1587 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;15881589 1590 1591 1592 1593 const normalized = normalizeEntry({1594 ...entry,1595 id,1596 title,1597 type: entry.type ?? "login",1598 createdAt,1599 modifiedAt,1600 });16011602 if (normalized.passkey) {1603 normalized.passkey = sanitizePasskey(normalized.passkey);1604 }1605 if (normalized.canary) {1606 normalized.canary = sanitizeCanary(normalized.canary);1607 }16081609 1610 1611 if (1612 normalized.type === "login" &&1613 !normalized.passkey &&1614 typeof normalized.password !== "string"1615 ) {1616 return null;1617 }1618 if (entry.passkey && !normalized.passkey) {1619 1620 1621 return null;1622 }16231624 return normalized;1625}1626162716281629163016311632function sanitizePasskey(pk: unknown): PasskeyData | undefined {1633 if (!pk || typeof pk !== "object") return undefined;1634 const r = pk as Record<string, unknown>;1635 if (1636 typeof r.rpId !== "string" || !r.rpId ||1637 typeof r.credentialId !== "string" || !r.credentialId ||1638 typeof r.publicKeyCose !== "string" ||1639 typeof r.userHandle !== "string" ||1640 typeof r.userName !== "string"1641 ) {1642 return undefined;1643 }1644 return {1645 rpId: r.rpId,1646 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1647 credentialId: r.credentialId,1648 publicKeyCose: r.publicKeyCose,1649 privateKeyJwk:1650 r.privateKeyJwk && typeof r.privateKeyJwk === "object"1651 ? (r.privateKeyJwk as JsonWebKey)1652 : undefined,1653 userHandle: r.userHandle,1654 userName: r.userName,1655 userDisplayName:1656 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1657 algorithm: r.algorithm === -8 ? -8 : -7,1658 signCount: typeof r.signCount === "number" ? r.signCount : 0,1659 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1660 backupEligible: r.backupEligible === true,1661 backupState: r.backupState === true,1662 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1663 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1664 transports: Array.isArray(r.transports)1665 ? r.transports.filter((t): t is string => typeof t === "string")1666 : undefined,1667 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1668 };1669}16701671167216731674167516761677167816791680function sanitizeCanary(c: unknown): Canary | undefined {1681 if (!c || typeof c !== "object") return undefined;1682 const r = c as Record<string, unknown>;1683 if (typeof r.webhookUrl !== "string") return undefined;1684 let url: URL;1685 try {1686 url = new URL(r.webhookUrl);1687 } catch {1688 return undefined;1689 }1690 if (url.protocol !== "https:") return undefined;1691 return {1692 webhookUrl: url.toString(),1693 label: typeof r.label === "string" ? r.label : undefined,1694 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,1695 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,1696 };1697}169816991700170117021703export async function clearVaultEntries(1704 confirmation: "REPLACE_ALL"1705): Promise<void> {1706 if (confirmation !== "REPLACE_ALL") {1707 throw new Error("clearVaultEntries requires explicit confirmation");1708 }1709 if (!derivedKey) throw new Error("vault_locked");1710 for (const entry of entries) {1711 if (!entry.deletedAt) {1712 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1713 entry.deletedAt = stamp;1714 entry.modifiedAt = stamp;1715 }1716 }1717 await persistEntries();1718}17191720export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1721 if (!derivedKey) throw new Error("vault_locked");17221723 const sanitized = sanitizeEntryDraft(entry);1724 const now = Date.now();1725 const stamp = monotonicEntryTimestamp();1726 const nextEntry: VaultEntry = {1727 ...sanitized,1728 id: crypto.randomUUID(),1729 createdAt: now,1730 modifiedAt: stamp,1731 1732 1733 passwordChangedAt: sanitized.password ? stamp : undefined,1734 };17351736 1737 1738 1739 const rollback = entries;1740 entries = [...entries, nextEntry];1741 try {1742 await persistEntries();1743 } catch (err) {1744 entries = rollback;1745 throw err;1746 }17471748 1749 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1750 checkBreach(nextEntry.password).then(async (count) => {1751 if (count > 0) {1752 const idx = entries.findIndex((e) => e.id === nextEntry.id);1753 if (idx !== -1) {1754 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };1755 await persistEntries();1756 }1757 }1758 });1759 }17601761 fireSyncTrigger("vault_change");17621763 return nextEntry;1764}17651766176717681769177017711772export async function consolidatePasskeyEntries(): Promise<void> {1773 if (!derivedKey) return;1774 const { merged } = mergePasskeyOnlyIntoLogins(entries);1775 if (merged) {1776 await persistEntries();1777 }1778}17791780export async function updateEntry(1781 id: string,1782 patch: Partial<VaultEntry>1783): Promise<void> {1784 if (!derivedKey) throw new Error("vault_locked");17851786 const idx = entries.findIndex((entry) => entry.id === id);1787 if (idx === -1 || entries[idx].deletedAt) {1788 throw new Error("entry_not_found");1789 }17901791 const current = entries[idx];1792 const sanitizedPatch = sanitizeEntryPatch(patch);1793 1794 1795 1796 const passwordChanged =1797 "password" in patch &&1798 typeof sanitizedPatch.password === "string" &&1799 sanitizedPatch.password !== current.password;1800 const stamp = monotonicEntryTimestamp(current.modifiedAt);1801 1802 1803 1804 1805 1806 1807 1808 const priorPassword = current.password;1809 const shouldCapture =1810 passwordChanged &&1811 typeof priorPassword === "string" &&1812 priorPassword.length > 0 &&1813 current.passwordHistory?.[0]?.password !== priorPassword;1814 const nextHistory = shouldCapture1815 ? [1816 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },1817 ...(current.passwordHistory ?? []),1818 ].slice(0, PASSWORD_HISTORY_MAX)1819 : current.passwordHistory;1820 entries[idx] = {1821 ...current,1822 ...sanitizedPatch,1823 modifiedAt: stamp,1824 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),1825 };1826182718281829 1830 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1831 entries[idx] = current;1832 throw new Error("card number is required for card entries");1833 }18341835 1836 1837 1838 1839 try {1840 await persistEntries();1841 } catch (err) {1842 entries[idx] = current;1843 throw err;1844 }18451846 1847 1848 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1849 entries[idx] = { ...entries[idx], breach: undefined };1850 await persistEntries();1851 const password = entries[idx].password;1852 if (password) {1853 checkBreach(password).then(async (count) => {1854 const i = entries.findIndex((e) => e.id === id);1855 if (i !== -1 && count > 0) {1856 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };1857 await persistEntries();1858 }1859 });1860 }1861 }18621863 fireSyncTrigger("vault_change");1864}18651866export async function deleteEntry(id: string): Promise<void> {1867 if (!derivedKey) throw new Error("vault_locked");18681869 const entry = entries.find((item) => item.id === id);1870 if (!entry || entry.deletedAt) {1871 throw new Error("entry_not_found");1872 }18731874 1875 1876 1877 const previousModifiedAt = entry.modifiedAt;1878 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1879 entry.deletedAt = stamp;1880 entry.modifiedAt = stamp;1881 1882 1883 try {1884 await persistEntries();1885 } catch (err) {1886 delete entry.deletedAt;1887 entry.modifiedAt = previousModifiedAt;1888 throw err;1889 }1890 fireSyncTrigger("vault_change");1891}18921893export function getDeletedEntries(): VaultEntry[] {1894 if (!derivedKey) return [];1895 return entries.filter((e) => !!e.deletedAt);1896}1897189818991900190119021903export async function getVaultVersion(): Promise<number> {1904 try {1905 const raw = await chrome.storage.local.get("peach_last_modified_at");1906 const v = (raw as Record<string, unknown>).peach_last_modified_at;1907 if (typeof v === "number") return v;1908 if (typeof v === "string" && v !== "") {1909 const n = Number.parseInt(v, 10);1910 return Number.isFinite(n) ? n : 0;1911 }1912 return 0;1913 } catch {1914 return 0;1915 }1916}191719181919192019211922192319241925192619271928export async function addEntryDirect(entry: VaultEntry): Promise<void> {1929 if (!derivedKey) throw new Error("vault_locked");1930 entries = [...entries, entry];1931 await persistEntries("sync_received");1932}19331934export async function updateEntryDirect(patch: VaultEntry): Promise<void> {1935 if (!derivedKey) throw new Error("vault_locked");1936 const idx = entries.findIndex((e) => e.id === patch.id);1937 if (idx === -1) throw new Error("entry_not_found");1938 entries[idx] = { ...entries[idx], ...patch };1939 await persistEntries("sync_received");1940}19411942export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {1943 if (!derivedKey) throw new Error("vault_locked");1944 const idx = entries.findIndex((e) => e.id === id);1945 if (idx === -1) throw new Error("entry_not_found");1946 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };1947 await persistEntries("sync_received");1948}1949195019511952195319541955195619571958195919601961export async function applyMergedRemoteEntries(1962 remoteEntries: VaultEntry[],1963 remoteTombstones: Record<string, number>,1964 context: { deviceName?: string } = {},1965): Promise<{ received: number; applied: number; conflicts: number }> {1966 1967 1968 1969 1970 1971 1972 if (!derivedKey) throw new Error("vault_locked");19731974 const received = remoteEntries.length + Object.keys(remoteTombstones).length;1975 let applied = 0;1976 let conflicts = 0;19771978 const byId = new Map<string, VaultEntry>();1979 for (const e of entries) byId.set(e.id, e);19801981 const alerts: SyncAlert[] = [];1982 const deviceName = context.deviceName ?? "paired device";1983 const mergeNow = Date.now();19841985 for (const remote of remoteEntries) {1986 const sanitized = sanitizeRestoredEntry(remote);1987 if (!sanitized) continue;1988 1989 1990 1991 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);1992 if (typeof sanitized.deletedAt === "number") {1993 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);1994 }1995 const tombstoneTs = remoteTombstones[sanitized.id];1996 if (tombstoneTs !== undefined && clampSyncTimestamp(tombstoneTs, mergeNow) >= sanitized.modifiedAt) {1997 continue;1998 }1999 const local = byId.get(sanitized.id);2000 if (!local) {2001 entries.push(sanitized);2002 byId.set(sanitized.id, sanitized);2003 applied += 1;2004 } else if (sanitized.modifiedAt > local.modifiedAt) {2005 const idx = entries.findIndex((e) => e.id === local.id);2006 if (idx !== -1) {2007 2008 2009 if (2010 !local.deletedAt &&2011 typeof sanitized.password === "string" &&2012 typeof local.password === "string" &&2013 sanitized.password !== local.password2014 ) {2015 alerts.push({2016 type: "REMOTE_PASSWORD_CHANGE",2017 entryId: sanitized.id,2018 entryTitle: sanitized.title,2019 deviceName,2020 timestamp: mergeNow,2021 });2022 }2023 entries[idx] = sanitized;2024 byId.set(sanitized.id, sanitized);2025 applied += 1;2026 if (!local.deletedAt) conflicts += 1;2027 }2028 }2029 }20302031 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2032 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt)) continue;2033 2034 const deletedAt = clampSyncTimestamp(rawDeletedAt, mergeNow);2035 const local = byId.get(id);2036 if (!local) continue;2037 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2038 if (deletedAt < local.modifiedAt) continue;2039 const idx = entries.findIndex((e) => e.id === id);2040 if (idx !== -1) {2041 2042 if (!local.deletedAt) {2043 alerts.push({2044 type: "REMOTE_DELETE",2045 entryId: id,2046 entryTitle: local.title,2047 deviceName,2048 timestamp: mergeNow,2049 });2050 }2051 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2052 applied += 1;2053 }2054 }20552056 if (applied > 0) {2057 await persistEntries("sync_received");2058 buildDomainIndexSync(entries);2059 }2060 2061 2062 await recordSyncAlerts(alerts);2063 return { received, applied, conflicts };2064}20652066export async function restoreEntry(id: string): Promise<void> {2067 if (!derivedKey) throw new Error("vault_locked");2068 const entry = entries.find((e) => e.id === id && e.deletedAt);2069 if (!entry) throw new Error("deleted entry not found");2070 delete entry.deletedAt;2071 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);2072 await persistEntries();2073 buildDomainIndexSync(entries);2074}20752076export async function purgeEntry(id: string): Promise<void> {2077 if (!derivedKey) throw new Error("vault_locked");2078 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2079 if (idx === -1) throw new Error("deleted entry not found");2080 entries.splice(idx, 1);2081 await persistEntries();2082}208320842085export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2086 if (!derivedKey || retentionMs === 0) return 0;2087 const cutoff = Date.now() - retentionMs;2088 const before = entries.length;2089 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2090 const purged = before - entries.length;2091 if (purged > 0) await persistEntries();2092 return purged;2093}20942095export function queryDomain(domain: string): VaultEntry[] {2096 return domainIndex.get(domain) ?? [];2097}20982099export function getEntryById(id: string): VaultEntry | undefined {2100 return entries.find((entry) => entry.id === id && !entry.deletedAt);2101}21022103async function persistSlotA(2104 reason: VaultSnapshot["reason"] = "local_save",2105): Promise<StoredVault> {2106 if (!derivedKey) throw new Error("vault_locked");21072108 const slotA = await loadSlot(SLOT_A);2109 if (!slotA) throw new Error("missing slot A");21102111 2112 2113 await takeVaultSnapshot(reason);21142115 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2116 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");21172118 2119 const nextSlotA: StoredVault = {2120 ...slotA,2121 nonce,2122 ciphertext,2123 tag,2124 2125 };2126 await writeSlot(SLOT_A, nextSlotA);21272128 2129 2130 2131 2132 2133 2134 try {2135 2136 2137 2138 if (!duressConfigured) {2139 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2140 }2141 2142 2143 2144 2145 2146 if (reason !== "sync_received") {2147 await stampLocalVaultMutation();2148 }2149 } catch (err) {2150 2151 2152 await writeSlot(SLOT_A, slotA).catch(() => {});2153 throw err;2154 }21552156 return nextSlotA;2157}21582159async function persistSlotB(): Promise<void> {2160 if (!derivedKey) throw new Error("vault_locked");21612162 const slotB = await loadSlot(SLOT_B);2163 if (!slotB) throw new Error("missing slot B");21642165 2166 2167 2168 2169 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2170 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");21712172 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2173 2174 2175 2176 try {2177 await stampLocalVaultMutation();2178 } catch (err) {2179 await writeSlot(SLOT_B, slotB).catch(() => {});2180 throw err;2181 }2182}21832184async function persistEntries(2185 reason: VaultSnapshot["reason"] = "local_save",2186): Promise<void> {2187 if (!derivedKey) throw new Error("vault_locked");21882189 if (vaultMode === "decoy") {2190 await persistSlotB();2191 } else {2192 await persistSlotA(reason);2193 }2194 buildDomainIndexSync(entries);2195}21962197function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2198 if (!Array.isArray(values)) return undefined;2199 const seen = new Set<string>();2200 const cleaned: string[] = [];2201 for (const value of values) {2202 const trimmed = typeof value === "string" ? value.trim() : "";2203 if (!trimmed) continue;2204 const key = trimmed.toLowerCase();2205 if (seen.has(key)) continue;2206 seen.add(key);2207 cleaned.push(trimmed);2208 }2209 return cleaned.length > 0 ? cleaned : undefined;2210}22112212function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2213 const title = entry.title.trim();2214 2215 2216 2217 let type: VaultEntry["type"] = entry.type ?? "login";2218 if (type === "passkey") type = "login";22192220 if (!title) throw new Error("title is required");22212222 if (entry.passkey) {2223 const p = entry.passkey;2224 if (2225 !p.rpId ||2226 !p.credentialId ||2227 !p.publicKeyCose ||2228 !p.userHandle ||2229 !p.userName ||2230 (p.algorithm !== -7 && p.algorithm !== -8)2231 ) {2232 throw new Error("invalid passkey credential");2233 }2234 }2235 if (type === "card") {2236 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2237 throw new Error("cardholder name and card number are required");2238 }2239 }22402241 return {2242 type,2243 title,2244 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2245 email: type === "login" ? entry.email?.trim() || undefined : undefined,2246 password: type === "login" ? entry.password : undefined,2247 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2248 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2249 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2250 notes: entry.notes?.trim() || undefined,2251 breach: type === "login" ? entry.breach : undefined,2252 canary: type === "login" ? entry.canary : undefined,2253 tags: entry.tags,2254 passkey: entry.passkey,2255 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2256 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2257 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2258 expYear: type === "card" ? entry.expYear?.trim() : undefined,2259 cvc: type === "card" ? entry.cvc?.trim() : undefined,2260 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2261 network: type === "card" ? entry.network : undefined,2262 last4: type === "card" ? entry.last4?.trim() : undefined,2263 };2264}22652266function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2267 const nextPatch: Partial<VaultEntry> = {};22682269 if ("type" in patch) {2270 nextPatch.type = patch.type;2271 }22722273 if ("title" in patch) {2274 const title = patch.title?.trim() ?? "";2275 if (!title) throw new Error("title is required");2276 nextPatch.title = title;2277 }22782279 if ("url" in patch) {2280 nextPatch.url = patch.url?.trim() ?? "";2281 }22822283 if ("additionalUrls" in patch) {2284 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2285 const seen = new Set<string>();2286 const cleaned: string[] = [];2287 for (const value of incoming) {2288 const trimmed = typeof value === "string" ? value.trim() : "";2289 if (!trimmed) continue;2290 const key = trimmed.toLowerCase();2291 if (seen.has(key)) continue;2292 seen.add(key);2293 cleaned.push(trimmed);2294 }2295 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2296 }22972298 if ("username" in patch) {2299 nextPatch.username = patch.username?.trim() ?? "";2300 }23012302 if ("email" in patch) {2303 nextPatch.email = patch.email?.trim() || undefined;2304 }23052306 if ("password" in patch) {2307 const password = patch.password ?? "";2308 2309 nextPatch.password = password || undefined;2310 }23112312 if ("totp" in patch) {2313 nextPatch.totp = patch.totp?.trim() || undefined;2314 }23152316 if ("notes" in patch) {2317 nextPatch.notes = patch.notes?.trim() || undefined;2318 }23192320 if ("breach" in patch) {2321 nextPatch.breach = patch.breach;2322 }23232324 if ("canary" in patch) {2325 nextPatch.canary = patch.canary;2326 }23272328 if ("passkey" in patch) {2329 2330 2331 2332 nextPatch.passkey = patch.passkey;2333 }23342335 if ("tags" in patch) {2336 nextPatch.tags = patch.tags;2337 }23382339 if ("cardholderName" in patch) {2340 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2341 }2342 if ("cardNumber" in patch) {2343 const n = patch.cardNumber ?? "";2344 nextPatch.cardNumber = n || undefined;2345 }2346 if ("expMonth" in patch) {2347 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2348 }2349 if ("expYear" in patch) {2350 nextPatch.expYear = patch.expYear?.trim() || undefined;2351 }2352 if ("cvc" in patch) {2353 nextPatch.cvc = patch.cvc?.trim() || undefined;2354 }2355 if ("cardNickname" in patch) {2356 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2357 }2358 if ("network" in patch) {2359 nextPatch.network = patch.network;2360 }2361 if ("last4" in patch) {2362 nextPatch.last4 = patch.last4?.trim() || undefined;2363 }23642365 return nextPatch;2366}23672368function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2369 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2370 const modifiedAt =2371 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;23722373 const base: VaultEntry = {2374 id: entry.id,2375 type: "type" in entry ? entry.type : "login",2376 title: entry.title,2377 username: entry.username,2378 email: "email" in entry ? entry.email : undefined,2379 password: entry.password,2380 url: entry.url,2381 additionalUrls:2382 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2383 ? entry.additionalUrls2384 : undefined,2385 totp: entry.totp,2386 notes: entry.notes,2387 breach: entry.breach,2388 2389 2390 2391 passwordChangedAt:2392 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2393 2394 2395 2396 passwordHistory:2397 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2398 ? entry.passwordHistory2399 : undefined,2400 canary: "canary" in entry ? entry.canary : undefined,2401 passkey: "passkey" in entry ? entry.passkey : undefined,2402 createdAt,2403 modifiedAt,2404 deletedAt: entry.deletedAt,2405 tags: "tags" in entry ? entry.tags : undefined,2406 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2407 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2408 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2409 expYear: "expYear" in entry ? entry.expYear : undefined,2410 cvc: "cvc" in entry ? entry.cvc : undefined,2411 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2412 network: "network" in entry ? entry.network : undefined,2413 last4: "last4" in entry ? entry.last4 : undefined,2414 };24152416 return migrateLegacyPasskey(base, entry);2417}241824192420242124222423242424252426function migrateLegacyPasskey(2427 base: VaultEntry,2428 raw: VaultEntry | LegacyVaultEntry2429): VaultEntry {2430 if (base.passkey) return base;2431 const flat = raw as Partial<VaultEntry>;2432 const isPasskeyType = base.type === "passkey";2433 const hasFlatPasskeyFields =2434 typeof flat.rpId === "string" &&2435 typeof flat.credentialId === "string" &&2436 typeof flat.publicKeyCose === "string" &&2437 typeof flat.userHandle === "string" &&2438 typeof flat.userName === "string";2439 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2440 if (!hasFlatPasskeyFields) return base;24412442 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2443 base.passkey = {2444 rpId: flat.rpId!,2445 rpName: flat.rpName ?? flat.rpId!,2446 credentialId: flat.credentialId!,2447 publicKeyCose: flat.publicKeyCose!,2448 privateKeyJwk: flat.privateKeyJwk,2449 userHandle: flat.userHandle!,2450 userName: flat.userName!,2451 userDisplayName: flat.userDisplayName ?? flat.userName!,2452 algorithm,2453 signCount: flat.signCount ?? 0,2454 aaguid: flat.aaguid ?? "",2455 backupEligible: flat.backupEligible === true,2456 backupState: flat.backupState === true,2457 lastUsedAt: flat.lastUsedAt,2458 browser: flat.browser,2459 transports: flat.transports,2460 createdAt: base.createdAt,2461 };2462 2463 2464 2465 if (isPasskeyType) base.type = "login";2466 if (!base.username) base.username = flat.userName;2467 if (!base.url) base.url = `https://${flat.rpId}`;2468 return base;2469}2470247124722473247424752476function entryDupeKey(e: VaultEntry): string {2477 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2478 2479 2480 2481 2482 if (e.passkey) {2483 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2484 }2485 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2486}248724882489export function countDuplicates(): number {2490 const best = new Map<string, VaultEntry>();2491 let dupeCount = 0;2492 for (const e of entries.filter((e) => !e.deletedAt)) {2493 const key = entryDupeKey(e);2494 const existing = best.get(key);2495 if (!existing) {2496 best.set(key, e);2497 } else {2498 dupeCount++;2499 2500 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2501 best.set(key, e);2502 }2503 }2504 }2505 return dupeCount;2506}250725082509251025112512export async function deduplicateEntries(): Promise<number> {2513 if (!derivedKey) throw new Error("vault_locked");2514 2515 2516 if (vaultMode === "decoy") {2517 throw new Error("deduplicate disabled in decoy mode");2518 }25192520 const best = new Map<string, VaultEntry>();2521 for (const e of entries.filter((en) => !en.deletedAt)) {2522 const key = entryDupeKey(e);2523 const existing = best.get(key);2524 if (!existing) {2525 best.set(key, e);2526 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2527 best.set(key, e);2528 }2529 }25302531 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2532 let removed = 0;2533 entries = entries.map((e) => {2534 if (!e.deletedAt && !keepIds.has(e.id)) {2535 removed++;2536 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2537 }2538 return e;2539 });25402541 if (removed > 0) await persistEntries();2542 return removed;2543}254425452546254725482549255025512552const HISTORY_KEY = "peach_vault_history";2553const HISTORY_MAX = 5;25542555255625572558255925602561export async function takeVaultSnapshot(2562 reason: VaultSnapshot["reason"],2563): Promise<void> {2564 try {2565 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2566 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2567 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];25682569 2570 2571 2572 if (!slotA) return;25732574 const vault_a = JSON.stringify(slotA);2575 const vault_b = JSON.stringify(slotB ?? slotA);25762577 2578 2579 2580 let entryCount: number | null = null;2581 if (derivedKey && vaultMode === "real") {2582 try {2583 entryCount = entries.filter((e) => !e.deletedAt).length;2584 } catch {2585 entryCount = null;2586 }2587 }25882589 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2590 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2591 ? existingRaw2592 : { snapshots: [] };25932594 2595 2596 2597 const latest = history.snapshots[0];2598 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2599 return;2600 }26012602 const snapshot: VaultSnapshot = {2603 id: crypto.randomUUID(),2604 savedAt: Date.now(),2605 reason,2606 vault_a,2607 vault_b,2608 entryCount,2609 };26102611 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2612 await chrome.storage.local.set({ [HISTORY_KEY]: history });2613 } catch (err) {2614 2615 2616 2617 2618 console.warn('[Peach] takeVaultSnapshot failed:', err);2619 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2620 try {2621 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2622 } catch {2623 2624 }2625 }2626 }2627}262826292630export async function clearVaultHistory(): Promise<void> {2631 try {2632 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2633 } catch {2634 2635 }2636}263726382639export async function getVaultHistory(): Promise<VaultSnapshot[]> {2640 const raw = await chrome.storage.local.get(HISTORY_KEY);2641 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2642 if (!history || !Array.isArray(history.snapshots)) return [];2643 return history.snapshots;2644}26452646264726482649265026512652export async function restoreVaultSnapshot(2653 snapshotId: string,2654): Promise<{ ok: true } | { ok: false; error: string }> {2655 const history = await getVaultHistory();2656 const snapshot = history.find((s) => s.id === snapshotId);2657 if (!snapshot) return { ok: false, error: "snapshot_not_found" };26582659 2660 2661 2662 await takeVaultSnapshot("restore");26632664 let restoredA: StoredVault;2665 let restoredB: StoredVault;2666 try {2667 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2668 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2669 } catch {2670 return { ok: false, error: "snapshot_corrupt" };2671 }26722673 await writeSlot(SLOT_A, restoredA);2674 await writeSlot(SLOT_B, restoredB);2675 await stampLocalVaultMutation();26762677 2678 2679 2680 lock();26812682 return { ok: true };2683}268426852686268726882689269026912692269326942695269626972698export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {2699 if (!derivedKey || vaultMode !== "real") {2700 return { ok: false, error: "vault_locked" };2701 }2702 const slotA = await loadSlot(SLOT_A);2703 if (!slotA) return { ok: false, error: "missing_slot_a" };27042705 2706 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =2707 await generateAndWrapRootKey();27082709 2710 const newRootKey = unhex(rawRootKey);2711 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);27122713 2714 2715 2716 2717 await takeVaultSnapshot("local_save");2718 const nextSlotA: StoredVault = {2719 ...slotA,2720 recovery_salt: recoverySalt,2721 recovery_wrapped_root_key: wrappedRootKey,2722 recovery_nonce: wrappedNonce,2723 recovery_tag: wrappedTag,2724 ...pwRoot,2725 };2726 await writeSlot(SLOT_A, nextSlotA);27272728 2729 if (rootKey) zeroKey(rootKey);2730 rootKey = newRootKey;27312732 2733 const { clearPairedDevices } = await import("./paired-devices");2734 await clearPairedDevices();27352736 2737 await setRecoveryConfirmPending(); 27382739 await stampLocalVaultMutation();2740 return { ok: true };2741}27422743274427452746274727482749export async function changePassword(2750 currentPassword: string,2751 newPassword: string2752): Promise<boolean> {2753 if (!derivedKey || vaultMode !== "real") return false;27542755 2756 const valid = await verifyMainPassword(currentPassword);2757 if (!valid) return false;27582759 const slotA = await loadSlot(SLOT_A);2760 if (!slotA) return false;2761 if (!rootKey) return false;27622763 2764 const newSalt = generateSalt();2765 const newKey = await deriveKey(newPassword, newSalt);27662767 2768 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2769 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");27702771 2772 2773 2774 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);27752776 const finalSlotA: StoredVault = {2777 ...slotA,2778 salt: hex(newSalt),2779 nonce,2780 ciphertext,2781 tag,2782 ...pwRoot,2783 };27842785 2786 2787 2788 await takeVaultSnapshot("local_save");27892790 2791 2792 2793 2794 await writeSlot(SLOT_A, finalSlotA);27952796 2797 2798 2799 zeroKey(derivedKey);2800 derivedKey = newKey;28012802 2803 2804 2805 if (!duressConfigured) {2806 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2807 }2808 await stampLocalVaultMutation();28092810 return true;2811}