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();1315 return { ok: true, entries };1316 } catch {1317 return { ok: false, error: "invalid_key" };1318 }1319}13201321132213231324132513261327132813291330133113321333133413351336133713381339export async function unlockFromCodex(params: {1340 slotA: string; 1341 slotB: string | null; 1342 timestamp: number;1343 masterPassphrase: string;1344}): Promise<1345 | { ok: true; entryCount: number }1346 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1347> {1348 let parsedSlotA: StoredVault;1349 let parsedSlotB: StoredVault | null = null;1350 try {1351 const rawA = JSON.parse(params.slotA) as unknown;1352 if (!hasValidVaultSchema(rawA)) {1353 return { ok: false, reason: "invalid_slot_format" };1354 }1355 parsedSlotA = rawA;1356 if (params.slotB !== null) {1357 const rawB = JSON.parse(params.slotB) as unknown;1358 if (!hasValidVaultSchema(rawB)) {1359 return { ok: false, reason: "invalid_slot_format" };1360 }1361 parsedSlotB = rawB;1362 }1363 } catch {1364 return { ok: false, reason: "invalid_slot_format" };1365 }13661367 1368 1369 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1370 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1371 if (!trialPayload) {1372 zeroKey(trialKey);1373 return { ok: false, reason: "wrong_passphrase" };1374 }1375 1376 1377 zeroKey(trialKey);13781379 1380 1381 await takeVaultSnapshot("restore");13821383 await writeSlot(SLOT_A, parsedSlotA);1384 if (parsedSlotB) {1385 await writeSlot(SLOT_B, parsedSlotB);1386 } else {1387 1388 1389 1390 await writeSlot(1391 SLOT_B,1392 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1393 );1394 }1395 await chrome.storage.local.set({1396 peach_last_modified_at: String(params.timestamp),1397 });13981399 1400 1401 const ok = await unlock(params.masterPassphrase);1402 if (!ok) {1403 1404 1405 1406 return { ok: false, reason: "wrong_passphrase" };1407 }1408 return { ok: true, entryCount: getEntries().length };1409}1410141114121413export function getDerivedKeyHex(): string {1414 if (!derivedKey) throw new Error("vault_not_unlocked");1415 return hex(derivedKey);1416}14171418141914201421142214231424142514261427142814291430143114321433export function getRootKeyHex(): string | null {1434 return rootKey ? hex(rootKey) : null;1435}14361437export function getEntries(): VaultEntry[] {1438 if (!derivedKey) throw new Error("vault_locked");1439 return entries.filter((entry) => !entry.deletedAt);1440}14411442const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;14431444export function getIdentities(): SavedIdentity[] {1445 if (!derivedKey) throw new Error("vault_locked");1446 const active = entries.filter((e) => !e.deletedAt && e.type === "login");14471448 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1449 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();14501451 for (const entry of active) {1452 const domain = extractDomain(entry.url ?? "") ?? "";1453 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;14541455 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1456 const existing = map.get(v);1457 if (!existing || modifiedAt > existing.lastUsed) {1458 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1459 } else {1460 map.set(v, { ...existing, count: existing.count + 1 });1461 }1462 }14631464 const uVal = entry.username?.trim();1465 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);14661467 1468 1469 const eVal = entry.email?.trim();1470 if (eVal) tally(emailMap, eVal);1471 }14721473 const toIdentity = (type: "email" | "username") =>1474 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1475 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1476 });14771478 return [1479 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1480 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1481 ];1482}14831484export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1485 if (!derivedKey) throw new Error("vault_locked");14861487 const now = Date.now();1488 const added: VaultEntry[] = [];1489 for (const draft of drafts) {1490 try {1491 const sanitized = sanitizeEntryDraft(draft);1492 const nextEntry: VaultEntry = {1493 ...sanitized,1494 id: crypto.randomUUID(),1495 createdAt: now,1496 modifiedAt: monotonicEntryTimestamp(),1497 };1498 entries.push(nextEntry);1499 added.push(nextEntry);1500 } catch {1501 1502 }1503 }15041505 if (added.length > 0) {1506 await persistEntries();1507 }15081509 return added;1510}151115121513151415151516151715181519export async function restoreEntries(1520 incoming: VaultEntry[],1521 mode: "replace" | "merge"1522): Promise<{ added: number; updated: number; skipped: number }> {1523 if (!derivedKey) throw new Error("vault_locked");15241525 1526 1527 1528 if (mode === "replace" && incoming.length === 0) {1529 throw new Error("refuse to replace with empty entry set");1530 }15311532 let added = 0;1533 let updated = 0;1534 let skipped = 0;15351536 if (mode === "replace") {1537 entries = [];1538 for (const entry of incoming) {1539 const normalized = sanitizeRestoredEntry(entry);1540 if (!normalized) {1541 skipped += 1;1542 continue;1543 }1544 entries.push(normalized);1545 added += 1;1546 }1547 } else {1548 const byId = new Map<string, VaultEntry>();1549 for (const e of entries) byId.set(e.id, e);15501551 for (const entry of incoming) {1552 const normalized = sanitizeRestoredEntry(entry);1553 if (!normalized) {1554 skipped += 1;1555 continue;1556 }1557 const existing = byId.get(normalized.id);1558 if (!existing) {1559 entries.push(normalized);1560 added += 1;1561 } else if (normalized.modifiedAt > existing.modifiedAt) {1562 const idx = entries.findIndex((e) => e.id === existing.id);1563 if (idx !== -1) {1564 entries[idx] = normalized;1565 updated += 1;1566 }1567 } else {1568 skipped += 1;1569 }1570 }1571 }15721573 await persistEntries("import");1574 return { added, updated, skipped };1575}157615771578157915801581158215831584function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1585 if (!entry || typeof entry !== "object") return null;1586 const title = typeof entry.title === "string" ? entry.title.trim() : "";1587 if (!title) return null;1588 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1589 const now = Date.now();1590 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1591 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;15921593 1594 1595 1596 1597 const normalized = normalizeEntry({1598 ...entry,1599 id,1600 title,1601 type: entry.type ?? "login",1602 createdAt,1603 modifiedAt,1604 });16051606 if (normalized.passkey) {1607 normalized.passkey = sanitizePasskey(normalized.passkey);1608 }1609 if (normalized.canary) {1610 normalized.canary = sanitizeCanary(normalized.canary);1611 }16121613 1614 1615 if (1616 normalized.type === "login" &&1617 !normalized.passkey &&1618 typeof normalized.password !== "string"1619 ) {1620 return null;1621 }1622 if (entry.passkey && !normalized.passkey) {1623 1624 1625 return null;1626 }16271628 return normalized;1629}1630163116321633163416351636function sanitizePasskey(pk: unknown): PasskeyData | undefined {1637 if (!pk || typeof pk !== "object") return undefined;1638 const r = pk as Record<string, unknown>;1639 if (1640 typeof r.rpId !== "string" || !r.rpId ||1641 typeof r.credentialId !== "string" || !r.credentialId ||1642 typeof r.publicKeyCose !== "string" ||1643 typeof r.userHandle !== "string" ||1644 typeof r.userName !== "string"1645 ) {1646 return undefined;1647 }1648 return {1649 rpId: r.rpId,1650 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1651 credentialId: r.credentialId,1652 publicKeyCose: r.publicKeyCose,1653 privateKeyJwk:1654 r.privateKeyJwk && typeof r.privateKeyJwk === "object"1655 ? (r.privateKeyJwk as JsonWebKey)1656 : undefined,1657 userHandle: r.userHandle,1658 userName: r.userName,1659 userDisplayName:1660 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1661 algorithm: r.algorithm === -8 ? -8 : -7,1662 signCount: typeof r.signCount === "number" ? r.signCount : 0,1663 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1664 backupEligible: r.backupEligible === true,1665 backupState: r.backupState === true,1666 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1667 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1668 transports: Array.isArray(r.transports)1669 ? r.transports.filter((t): t is string => typeof t === "string")1670 : undefined,1671 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1672 };1673}16741675167616771678167916801681168216831684function sanitizeCanary(c: unknown): Canary | undefined {1685 if (!c || typeof c !== "object") return undefined;1686 const r = c as Record<string, unknown>;1687 if (typeof r.webhookUrl !== "string") return undefined;1688 let url: URL;1689 try {1690 url = new URL(r.webhookUrl);1691 } catch {1692 return undefined;1693 }1694 if (url.protocol !== "https:") return undefined;1695 return {1696 webhookUrl: url.toString(),1697 label: typeof r.label === "string" ? r.label : undefined,1698 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,1699 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,1700 };1701}170217031704170517061707export async function clearVaultEntries(1708 confirmation: "REPLACE_ALL"1709): Promise<void> {1710 if (confirmation !== "REPLACE_ALL") {1711 throw new Error("clearVaultEntries requires explicit confirmation");1712 }1713 if (!derivedKey) throw new Error("vault_locked");1714 for (const entry of entries) {1715 if (!entry.deletedAt) {1716 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1717 entry.deletedAt = stamp;1718 entry.modifiedAt = stamp;1719 }1720 }1721 await persistEntries();1722}17231724export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1725 if (!derivedKey) throw new Error("vault_locked");17261727 const sanitized = sanitizeEntryDraft(entry);1728 const now = Date.now();1729 const stamp = monotonicEntryTimestamp();1730 const nextEntry: VaultEntry = {1731 ...sanitized,1732 id: crypto.randomUUID(),1733 createdAt: now,1734 modifiedAt: stamp,1735 1736 1737 passwordChangedAt: sanitized.password ? stamp : undefined,1738 };17391740 1741 1742 1743 const rollback = entries;1744 entries = [...entries, nextEntry];1745 try {1746 await persistEntries();1747 } catch (err) {1748 entries = rollback;1749 throw err;1750 }17511752 1753 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1754 checkBreach(nextEntry.password).then(async (count) => {1755 if (count > 0) {1756 const idx = entries.findIndex((e) => e.id === nextEntry.id);1757 if (idx !== -1) {1758 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };1759 await persistEntries();1760 }1761 }1762 });1763 }17641765 fireSyncTrigger("vault_change");17661767 return nextEntry;1768}17691770177117721773177417751776export async function consolidatePasskeyEntries(): Promise<void> {1777 if (!derivedKey) return;1778 const { merged } = mergePasskeyOnlyIntoLogins(entries);1779 if (merged) {1780 await persistEntries();1781 }1782}17831784export async function updateEntry(1785 id: string,1786 patch: Partial<VaultEntry>1787): Promise<void> {1788 if (!derivedKey) throw new Error("vault_locked");17891790 const idx = entries.findIndex((entry) => entry.id === id);1791 if (idx === -1 || entries[idx].deletedAt) {1792 throw new Error("entry_not_found");1793 }17941795 const current = entries[idx];1796 const sanitizedPatch = sanitizeEntryPatch(patch);1797 1798 1799 1800 const passwordChanged =1801 "password" in patch &&1802 typeof sanitizedPatch.password === "string" &&1803 sanitizedPatch.password !== current.password;1804 const stamp = monotonicEntryTimestamp(current.modifiedAt);1805 1806 1807 1808 1809 1810 1811 1812 const priorPassword = current.password;1813 const shouldCapture =1814 passwordChanged &&1815 typeof priorPassword === "string" &&1816 priorPassword.length > 0 &&1817 current.passwordHistory?.[0]?.password !== priorPassword;1818 const nextHistory = shouldCapture1819 ? [1820 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },1821 ...(current.passwordHistory ?? []),1822 ].slice(0, PASSWORD_HISTORY_MAX)1823 : current.passwordHistory;1824 entries[idx] = {1825 ...current,1826 ...sanitizedPatch,1827 modifiedAt: stamp,1828 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),1829 };1830183118321833 1834 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1835 entries[idx] = current;1836 throw new Error("card number is required for card entries");1837 }18381839 1840 1841 1842 1843 try {1844 await persistEntries();1845 } catch (err) {1846 entries[idx] = current;1847 throw err;1848 }18491850 1851 1852 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1853 entries[idx] = { ...entries[idx], breach: undefined };1854 await persistEntries();1855 const password = entries[idx].password;1856 if (password) {1857 checkBreach(password).then(async (count) => {1858 const i = entries.findIndex((e) => e.id === id);1859 if (i !== -1 && count > 0) {1860 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };1861 await persistEntries();1862 }1863 });1864 }1865 }18661867 fireSyncTrigger("vault_change");1868}18691870export async function deleteEntry(id: string): Promise<void> {1871 if (!derivedKey) throw new Error("vault_locked");18721873 const entry = entries.find((item) => item.id === id);1874 if (!entry || entry.deletedAt) {1875 throw new Error("entry_not_found");1876 }18771878 1879 1880 1881 const previousModifiedAt = entry.modifiedAt;1882 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1883 entry.deletedAt = stamp;1884 entry.modifiedAt = stamp;1885 1886 1887 try {1888 await persistEntries();1889 } catch (err) {1890 delete entry.deletedAt;1891 entry.modifiedAt = previousModifiedAt;1892 throw err;1893 }1894 fireSyncTrigger("vault_change");1895}18961897export function getDeletedEntries(): VaultEntry[] {1898 if (!derivedKey) return [];1899 return entries.filter((e) => !!e.deletedAt);1900}1901190219031904190519061907export async function getVaultVersion(): Promise<number> {1908 try {1909 const raw = await chrome.storage.local.get("peach_last_modified_at");1910 const v = (raw as Record<string, unknown>).peach_last_modified_at;1911 if (typeof v === "number") return v;1912 if (typeof v === "string" && v !== "") {1913 const n = Number.parseInt(v, 10);1914 return Number.isFinite(n) ? n : 0;1915 }1916 return 0;1917 } catch {1918 return 0;1919 }1920}192119221923192419251926192719281929193019311932export async function addEntryDirect(entry: VaultEntry): Promise<void> {1933 if (!derivedKey) throw new Error("vault_locked");1934 entries = [...entries, entry];1935 await persistEntries("sync_received");1936}19371938export async function updateEntryDirect(patch: VaultEntry): Promise<void> {1939 if (!derivedKey) throw new Error("vault_locked");1940 const idx = entries.findIndex((e) => e.id === patch.id);1941 if (idx === -1) throw new Error("entry_not_found");1942 entries[idx] = { ...entries[idx], ...patch };1943 await persistEntries("sync_received");1944}19451946export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {1947 if (!derivedKey) throw new Error("vault_locked");1948 const idx = entries.findIndex((e) => e.id === id);1949 if (idx === -1) throw new Error("entry_not_found");1950 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };1951 await persistEntries("sync_received");1952}1953195419551956195719581959196019611962196319641965export async function applyMergedRemoteEntries(1966 remoteEntries: VaultEntry[],1967 remoteTombstones: Record<string, number>,1968 context: { deviceName?: string } = {},1969): Promise<{ received: number; applied: number; conflicts: number }> {1970 1971 1972 1973 1974 1975 1976 if (!derivedKey) throw new Error("vault_locked");19771978 const received = remoteEntries.length + Object.keys(remoteTombstones).length;1979 let applied = 0;1980 let conflicts = 0;19811982 const byId = new Map<string, VaultEntry>();1983 for (const e of entries) byId.set(e.id, e);19841985 const alerts: SyncAlert[] = [];1986 const deviceName = context.deviceName ?? "paired device";1987 const mergeNow = Date.now();19881989 for (const remote of remoteEntries) {1990 const sanitized = sanitizeRestoredEntry(remote);1991 if (!sanitized) continue;1992 1993 1994 1995 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);1996 if (typeof sanitized.deletedAt === "number") {1997 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);1998 }1999 const tombstoneTs = remoteTombstones[sanitized.id];2000 if (tombstoneTs !== undefined && clampSyncTimestamp(tombstoneTs, mergeNow) >= sanitized.modifiedAt) {2001 continue;2002 }2003 const local = byId.get(sanitized.id);2004 if (!local) {2005 entries.push(sanitized);2006 byId.set(sanitized.id, sanitized);2007 applied += 1;2008 } else if (sanitized.modifiedAt > local.modifiedAt) {2009 const idx = entries.findIndex((e) => e.id === local.id);2010 if (idx !== -1) {2011 2012 2013 if (2014 !local.deletedAt &&2015 typeof sanitized.password === "string" &&2016 typeof local.password === "string" &&2017 sanitized.password !== local.password2018 ) {2019 alerts.push({2020 type: "REMOTE_PASSWORD_CHANGE",2021 entryId: sanitized.id,2022 entryTitle: sanitized.title,2023 deviceName,2024 timestamp: mergeNow,2025 });2026 }2027 entries[idx] = sanitized;2028 byId.set(sanitized.id, sanitized);2029 applied += 1;2030 if (!local.deletedAt) conflicts += 1;2031 }2032 }2033 }20342035 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2036 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt)) continue;2037 2038 const deletedAt = clampSyncTimestamp(rawDeletedAt, mergeNow);2039 const local = byId.get(id);2040 if (!local) continue;2041 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2042 if (deletedAt < local.modifiedAt) continue;2043 const idx = entries.findIndex((e) => e.id === id);2044 if (idx !== -1) {2045 2046 if (!local.deletedAt) {2047 alerts.push({2048 type: "REMOTE_DELETE",2049 entryId: id,2050 entryTitle: local.title,2051 deviceName,2052 timestamp: mergeNow,2053 });2054 }2055 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2056 applied += 1;2057 }2058 }20592060 if (applied > 0) {2061 await persistEntries("sync_received");2062 buildDomainIndexSync(entries);2063 }2064 2065 2066 await recordSyncAlerts(alerts);2067 return { received, applied, conflicts };2068}20692070export async function restoreEntry(id: string): Promise<void> {2071 if (!derivedKey) throw new Error("vault_locked");2072 const entry = entries.find((e) => e.id === id && e.deletedAt);2073 if (!entry) throw new Error("deleted entry not found");2074 delete entry.deletedAt;2075 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);2076 await persistEntries();2077 buildDomainIndexSync(entries);2078}20792080export async function purgeEntry(id: string): Promise<void> {2081 if (!derivedKey) throw new Error("vault_locked");2082 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2083 if (idx === -1) throw new Error("deleted entry not found");2084 entries.splice(idx, 1);2085 await persistEntries();2086}208720882089export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2090 if (!derivedKey || retentionMs === 0) return 0;2091 const cutoff = Date.now() - retentionMs;2092 const before = entries.length;2093 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2094 const purged = before - entries.length;2095 if (purged > 0) await persistEntries();2096 return purged;2097}20982099export function queryDomain(domain: string): VaultEntry[] {2100 return domainIndex.get(domain) ?? [];2101}21022103export function getEntryById(id: string): VaultEntry | undefined {2104 return entries.find((entry) => entry.id === id && !entry.deletedAt);2105}21062107async function persistSlotA(2108 reason: VaultSnapshot["reason"] = "local_save",2109): Promise<StoredVault> {2110 if (!derivedKey) throw new Error("vault_locked");21112112 const slotA = await loadSlot(SLOT_A);2113 if (!slotA) throw new Error("missing slot A");21142115 2116 2117 await takeVaultSnapshot(reason);21182119 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2120 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");21212122 2123 const nextSlotA: StoredVault = {2124 ...slotA,2125 nonce,2126 ciphertext,2127 tag,2128 2129 };2130 await writeSlot(SLOT_A, nextSlotA);21312132 2133 2134 2135 2136 2137 2138 try {2139 2140 2141 2142 if (!duressConfigured) {2143 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2144 }2145 2146 2147 2148 2149 2150 if (reason !== "sync_received") {2151 await stampLocalVaultMutation();2152 }2153 } catch (err) {2154 2155 2156 await writeSlot(SLOT_A, slotA).catch(() => {});2157 throw err;2158 }21592160 return nextSlotA;2161}21622163async function persistSlotB(): Promise<void> {2164 if (!derivedKey) throw new Error("vault_locked");21652166 const slotB = await loadSlot(SLOT_B);2167 if (!slotB) throw new Error("missing slot B");21682169 2170 2171 2172 2173 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2174 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");21752176 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2177 2178 2179 2180 try {2181 await stampLocalVaultMutation();2182 } catch (err) {2183 await writeSlot(SLOT_B, slotB).catch(() => {});2184 throw err;2185 }2186}21872188async function persistEntries(2189 reason: VaultSnapshot["reason"] = "local_save",2190): Promise<void> {2191 if (!derivedKey) throw new Error("vault_locked");21922193 if (vaultMode === "decoy") {2194 await persistSlotB();2195 } else {2196 await persistSlotA(reason);2197 }2198 buildDomainIndexSync(entries);2199}22002201function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2202 if (!Array.isArray(values)) return undefined;2203 const seen = new Set<string>();2204 const cleaned: string[] = [];2205 for (const value of values) {2206 const trimmed = typeof value === "string" ? value.trim() : "";2207 if (!trimmed) continue;2208 const key = trimmed.toLowerCase();2209 if (seen.has(key)) continue;2210 seen.add(key);2211 cleaned.push(trimmed);2212 }2213 return cleaned.length > 0 ? cleaned : undefined;2214}22152216function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2217 const title = entry.title.trim();2218 2219 2220 2221 let type: VaultEntry["type"] = entry.type ?? "login";2222 if (type === "passkey") type = "login";22232224 if (!title) throw new Error("title is required");22252226 if (entry.passkey) {2227 const p = entry.passkey;2228 if (2229 !p.rpId ||2230 !p.credentialId ||2231 !p.publicKeyCose ||2232 !p.userHandle ||2233 !p.userName ||2234 (p.algorithm !== -7 && p.algorithm !== -8)2235 ) {2236 throw new Error("invalid passkey credential");2237 }2238 }2239 if (type === "card") {2240 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2241 throw new Error("cardholder name and card number are required");2242 }2243 }22442245 return {2246 type,2247 title,2248 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2249 email: type === "login" ? entry.email?.trim() || undefined : undefined,2250 password: type === "login" ? entry.password : undefined,2251 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2252 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2253 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2254 notes: entry.notes?.trim() || undefined,2255 breach: type === "login" ? entry.breach : undefined,2256 canary: type === "login" ? entry.canary : undefined,2257 tags: entry.tags,2258 passkey: entry.passkey,2259 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2260 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2261 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2262 expYear: type === "card" ? entry.expYear?.trim() : undefined,2263 cvc: type === "card" ? entry.cvc?.trim() : undefined,2264 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2265 network: type === "card" ? entry.network : undefined,2266 last4: type === "card" ? entry.last4?.trim() : undefined,2267 };2268}22692270function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2271 const nextPatch: Partial<VaultEntry> = {};22722273 if ("type" in patch) {2274 nextPatch.type = patch.type;2275 }22762277 if ("title" in patch) {2278 const title = patch.title?.trim() ?? "";2279 if (!title) throw new Error("title is required");2280 nextPatch.title = title;2281 }22822283 if ("url" in patch) {2284 nextPatch.url = patch.url?.trim() ?? "";2285 }22862287 if ("additionalUrls" in patch) {2288 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2289 const seen = new Set<string>();2290 const cleaned: string[] = [];2291 for (const value of incoming) {2292 const trimmed = typeof value === "string" ? value.trim() : "";2293 if (!trimmed) continue;2294 const key = trimmed.toLowerCase();2295 if (seen.has(key)) continue;2296 seen.add(key);2297 cleaned.push(trimmed);2298 }2299 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2300 }23012302 if ("username" in patch) {2303 nextPatch.username = patch.username?.trim() ?? "";2304 }23052306 if ("email" in patch) {2307 nextPatch.email = patch.email?.trim() || undefined;2308 }23092310 if ("password" in patch) {2311 const password = patch.password ?? "";2312 2313 nextPatch.password = password || undefined;2314 }23152316 if ("totp" in patch) {2317 nextPatch.totp = patch.totp?.trim() || undefined;2318 }23192320 if ("notes" in patch) {2321 nextPatch.notes = patch.notes?.trim() || undefined;2322 }23232324 if ("breach" in patch) {2325 nextPatch.breach = patch.breach;2326 }23272328 if ("canary" in patch) {2329 nextPatch.canary = patch.canary;2330 }23312332 if ("passkey" in patch) {2333 2334 2335 2336 nextPatch.passkey = patch.passkey;2337 }23382339 if ("tags" in patch) {2340 nextPatch.tags = patch.tags;2341 }23422343 if ("cardholderName" in patch) {2344 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2345 }2346 if ("cardNumber" in patch) {2347 const n = patch.cardNumber ?? "";2348 nextPatch.cardNumber = n || undefined;2349 }2350 if ("expMonth" in patch) {2351 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2352 }2353 if ("expYear" in patch) {2354 nextPatch.expYear = patch.expYear?.trim() || undefined;2355 }2356 if ("cvc" in patch) {2357 nextPatch.cvc = patch.cvc?.trim() || undefined;2358 }2359 if ("cardNickname" in patch) {2360 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2361 }2362 if ("network" in patch) {2363 nextPatch.network = patch.network;2364 }2365 if ("last4" in patch) {2366 nextPatch.last4 = patch.last4?.trim() || undefined;2367 }23682369 return nextPatch;2370}23712372function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2373 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2374 const modifiedAt =2375 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;23762377 const base: VaultEntry = {2378 id: entry.id,2379 type: "type" in entry ? entry.type : "login",2380 title: entry.title,2381 username: entry.username,2382 email: "email" in entry ? entry.email : undefined,2383 password: entry.password,2384 url: entry.url,2385 additionalUrls:2386 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2387 ? entry.additionalUrls2388 : undefined,2389 totp: entry.totp,2390 notes: entry.notes,2391 breach: entry.breach,2392 2393 2394 2395 passwordChangedAt:2396 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2397 2398 2399 2400 passwordHistory:2401 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2402 ? entry.passwordHistory2403 : undefined,2404 canary: "canary" in entry ? entry.canary : undefined,2405 passkey: "passkey" in entry ? entry.passkey : undefined,2406 createdAt,2407 modifiedAt,2408 deletedAt: entry.deletedAt,2409 tags: "tags" in entry ? entry.tags : undefined,2410 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2411 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2412 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2413 expYear: "expYear" in entry ? entry.expYear : undefined,2414 cvc: "cvc" in entry ? entry.cvc : undefined,2415 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2416 network: "network" in entry ? entry.network : undefined,2417 last4: "last4" in entry ? entry.last4 : undefined,2418 };24192420 return migrateLegacyPasskey(base, entry);2421}242224232424242524262427242824292430function migrateLegacyPasskey(2431 base: VaultEntry,2432 raw: VaultEntry | LegacyVaultEntry2433): VaultEntry {2434 if (base.passkey) return base;2435 const flat = raw as Partial<VaultEntry>;2436 const isPasskeyType = base.type === "passkey";2437 const hasFlatPasskeyFields =2438 typeof flat.rpId === "string" &&2439 typeof flat.credentialId === "string" &&2440 typeof flat.publicKeyCose === "string" &&2441 typeof flat.userHandle === "string" &&2442 typeof flat.userName === "string";2443 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2444 if (!hasFlatPasskeyFields) return base;24452446 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2447 base.passkey = {2448 rpId: flat.rpId!,2449 rpName: flat.rpName ?? flat.rpId!,2450 credentialId: flat.credentialId!,2451 publicKeyCose: flat.publicKeyCose!,2452 privateKeyJwk: flat.privateKeyJwk,2453 userHandle: flat.userHandle!,2454 userName: flat.userName!,2455 userDisplayName: flat.userDisplayName ?? flat.userName!,2456 algorithm,2457 signCount: flat.signCount ?? 0,2458 aaguid: flat.aaguid ?? "",2459 backupEligible: flat.backupEligible === true,2460 backupState: flat.backupState === true,2461 lastUsedAt: flat.lastUsedAt,2462 browser: flat.browser,2463 transports: flat.transports,2464 createdAt: base.createdAt,2465 };2466 2467 2468 2469 if (isPasskeyType) base.type = "login";2470 if (!base.username) base.username = flat.userName;2471 if (!base.url) base.url = `https://${flat.rpId}`;2472 return base;2473}2474247524762477247824792480function entryDupeKey(e: VaultEntry): string {2481 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2482 2483 2484 2485 2486 if (e.passkey) {2487 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2488 }2489 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2490}249124922493export function countDuplicates(): number {2494 const best = new Map<string, VaultEntry>();2495 let dupeCount = 0;2496 for (const e of entries.filter((e) => !e.deletedAt)) {2497 const key = entryDupeKey(e);2498 const existing = best.get(key);2499 if (!existing) {2500 best.set(key, e);2501 } else {2502 dupeCount++;2503 2504 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2505 best.set(key, e);2506 }2507 }2508 }2509 return dupeCount;2510}251125122513251425152516export async function deduplicateEntries(): Promise<number> {2517 if (!derivedKey) throw new Error("vault_locked");2518 2519 2520 if (vaultMode === "decoy") {2521 throw new Error("deduplicate disabled in decoy mode");2522 }25232524 const best = new Map<string, VaultEntry>();2525 for (const e of entries.filter((en) => !en.deletedAt)) {2526 const key = entryDupeKey(e);2527 const existing = best.get(key);2528 if (!existing) {2529 best.set(key, e);2530 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2531 best.set(key, e);2532 }2533 }25342535 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2536 let removed = 0;2537 entries = entries.map((e) => {2538 if (!e.deletedAt && !keepIds.has(e.id)) {2539 removed++;2540 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2541 }2542 return e;2543 });25442545 if (removed > 0) await persistEntries();2546 return removed;2547}254825492550255125522553255425552556const HISTORY_KEY = "peach_vault_history";2557const HISTORY_MAX = 5;25582559256025612562256325642565export async function takeVaultSnapshot(2566 reason: VaultSnapshot["reason"],2567): Promise<void> {2568 try {2569 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2570 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2571 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];25722573 2574 2575 2576 if (!slotA) return;25772578 const vault_a = JSON.stringify(slotA);2579 const vault_b = JSON.stringify(slotB ?? slotA);25802581 2582 2583 2584 let entryCount: number | null = null;2585 if (derivedKey && vaultMode === "real") {2586 try {2587 entryCount = entries.filter((e) => !e.deletedAt).length;2588 } catch {2589 entryCount = null;2590 }2591 }25922593 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2594 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2595 ? existingRaw2596 : { snapshots: [] };25972598 2599 2600 2601 const latest = history.snapshots[0];2602 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2603 return;2604 }26052606 const snapshot: VaultSnapshot = {2607 id: crypto.randomUUID(),2608 savedAt: Date.now(),2609 reason,2610 vault_a,2611 vault_b,2612 entryCount,2613 };26142615 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2616 await chrome.storage.local.set({ [HISTORY_KEY]: history });2617 } catch (err) {2618 2619 2620 2621 2622 console.warn('[Peach] takeVaultSnapshot failed:', err);2623 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2624 try {2625 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2626 } catch {2627 2628 }2629 }2630 }2631}263226332634export async function clearVaultHistory(): Promise<void> {2635 try {2636 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2637 } catch {2638 2639 }2640}264126422643export async function getVaultHistory(): Promise<VaultSnapshot[]> {2644 const raw = await chrome.storage.local.get(HISTORY_KEY);2645 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2646 if (!history || !Array.isArray(history.snapshots)) return [];2647 return history.snapshots;2648}26492650265126522653265426552656export async function restoreVaultSnapshot(2657 snapshotId: string,2658): Promise<{ ok: true } | { ok: false; error: string }> {2659 const history = await getVaultHistory();2660 const snapshot = history.find((s) => s.id === snapshotId);2661 if (!snapshot) return { ok: false, error: "snapshot_not_found" };26622663 2664 2665 2666 await takeVaultSnapshot("restore");26672668 let restoredA: StoredVault;2669 let restoredB: StoredVault;2670 try {2671 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2672 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2673 } catch {2674 return { ok: false, error: "snapshot_corrupt" };2675 }26762677 await writeSlot(SLOT_A, restoredA);2678 await writeSlot(SLOT_B, restoredB);2679 await stampLocalVaultMutation();26802681 2682 2683 2684 lock();26852686 return { ok: true };2687}268826892690269126922693269426952696269726982699270027012702export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {2703 if (!derivedKey || vaultMode !== "real") {2704 return { ok: false, error: "vault_locked" };2705 }2706 const slotA = await loadSlot(SLOT_A);2707 if (!slotA) return { ok: false, error: "missing_slot_a" };27082709 2710 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =2711 await generateAndWrapRootKey();27122713 2714 const newRootKey = unhex(rawRootKey);2715 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);27162717 2718 2719 2720 2721 await takeVaultSnapshot("local_save");2722 const nextSlotA: StoredVault = {2723 ...slotA,2724 recovery_salt: recoverySalt,2725 recovery_wrapped_root_key: wrappedRootKey,2726 recovery_nonce: wrappedNonce,2727 recovery_tag: wrappedTag,2728 ...pwRoot,2729 };2730 await writeSlot(SLOT_A, nextSlotA);27312732 2733 if (rootKey) zeroKey(rootKey);2734 rootKey = newRootKey;27352736 2737 const { clearPairedDevices } = await import("./paired-devices");2738 await clearPairedDevices();27392740 2741 await setRecoveryConfirmPending(); 27422743 await stampLocalVaultMutation();2744 return { ok: true };2745}27462747274827492750275127522753export async function changePassword(2754 currentPassword: string,2755 newPassword: string2756): Promise<boolean> {2757 if (!derivedKey || vaultMode !== "real") return false;27582759 2760 const valid = await verifyMainPassword(currentPassword);2761 if (!valid) return false;27622763 const slotA = await loadSlot(SLOT_A);2764 if (!slotA) return false;2765 if (!rootKey) return false;27662767 2768 const newSalt = generateSalt();2769 const newKey = await deriveKey(newPassword, newSalt);27702771 2772 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2773 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");27742775 2776 2777 2778 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);27792780 const finalSlotA: StoredVault = {2781 ...slotA,2782 salt: hex(newSalt),2783 nonce,2784 ciphertext,2785 tag,2786 ...pwRoot,2787 };27882789 2790 2791 2792 await takeVaultSnapshot("local_save");27932794 2795 2796 2797 2798 await writeSlot(SLOT_A, finalSlotA);27992800 2801 2802 2803 zeroKey(derivedKey);2804 derivedKey = newKey;28052806 2807 2808 2809 2810 void saveSessionKey(newKey, "real");28112812 2813 2814 2815 if (!duressConfigured) {2816 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2817 }2818 await stampLocalVaultMutation();28192820 return true;2821}