123456789101112import { deriveKey, 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 settings = { ...settings, ...patch };166 await persistSettings();167}168169async function persistSettings(): Promise<void> {170 try {171 await chrome.storage.local.set({ peach_settings: settings });172 } catch (err) {173 throw mapStorageError(err);174 }175}176177function mapStorageError(err: unknown): Error {178 const message = err instanceof Error ? err.message : String(err);179 if (/QUOTA_BYTES|quota/i.test(message)) {180 return new Error("STORAGE_QUOTA_EXCEEDED");181 }182 return err instanceof Error ? err : new Error(message);183}184185async function loadSettings(): Promise<void> {186 const result = await chrome.storage.local.get("peach_settings");187 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};188}189190191192193194195function fireSyncTrigger(reason: "vault_change"): void {196 void import("./sync-trigger")197 .then((mod) => mod.triggerSyncIfReachable({ reason }))198 .catch(() => {199 200 });201}202203204205206207export async function checkBreach(password: string): Promise<number> {208 209 210 const encoder = new TextEncoder();211 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));212 const hashHex = Array.from(new Uint8Array(hashBuffer))213 .map((b) => b.toString(16).padStart(2, "0"))214 .join("")215 .toUpperCase();216217 const prefix = hashHex.slice(0, 5);218 const suffix = hashHex.slice(5);219220 try {221 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);222 if (!res.ok) return 0;223 const text = await res.text();224 const match = text.split("\r\n").find((l) => l.startsWith(suffix));225 return match ? parseInt(match.split(":")[1], 10) : 0;226 } catch {227 return 0; 228 }229}230231export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {232 const fingerprint = await cardFingerprint(last4, expMonth, expYear);233 await loadSettings();234 settings = {235 ...settings,236 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],237 };238 await persistSettings();239}240241export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {242 await loadSettings();243 const fingerprint = await cardFingerprint(last4, expMonth, expYear);244 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);245}246247async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {248 const encoder = new TextEncoder();249 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));250 return Array.from(new Uint8Array(buf).slice(0, 8))251 .map((b) => b.toString(16).padStart(2, "0"))252 .join("");253}254255function hex(buf: Uint8Array): string {256 return Array.from(buf)257 .map((b) => b.toString(16).padStart(2, "0"))258 .join("");259}260261function unhex(s: string): Uint8Array {262 const out = new Uint8Array(s.length / 2);263 for (let i = 0; i < s.length; i += 2) {264 out[i / 2] = parseInt(s.slice(i, i + 2), 16);265 }266 return out;267}268269270271272273274275276277278279280async function generateAndWrapRootKey(): Promise<{281 rawRootKey: string; 282 recoverySalt: string;283 wrappedRootKey: string;284 wrappedNonce: string;285 wrappedTag: string;286}> {287 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));288289 290 const words = await rootKeyToWords(rootKeyBytes);291 const wordMaterial = await wordsToRootKeyMaterial(words);292 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));293 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);294295 296 297 const nonce = crypto.getRandomValues(new Uint8Array(12));298 const encoded = new TextEncoder().encode(hex(rootKeyBytes));299 const raw = await crypto.subtle.encrypt(300 {301 name: "AES-GCM",302 iv: nonce,303 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,304 tagLength: 128,305 },306 recoveryKey,307 encoded308 );309 310 const rawU8 = new Uint8Array(raw);311 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));312 const tag = hex(rawU8.slice(rawU8.length - 16));313314 return {315 rawRootKey: hex(rootKeyBytes),316 recoverySalt: hex(recoverySalt),317 wrappedRootKey: ciphertext,318 wrappedNonce: hex(nonce),319 wrappedTag: tag,320 };321}322323324325326327328export async function unwrapRootKeyForRecovery(329 wrappedRootKey: string,330 wrappedNonce: string,331 wrappedTag: string,332 recoverySaltHex: string,333 words: string[]334): Promise<void> {335 const wordMaterial = await wordsToRootKeyMaterial(words);336 const recoverySalt = unhex(recoverySaltHex);337 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);338339 340 341 342 343 344 const nonceRaw = unhex(wrappedNonce);345 const ciphertextRaw = unhex(wrappedRootKey);346 const tagRaw = unhex(wrappedTag);347 348 349 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;350 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);351 combined.set(ciphertextRaw, 0);352 combined.set(tagRaw, ciphertextRaw.length);353 const raw = await crypto.subtle.decrypt(354 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },355 recoveryKey,356 combined357 );358 const decryptedHex = new TextDecoder().decode(raw);359 rootKey = unhex(decryptedHex);360}361362363364365366367368369370371export async function rewrapRootKeyForPassword(372 newDerivedKey: Uint8Array,373 _newSalt: Uint8Array374): Promise<void> {375 if (!rootKey) throw new Error("root key not set");376 const slotA = await loadSlot(SLOT_A);377 if (!slotA) throw new Error("missing slot A");378 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);379 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });380}381382383384385386387async function reencryptVaultForNewKey(388 newKey: Uint8Array,389 slotA: StoredVault390): Promise<void> {391 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);392 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");393 await writeSlot(SLOT_A, {394 ...slotA,395 nonce,396 ciphertext,397 tag,398 });399}400401402403404405406export async function changePasswordWithNewKey(407 newSalt: Uint8Array,408 newKey: Uint8Array409): Promise<void> {410 const slotA = await loadSlot(SLOT_A);411 if (!slotA) throw new Error("missing slot A");412 413 414 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });415 if (derivedKey) zeroKey(derivedKey);416 derivedKey = newKey;417 418 419 420 421 if (rootKey) {422 const slotAfter = await loadSlot(SLOT_A);423 if (slotAfter) {424 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);425 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });426 }427 }428 if (!duressConfigured) {429 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));430 }431 await stampLocalVaultMutation();432}433434435436437438439export { deriveKey, generateSalt };440441export async function loadSlotA(): Promise<StoredVault | null> {442 return loadSlot(SLOT_A);443}444445446447448449450451452453454455456457async function deriveRootKeyFromDerivedKey(458 derivedKeyBytes: Uint8Array,459 slotA: StoredVault,460): Promise<Uint8Array | null> {461 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {462 return null;463 }464 try {465 const rootHex = await decrypt(466 derivedKeyBytes,467 slotA.pw_root_nonce,468 slotA.pw_root_ciphertext,469 slotA.pw_root_tag,470 "pw_root_v1",471 );472 return unhex(rootHex);473 } catch {474 return null;475 }476}477478479480async function wrapRootKeyForDerivedKey(481 derivedKeyBytes: Uint8Array,482 rootKeyBytes: Uint8Array,483): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {484 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");485 return {486 pw_root_ciphertext: ciphertext,487 pw_root_nonce: nonce,488 pw_root_tag: tag,489 };490}491492493494495496497498499export async function migrateToRecovery(): Promise<void> {500 if (!derivedKey || vaultMode !== "real") {501 throw new Error("vault must be unlocked in real mode");502 }503 const slotA = await loadSlot(SLOT_A);504 if (!slotA) throw new Error("missing slot A");505 const hasRecoveryEscrow =506 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;507 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;508 if (hasRecoveryEscrow && hasPwRoot) return; 509510 511 512 513 514 515 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =516 await generateAndWrapRootKey();517 rootKey = unhex(rawRootKey);518519 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);520 const nextSlotA: StoredVault = {521 ...slotA,522 recovery_salt: recoverySalt,523 recovery_wrapped_root_key: wrappedRootKey,524 recovery_nonce: wrappedNonce,525 recovery_tag: wrappedTag,526 ...pwRoot,527 };528 await writeSlot(SLOT_A, nextSlotA);529 await stampLocalVaultMutation();530}531532533534535536537export async function getRecoveryWords(): Promise<string[]> {538 if (!rootKey) {539 throw new Error("recovery not set up — vault may need migration");540 }541 return rootKeyToWords(rootKey);542}543544async function loadSlot(key: string): Promise<StoredVault | null> {545 const result = await chrome.storage.local.get(key);546 return (result as Record<string, StoredVault | undefined>)[key] ?? null;547}548549async function writeSlot(key: string, vault: StoredVault): Promise<void> {550 try {551 await chrome.storage.local.set({ [key]: vault });552 } catch (err) {553 throw mapStorageError(err);554 }555}556557558559560561562563564let lastEntryTimestamp = 0;565function monotonicEntryTimestamp(currentModifiedAt?: number): number {566 const base = Math.max(567 Date.now(),568 (currentModifiedAt ?? 0) + 1,569 lastEntryTimestamp + 1,570 );571 lastEntryTimestamp = base;572 return base;573}574575async function stampLocalVaultMutation(): Promise<void> {576 const raw = await chrome.storage.local.get("peach_last_modified_at");577 const previous = Number(578 (raw as Record<string, unknown>).peach_last_modified_at,579 );580 const next = Math.max(581 Date.now(),582 Number.isFinite(previous) ? previous + 1 : 0,583 );584 await chrome.storage.local.set({585 peach_last_modified_at: String(next),586 });587}588589590591592593594async function ensureMigrated(): Promise<void> {595 if (migrationRan) return;596 migrationRan = true;597598 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];599 const raw = await chrome.storage.local.get(keys);600 const slotAExists = raw[SLOT_A] !== undefined;601 const slotBExists = raw[SLOT_B] !== undefined;602 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;603 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;604605 606 if (!slotAExists && !legacyMain) return;607608 609 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;610611 let didMigrate = false;612 if (legacyMain && !slotAExists) {613 await writeSlot(SLOT_A, legacyMain);614 didMigrate = true;615 }616617 618 if (!slotBExists) {619 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;620 if (legacyDecoy) {621 await writeSlot(SLOT_B, legacyDecoy);622 didMigrate = true;623 } else if (target) {624 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));625 didMigrate = true;626 }627 }628629 const toRemove: string[] = [];630 if (legacyMain) toRemove.push(LEGACY_MAIN);631 if (legacyDecoy) toRemove.push(LEGACY_DECOY);632 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);633 if (toRemove.length > 0) {634 await chrome.storage.local.remove(toRemove);635 didMigrate = true;636 }637 if (didMigrate) {638 await stampLocalVaultMutation();639 }640}641642643644645646647648649650651652function padPlaintext(json: string, minBytes = 0): string {653 const encoded = new TextEncoder().encode(json);654 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);655 656 const target = Math.max(bucket, minBytes);657 const padLen = target - encoded.length;658 return json + " ".repeat(padLen);659}660661662663664665666667668669670671const CSPRNG_CHUNK = 65_536;672673function fillRandom(buf: Uint8Array): Uint8Array {674 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {675 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);676 const chunk = new Uint8Array(end - offset);677 crypto.getRandomValues(chunk);678 buf.set(chunk, offset);679 }680 return buf;681}682683function generateNoiseBlob(ciphertextBytes: number): StoredVault {684 const salt = crypto.getRandomValues(new Uint8Array(16));685 const nonce = crypto.getRandomValues(new Uint8Array(12));686 const cipher = fillRandom(new Uint8Array(ciphertextBytes));687 const tag = crypto.getRandomValues(new Uint8Array(16));688 return {689 version: 1,690 salt: hex(salt),691 nonce: hex(nonce),692 ciphertext: hex(cipher),693 tag: hex(tag),694 };695}696697698699700701type VaultPayload = {702 entries: VaultEntry[];703 duressConfigured?: boolean;704};705706type DecryptedPayload = {707 entries: VaultEntry[];708 duressConfigured: boolean;709 710711712713714715716 migratedFromLegacy: boolean;717};718719function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {720 const envelope: VaultPayload = { entries, duressConfigured: duress };721 return padPlaintext(JSON.stringify(envelope), minBytes);722}723724function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {725 if ("created" in raw || "updated" in raw) return true;726 const flat = raw as Partial<VaultEntry>;727 if (flat.type === "passkey") return true;728 729 if (flat.rpId !== undefined && flat.passkey === undefined) return true;730 return false;731}732733function decodePayload(plain: string): DecryptedPayload {734 const parsed = JSON.parse(plain) as735 | Array<VaultEntry | LegacyVaultEntry>736 | VaultPayload;737738 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];739 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);740741 const normalized = rawEntries.map(normalizeEntry);742 const { merged } = mergePasskeyOnlyIntoLogins(normalized);743744 if (Array.isArray(parsed)) {745 return {746 entries: normalized,747 duressConfigured: false,748 migratedFromLegacy: true, 749 };750 }751752 return {753 entries: normalized,754 duressConfigured: parsed.duressConfigured === true,755 migratedFromLegacy: flatMigrationNeeded || merged,756 };757}758759760761762763764765766767768769770771772773774775776777778779780781782function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {783 let merged = false;784 const now = Date.now();785786 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);787 const byRpId = new Map<string, VaultEntry[]>();788 for (const e of candidates) {789 const rpId = e.passkey!.rpId;790 const list = byRpId.get(rpId);791 if (list) list.push(e);792 else byRpId.set(rpId, [e]);793 }794795 for (const [rpId, group] of byRpId) {796 if (group.length === 0) continue;797 group.sort((a, b) => {798 const aT =799 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;800 const bT =801 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;802 return bT - aT;803 });804 const winner = group[0]!;805806 807 808 809 const loginTarget = entries.find((e) => {810 if (e.deletedAt || e.passkey || e.type !== "login") return false;811 if (e.url && extractDomain(e.url) === rpId) return true;812 for (const extra of e.additionalUrls ?? []) {813 if (extractDomain(extra) === rpId) return true;814 }815 return false;816 });817818 if (loginTarget) {819 loginTarget.passkey = winner.passkey;820 loginTarget.modifiedAt = now;821 for (const dup of group) {822 if (!dup.deletedAt) {823 dup.deletedAt = now;824 dup.modifiedAt = now;825 }826 }827 merged = true;828 } else if (group.length > 1) {829 for (let i = 1; i < group.length; i += 1) {830 const dup = group[i]!;831 if (!dup.deletedAt) {832 dup.deletedAt = now;833 dup.modifiedAt = now;834 }835 }836 merged = true;837 }838 }839840 return { merged };841}842843844845846847848849function hasValidVaultSchema(v: unknown): v is StoredVault {850 if (typeof v !== "object" || v === null) return false;851 const r = v as Record<string, unknown>;852 return (853 r.version === 1 &&854 typeof r.salt === "string" &&855 typeof r.nonce === "string" &&856 typeof r.ciphertext === "string" &&857 typeof r.tag === "string"858 );859}860861export type TryDecryptResult =862 | { status: "ok"; payload: DecryptedPayload }863 | { status: "wrong_key" }864 | { status: "corrupted"; reason: string };865866867868869870871export async function tryDecryptSlot(872 vault: StoredVault,873 key: Uint8Array,874 aadTag?: string,875): Promise<DecryptedPayload | null> {876 const res = await tryDecryptSlotResult(vault, key, aadTag);877 return res.status === "ok" ? res.payload : null;878}879880export async function tryDecryptSlotResult(881 vault: StoredVault,882 key: Uint8Array,883 aadTag?: string,884): Promise<TryDecryptResult> {885 if (!hasValidVaultSchema(vault)) {886 return { status: "corrupted", reason: "invalid_slot_schema" };887 }888 let plain: string;889 try {890 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);891 } catch {892 return { status: "wrong_key" };893 }894 try {895 const parsed = JSON.parse(plain) as unknown;896 if (typeof parsed !== "object" || parsed === null) {897 return { status: "corrupted", reason: "invalid_payload_shape" };898 }899 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {900 return { status: "corrupted", reason: "invalid_payload_shape" };901 }902 return { status: "ok", payload: decodePayload(plain) };903 } catch {904 return { status: "corrupted", reason: "invalid_payload_json" };905 }906}907908909910911912function buildDomainIndexSync(all: VaultEntry[]): void {913 domainIndex = new Map();914915 for (const entry of all) {916 if (entry.deletedAt) continue;917 if (entry.type === "note" || entry.type === "card") continue;918919 920 921 922 923 const domains = new Set<string>();924 const primary = extractDomain(entry.url ?? "");925 if (primary) domains.add(primary);926 for (const extra of entry.additionalUrls ?? []) {927 const d = extractDomain(extra);928 if (d) domains.add(d);929 }930 if (domains.size === 0) continue;931932 for (const domain of domains) {933 const bucket = domainIndex.get(domain) ?? [];934 bucket.push(entry);935 domainIndex.set(domain, bucket);936 }937 }938}939940export async function createVault(password: string): Promise<void> {941 await ensureMigrated();942943 const salt = generateSalt();944 derivedKey = await deriveKey(password, salt);945 entries = [];946 vaultMode = "real";947 duressConfigured = false;948 buildDomainIndexSync(entries);949950 951 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =952 await generateAndWrapRootKey();953 rootKey = unhex(rawRootKey);954955 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);956 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");957958 959 960 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);961962 const slotA: StoredVault = {963 version: 1,964 salt: hex(salt),965 nonce,966 ciphertext,967 tag,968 recovery_salt: recoverySalt,969 recovery_wrapped_root_key: wrappedRootKey,970 recovery_nonce: wrappedNonce,971 recovery_tag: wrappedTag,972 ...pwRoot,973 };974 await writeSlot(SLOT_A, slotA);975976 977 978 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));979 await stampLocalVaultMutation();980981 touchAutoLock();982}983984export async function hasVault(): Promise<boolean> {985 await ensureMigrated();986 return (await loadSlot(SLOT_A)) !== null;987}988989990991992993994export async function isDuressEnabled(): Promise<boolean> {995 return vaultMode === "real" && duressConfigured;996}997998export async function setupDuress(duressPassword: string): Promise<void> {999 if (!derivedKey || vaultMode !== "real") {1000 throw new Error("vault must be unlocked in real mode");1001 }1002 await ensureMigrated();10031004 1005 1006 1007 const slotA = await loadSlot(SLOT_A);1008 if (!slotA) throw new Error("missing slot A");10091010 const decoySalt = generateSalt();1011 const decoyKey = await deriveKey(duressPassword, decoySalt);1012 try {1013 1014 1015 1016 1017 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1018 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1019 await writeSlot(SLOT_B, {1020 version: 1,1021 salt: hex(decoySalt),1022 nonce,1023 ciphertext,1024 tag,1025 });1026 } finally {1027 zeroKey(decoyKey);1028 }10291030 1031 1032 duressConfigured = true;1033 await persistSlotA();1034}10351036export async function disableDuress(): Promise<void> {1037 if (!derivedKey || vaultMode !== "real") {1038 throw new Error("vault must be unlocked in real mode");1039 }1040 await ensureMigrated();10411042 1043 1044 1045 const slotA = await loadSlot(SLOT_A);1046 if (slotA) {1047 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1048 }10491050 duressConfigured = false;1051 await persistSlotA();1052}10531054105510561057105810591060export async function verifyMainPassword(password: string): Promise<boolean> {1061 await ensureMigrated();1062 const slotA = await loadSlot(SLOT_A);1063 if (!slotA) return false;1064 const key = await deriveKey(password, unhex(slotA.salt));1065 try {1066 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1067 } finally {1068 zeroKey(key);1069 }1070}10711072export async function unlock(password: string): Promise<boolean> {1073 await ensureMigrated();10741075 1076 1077 1078 1079 1080 const [slotA, slotB] = await Promise.all([1081 loadSlot(SLOT_A),1082 loadSlot(SLOT_B),1083 ]);10841085 1086 const [keyA, keyB] = await Promise.all([1087 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),1088 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),1089 ]);10901091 1092 1093 1094 const [resultA, resultB] = await Promise.all([1095 keyA && slotA1096 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1097 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1098 keyB && slotB1099 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1100 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1101 ]);1102 const payloadA = resultA.status === "ok" ? resultA.payload : null;1103 const payloadB = resultB.status === "ok" ? resultB.payload : null;11041105 if (payloadA && keyA) {1106 derivedKey = keyA;1107 void saveSessionKey(keyA, "real");1108 vaultMode = "real";1109 entries = payloadA.entries;1110 duressConfigured = payloadA.duressConfigured;1111 buildDomainIndexSync(entries);1112 await loadSettings();1113 touchAutoLock();1114 if (keyB) zeroKey(keyB);11151116 1117 if (slotA) {1118 const needsRecoverySetup =1119 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1120 if (!needsRecoverySetup) {1121 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1122 }1123 1124 1125 1126 1127 if (needsRecoverySetup || !rootKey) {1128 try {1129 await migrateToRecovery();1130 if (needsRecoverySetup) {1131 1132 1133 1134 await chrome.storage.local.set({ peach_recovery_pending: '1' });1135 }1136 } catch (migrationErr) {1137 console.warn('[Peach] recovery migration failed:', migrationErr);1138 }1139 }1140 }11411142 1143 1144 1145 if (payloadA.migratedFromLegacy) {1146 await persistSlotA();1147 }1148 return true;1149 }11501151 if (payloadB && keyB) {1152 derivedKey = keyB;1153 void saveSessionKey(keyB, "decoy");1154 vaultMode = "decoy";1155 entries = payloadB.entries;1156 duressConfigured = false;1157 buildDomainIndexSync(entries);1158 await loadSettings();1159 touchAutoLock();1160 if (keyA) zeroKey(keyA);1161 return true;1162 }11631164 if (keyA) zeroKey(keyA);1165 if (keyB) zeroKey(keyB);1166 1167 1168 if (resultA.status === "corrupted") {1169 lastUnlockFailureReason = resultA.reason;1170 } else if (resultB.status === "corrupted") {1171 lastUnlockFailureReason = resultB.reason;1172 }1173 return false;1174}11751176117711781179118011811182const SESSION_KEY_STORAGE = "peach_session_dk";11831184async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1185 if (!chrome.storage.session) return;1186 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1187}11881189async function clearSessionKey(): Promise<void> {1190 if (!chrome.storage.session) return;1191 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1192}11931194119511961197119811991200export async function restoreFromSession(): Promise<boolean> {1201 if (derivedKey !== null) return true;1202 if (!chrome.storage.session) return false;1203 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1204 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1205 if (!entry?.keyHex) return false;12061207 const key = unhex(entry.keyHex);1208 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1209 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1210 const slot = await loadSlot(slotKey);1211 if (!slot) { await clearSessionKey(); return false; }12121213 const result = await tryDecryptSlotResult(slot, key, aadTag);1214 if (result.status !== "ok") { await clearSessionKey(); return false; }12151216 derivedKey = key;1217 vaultMode = entry.mode;1218 entries = result.payload.entries;1219 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1220 buildDomainIndexSync(entries);1221 await loadSettings();1222 touchAutoLock();12231224 if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1225 try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { }1226 }12271228 return true;1229}12301231export function lock(): void {1232 void clearSessionKey();1233 if (derivedKey) {1234 zeroKey(derivedKey);1235 derivedKey = null;1236 }1237 if (rootKey) {1238 rootKey.fill(0);1239 rootKey = null;1240 }12411242 entries = [];1243 domainIndex = new Map();1244 vaultMode = "real";1245 duressConfigured = false;1246 clearAutoLock();1247}12481249export function isUnlocked(): boolean {1250 return derivedKey !== null;1251}1252125312541255export async function unlockWithKey(1256 masterKeyHex: string,1257): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1258 try {1259 const masterKeyBytes = unhex(masterKeyHex);1260 const stored = await loadSlot(SLOT_A);1261 if (!stored) return { ok: false, error: "invalid_key" };1262 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1263 if (!payload) return { ok: false, error: "invalid_key" };12641265 derivedKey = masterKeyBytes;1266 vaultMode = "real";1267 entries = payload.entries;1268 duressConfigured = payload.duressConfigured;1269 buildDomainIndexSync(entries);1270 await loadSettings();1271 touchAutoLock();1272 return { ok: true, entries };1273 } catch {1274 return { ok: false, error: "invalid_key" };1275 }1276}12771278127912801281128212831284128512861287128812891290129112921293129412951296export async function unlockFromCodex(params: {1297 slotA: string; 1298 slotB: string | null; 1299 timestamp: number;1300 masterPassphrase: string;1301}): Promise<1302 | { ok: true; entryCount: number }1303 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1304> {1305 let parsedSlotA: StoredVault;1306 let parsedSlotB: StoredVault | null = null;1307 try {1308 const rawA = JSON.parse(params.slotA) as unknown;1309 if (!hasValidVaultSchema(rawA)) {1310 return { ok: false, reason: "invalid_slot_format" };1311 }1312 parsedSlotA = rawA;1313 if (params.slotB !== null) {1314 const rawB = JSON.parse(params.slotB) as unknown;1315 if (!hasValidVaultSchema(rawB)) {1316 return { ok: false, reason: "invalid_slot_format" };1317 }1318 parsedSlotB = rawB;1319 }1320 } catch {1321 return { ok: false, reason: "invalid_slot_format" };1322 }13231324 1325 1326 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1327 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1328 if (!trialPayload) {1329 zeroKey(trialKey);1330 return { ok: false, reason: "wrong_passphrase" };1331 }1332 1333 1334 zeroKey(trialKey);13351336 1337 1338 await takeVaultSnapshot("restore");13391340 await writeSlot(SLOT_A, parsedSlotA);1341 if (parsedSlotB) {1342 await writeSlot(SLOT_B, parsedSlotB);1343 } else {1344 1345 1346 1347 await writeSlot(1348 SLOT_B,1349 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1350 );1351 }1352 await chrome.storage.local.set({1353 peach_last_modified_at: String(params.timestamp),1354 });13551356 1357 1358 const ok = await unlock(params.masterPassphrase);1359 if (!ok) {1360 1361 1362 1363 return { ok: false, reason: "wrong_passphrase" };1364 }1365 return { ok: true, entryCount: getEntries().length };1366}1367136813691370export function getDerivedKeyHex(): string {1371 if (!derivedKey) throw new Error("vault_not_unlocked");1372 return hex(derivedKey);1373}13741375137613771378137913801381138213831384138513861387138813891390export function getRootKeyHex(): string | null {1391 return rootKey ? hex(rootKey) : null;1392}13931394export function getEntries(): VaultEntry[] {1395 if (!derivedKey) throw new Error("vault_locked");1396 return entries.filter((entry) => !entry.deletedAt);1397}13981399const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;14001401export function getIdentities(): SavedIdentity[] {1402 if (!derivedKey) throw new Error("vault_locked");1403 const active = entries.filter((e) => !e.deletedAt && e.type === "login");14041405 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1406 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();14071408 for (const entry of active) {1409 const domain = extractDomain(entry.url ?? "") ?? "";1410 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;14111412 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1413 const existing = map.get(v);1414 if (!existing || modifiedAt > existing.lastUsed) {1415 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1416 } else {1417 map.set(v, { ...existing, count: existing.count + 1 });1418 }1419 }14201421 const uVal = entry.username?.trim();1422 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);14231424 1425 1426 const eVal = entry.email?.trim();1427 if (eVal) tally(emailMap, eVal);1428 }14291430 const toIdentity = (type: "email" | "username") =>1431 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1432 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1433 });14341435 return [1436 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1437 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1438 ];1439}14401441export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1442 if (!derivedKey) throw new Error("vault_locked");14431444 const now = Date.now();1445 const added: VaultEntry[] = [];1446 for (const draft of drafts) {1447 try {1448 const sanitized = sanitizeEntryDraft(draft);1449 const nextEntry: VaultEntry = {1450 ...sanitized,1451 id: crypto.randomUUID(),1452 createdAt: now,1453 modifiedAt: monotonicEntryTimestamp(),1454 };1455 entries.push(nextEntry);1456 added.push(nextEntry);1457 } catch {1458 1459 }1460 }14611462 if (added.length > 0) {1463 await persistEntries();1464 }14651466 return added;1467}146814691470147114721473147414751476export async function restoreEntries(1477 incoming: VaultEntry[],1478 mode: "replace" | "merge"1479): Promise<{ added: number; updated: number; skipped: number }> {1480 if (!derivedKey) throw new Error("vault_locked");14811482 1483 1484 1485 if (mode === "replace" && incoming.length === 0) {1486 throw new Error("refuse to replace with empty entry set");1487 }14881489 let added = 0;1490 let updated = 0;1491 let skipped = 0;14921493 if (mode === "replace") {1494 entries = [];1495 for (const entry of incoming) {1496 const normalized = sanitizeRestoredEntry(entry);1497 if (!normalized) {1498 skipped += 1;1499 continue;1500 }1501 entries.push(normalized);1502 added += 1;1503 }1504 } else {1505 const byId = new Map<string, VaultEntry>();1506 for (const e of entries) byId.set(e.id, e);15071508 for (const entry of incoming) {1509 const normalized = sanitizeRestoredEntry(entry);1510 if (!normalized) {1511 skipped += 1;1512 continue;1513 }1514 const existing = byId.get(normalized.id);1515 if (!existing) {1516 entries.push(normalized);1517 added += 1;1518 } else if (normalized.modifiedAt > existing.modifiedAt) {1519 const idx = entries.findIndex((e) => e.id === existing.id);1520 if (idx !== -1) {1521 entries[idx] = normalized;1522 updated += 1;1523 }1524 } else {1525 skipped += 1;1526 }1527 }1528 }15291530 await persistEntries("import");1531 return { added, updated, skipped };1532}153315341535153615371538153915401541function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1542 if (!entry || typeof entry !== "object") return null;1543 const title = typeof entry.title === "string" ? entry.title.trim() : "";1544 if (!title) return null;1545 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1546 const now = Date.now();1547 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1548 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;15491550 1551 1552 1553 1554 const normalized = normalizeEntry({1555 ...entry,1556 id,1557 title,1558 type: entry.type ?? "login",1559 createdAt,1560 modifiedAt,1561 });15621563 if (normalized.passkey) {1564 normalized.passkey = sanitizePasskey(normalized.passkey);1565 }1566 if (normalized.canary) {1567 normalized.canary = sanitizeCanary(normalized.canary);1568 }15691570 1571 1572 if (1573 normalized.type === "login" &&1574 !normalized.passkey &&1575 typeof normalized.password !== "string"1576 ) {1577 return null;1578 }1579 if (entry.passkey && !normalized.passkey) {1580 1581 1582 return null;1583 }15841585 return normalized;1586}1587158815891590159115921593function sanitizePasskey(pk: unknown): PasskeyData | undefined {1594 if (!pk || typeof pk !== "object") return undefined;1595 const r = pk as Record<string, unknown>;1596 if (1597 typeof r.rpId !== "string" || !r.rpId ||1598 typeof r.credentialId !== "string" || !r.credentialId ||1599 typeof r.publicKeyCose !== "string" ||1600 typeof r.userHandle !== "string" ||1601 typeof r.userName !== "string"1602 ) {1603 return undefined;1604 }1605 return {1606 rpId: r.rpId,1607 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1608 credentialId: r.credentialId,1609 publicKeyCose: r.publicKeyCose,1610 privateKeyJwk:1611 r.privateKeyJwk && typeof r.privateKeyJwk === "object"1612 ? (r.privateKeyJwk as JsonWebKey)1613 : undefined,1614 userHandle: r.userHandle,1615 userName: r.userName,1616 userDisplayName:1617 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1618 algorithm: r.algorithm === -8 ? -8 : -7,1619 signCount: typeof r.signCount === "number" ? r.signCount : 0,1620 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1621 backupEligible: r.backupEligible === true,1622 backupState: r.backupState === true,1623 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1624 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1625 transports: Array.isArray(r.transports)1626 ? r.transports.filter((t): t is string => typeof t === "string")1627 : undefined,1628 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1629 };1630}16311632163316341635163616371638163916401641function sanitizeCanary(c: unknown): Canary | undefined {1642 if (!c || typeof c !== "object") return undefined;1643 const r = c as Record<string, unknown>;1644 if (typeof r.webhookUrl !== "string") return undefined;1645 let url: URL;1646 try {1647 url = new URL(r.webhookUrl);1648 } catch {1649 return undefined;1650 }1651 if (url.protocol !== "https:") return undefined;1652 return {1653 webhookUrl: url.toString(),1654 label: typeof r.label === "string" ? r.label : undefined,1655 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,1656 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,1657 };1658}165916601661166216631664export async function clearVaultEntries(1665 confirmation: "REPLACE_ALL"1666): Promise<void> {1667 if (confirmation !== "REPLACE_ALL") {1668 throw new Error("clearVaultEntries requires explicit confirmation");1669 }1670 if (!derivedKey) throw new Error("vault_locked");1671 for (const entry of entries) {1672 if (!entry.deletedAt) {1673 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1674 entry.deletedAt = stamp;1675 entry.modifiedAt = stamp;1676 }1677 }1678 await persistEntries();1679}16801681export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1682 if (!derivedKey) throw new Error("vault_locked");16831684 const sanitized = sanitizeEntryDraft(entry);1685 const now = Date.now();1686 const stamp = monotonicEntryTimestamp();1687 const nextEntry: VaultEntry = {1688 ...sanitized,1689 id: crypto.randomUUID(),1690 createdAt: now,1691 modifiedAt: stamp,1692 1693 1694 passwordChangedAt: sanitized.password ? stamp : undefined,1695 };16961697 1698 1699 1700 const rollback = entries;1701 entries = [...entries, nextEntry];1702 try {1703 await persistEntries();1704 } catch (err) {1705 entries = rollback;1706 throw err;1707 }17081709 1710 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1711 checkBreach(nextEntry.password).then(async (count) => {1712 if (count > 0) {1713 const idx = entries.findIndex((e) => e.id === nextEntry.id);1714 if (idx !== -1) {1715 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };1716 await persistEntries();1717 }1718 }1719 });1720 }17211722 fireSyncTrigger("vault_change");17231724 return nextEntry;1725}17261727172817291730173117321733export async function consolidatePasskeyEntries(): Promise<void> {1734 if (!derivedKey) return;1735 const { merged } = mergePasskeyOnlyIntoLogins(entries);1736 if (merged) {1737 await persistEntries();1738 }1739}17401741export async function updateEntry(1742 id: string,1743 patch: Partial<VaultEntry>1744): Promise<void> {1745 if (!derivedKey) throw new Error("vault_locked");17461747 const idx = entries.findIndex((entry) => entry.id === id);1748 if (idx === -1 || entries[idx].deletedAt) {1749 throw new Error("entry_not_found");1750 }17511752 const current = entries[idx];1753 const sanitizedPatch = sanitizeEntryPatch(patch);1754 1755 1756 1757 const passwordChanged =1758 "password" in patch &&1759 typeof sanitizedPatch.password === "string" &&1760 sanitizedPatch.password !== current.password;1761 const stamp = monotonicEntryTimestamp(current.modifiedAt);1762 1763 1764 1765 1766 1767 1768 1769 const priorPassword = current.password;1770 const shouldCapture =1771 passwordChanged &&1772 typeof priorPassword === "string" &&1773 priorPassword.length > 0 &&1774 current.passwordHistory?.[0]?.password !== priorPassword;1775 const nextHistory = shouldCapture1776 ? [1777 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },1778 ...(current.passwordHistory ?? []),1779 ].slice(0, PASSWORD_HISTORY_MAX)1780 : current.passwordHistory;1781 entries[idx] = {1782 ...current,1783 ...sanitizedPatch,1784 modifiedAt: stamp,1785 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),1786 };1787178817891790 1791 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1792 entries[idx] = current;1793 throw new Error("card number is required for card entries");1794 }17951796 1797 1798 1799 1800 try {1801 await persistEntries();1802 } catch (err) {1803 entries[idx] = current;1804 throw err;1805 }18061807 1808 1809 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1810 entries[idx] = { ...entries[idx], breach: undefined };1811 await persistEntries();1812 const password = entries[idx].password;1813 if (password) {1814 checkBreach(password).then(async (count) => {1815 const i = entries.findIndex((e) => e.id === id);1816 if (i !== -1 && count > 0) {1817 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };1818 await persistEntries();1819 }1820 });1821 }1822 }18231824 fireSyncTrigger("vault_change");1825}18261827export async function deleteEntry(id: string): Promise<void> {1828 if (!derivedKey) throw new Error("vault_locked");18291830 const entry = entries.find((item) => item.id === id);1831 if (!entry || entry.deletedAt) {1832 throw new Error("entry_not_found");1833 }18341835 1836 1837 1838 const previousModifiedAt = entry.modifiedAt;1839 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1840 entry.deletedAt = stamp;1841 entry.modifiedAt = stamp;1842 1843 1844 try {1845 await persistEntries();1846 } catch (err) {1847 delete entry.deletedAt;1848 entry.modifiedAt = previousModifiedAt;1849 throw err;1850 }1851 fireSyncTrigger("vault_change");1852}18531854export function getDeletedEntries(): VaultEntry[] {1855 if (!derivedKey) return [];1856 return entries.filter((e) => !!e.deletedAt);1857}1858185918601861186218631864export async function getVaultVersion(): Promise<number> {1865 try {1866 const raw = await chrome.storage.local.get("peach_last_modified_at");1867 const v = (raw as Record<string, unknown>).peach_last_modified_at;1868 if (typeof v === "number") return v;1869 if (typeof v === "string" && v !== "") {1870 const n = Number.parseInt(v, 10);1871 return Number.isFinite(n) ? n : 0;1872 }1873 return 0;1874 } catch {1875 return 0;1876 }1877}187818791880188118821883188418851886188718881889export async function addEntryDirect(entry: VaultEntry): Promise<void> {1890 if (!derivedKey) throw new Error("vault_locked");1891 entries = [...entries, entry];1892 await persistEntries("sync_received");1893}18941895export async function updateEntryDirect(patch: VaultEntry): Promise<void> {1896 if (!derivedKey) throw new Error("vault_locked");1897 const idx = entries.findIndex((e) => e.id === patch.id);1898 if (idx === -1) throw new Error("entry_not_found");1899 entries[idx] = { ...entries[idx], ...patch };1900 await persistEntries("sync_received");1901}19021903export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {1904 if (!derivedKey) throw new Error("vault_locked");1905 const idx = entries.findIndex((e) => e.id === id);1906 if (idx === -1) throw new Error("entry_not_found");1907 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };1908 await persistEntries("sync_received");1909}1910191119121913191419151916191719181919192019211922export async function applyMergedRemoteEntries(1923 remoteEntries: VaultEntry[],1924 remoteTombstones: Record<string, number>,1925 context: { deviceName?: string } = {},1926): Promise<{ received: number; applied: number; conflicts: number }> {1927 1928 1929 1930 1931 1932 1933 if (!derivedKey) throw new Error("vault_locked");19341935 const received = remoteEntries.length + Object.keys(remoteTombstones).length;1936 let applied = 0;1937 let conflicts = 0;19381939 const byId = new Map<string, VaultEntry>();1940 for (const e of entries) byId.set(e.id, e);19411942 const alerts: SyncAlert[] = [];1943 const deviceName = context.deviceName ?? "paired device";1944 const mergeNow = Date.now();19451946 for (const remote of remoteEntries) {1947 const sanitized = sanitizeRestoredEntry(remote);1948 if (!sanitized) continue;1949 1950 1951 1952 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);1953 if (typeof sanitized.deletedAt === "number") {1954 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);1955 }1956 const tombstoneTs = remoteTombstones[sanitized.id];1957 if (tombstoneTs !== undefined && clampSyncTimestamp(tombstoneTs, mergeNow) >= sanitized.modifiedAt) {1958 continue;1959 }1960 const local = byId.get(sanitized.id);1961 if (!local) {1962 entries.push(sanitized);1963 byId.set(sanitized.id, sanitized);1964 applied += 1;1965 } else if (sanitized.modifiedAt > local.modifiedAt) {1966 const idx = entries.findIndex((e) => e.id === local.id);1967 if (idx !== -1) {1968 1969 1970 if (1971 !local.deletedAt &&1972 typeof sanitized.password === "string" &&1973 typeof local.password === "string" &&1974 sanitized.password !== local.password1975 ) {1976 alerts.push({1977 type: "REMOTE_PASSWORD_CHANGE",1978 entryId: sanitized.id,1979 entryTitle: sanitized.title,1980 deviceName,1981 timestamp: mergeNow,1982 });1983 }1984 entries[idx] = sanitized;1985 byId.set(sanitized.id, sanitized);1986 applied += 1;1987 if (!local.deletedAt) conflicts += 1;1988 }1989 }1990 }19911992 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {1993 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt)) continue;1994 1995 const deletedAt = clampSyncTimestamp(rawDeletedAt, mergeNow);1996 const local = byId.get(id);1997 if (!local) continue;1998 if (local.deletedAt && local.deletedAt >= deletedAt) continue;1999 if (deletedAt < local.modifiedAt) continue;2000 const idx = entries.findIndex((e) => e.id === id);2001 if (idx !== -1) {2002 2003 if (!local.deletedAt) {2004 alerts.push({2005 type: "REMOTE_DELETE",2006 entryId: id,2007 entryTitle: local.title,2008 deviceName,2009 timestamp: mergeNow,2010 });2011 }2012 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2013 applied += 1;2014 }2015 }20162017 if (applied > 0) {2018 await persistEntries("sync_received");2019 buildDomainIndexSync(entries);2020 }2021 2022 2023 await recordSyncAlerts(alerts);2024 return { received, applied, conflicts };2025}20262027export async function restoreEntry(id: string): Promise<void> {2028 if (!derivedKey) throw new Error("vault_locked");2029 const entry = entries.find((e) => e.id === id && e.deletedAt);2030 if (!entry) throw new Error("deleted entry not found");2031 delete entry.deletedAt;2032 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);2033 await persistEntries();2034 buildDomainIndexSync(entries);2035}20362037export async function purgeEntry(id: string): Promise<void> {2038 if (!derivedKey) throw new Error("vault_locked");2039 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2040 if (idx === -1) throw new Error("deleted entry not found");2041 entries.splice(idx, 1);2042 await persistEntries();2043}204420452046export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2047 if (!derivedKey || retentionMs === 0) return 0;2048 const cutoff = Date.now() - retentionMs;2049 const before = entries.length;2050 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2051 const purged = before - entries.length;2052 if (purged > 0) await persistEntries();2053 return purged;2054}20552056export function queryDomain(domain: string): VaultEntry[] {2057 return domainIndex.get(domain) ?? [];2058}20592060export function getEntryById(id: string): VaultEntry | undefined {2061 return entries.find((entry) => entry.id === id && !entry.deletedAt);2062}20632064async function persistSlotA(2065 reason: VaultSnapshot["reason"] = "local_save",2066): Promise<StoredVault> {2067 if (!derivedKey) throw new Error("vault_locked");20682069 const slotA = await loadSlot(SLOT_A);2070 if (!slotA) throw new Error("missing slot A");20712072 2073 2074 await takeVaultSnapshot(reason);20752076 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2077 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");20782079 2080 const nextSlotA: StoredVault = {2081 ...slotA,2082 nonce,2083 ciphertext,2084 tag,2085 2086 };2087 await writeSlot(SLOT_A, nextSlotA);20882089 2090 2091 2092 2093 2094 2095 try {2096 2097 2098 2099 if (!duressConfigured) {2100 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2101 }2102 2103 2104 2105 2106 2107 if (reason !== "sync_received") {2108 await stampLocalVaultMutation();2109 }2110 } catch (err) {2111 2112 2113 await writeSlot(SLOT_A, slotA).catch(() => {});2114 throw err;2115 }21162117 return nextSlotA;2118}21192120async function persistSlotB(): Promise<void> {2121 if (!derivedKey) throw new Error("vault_locked");21222123 const slotB = await loadSlot(SLOT_B);2124 if (!slotB) throw new Error("missing slot B");21252126 2127 2128 2129 2130 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2131 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");21322133 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2134 2135 2136 2137 try {2138 await stampLocalVaultMutation();2139 } catch (err) {2140 await writeSlot(SLOT_B, slotB).catch(() => {});2141 throw err;2142 }2143}21442145async function persistEntries(2146 reason: VaultSnapshot["reason"] = "local_save",2147): Promise<void> {2148 if (!derivedKey) throw new Error("vault_locked");21492150 if (vaultMode === "decoy") {2151 await persistSlotB();2152 } else {2153 await persistSlotA(reason);2154 }2155 buildDomainIndexSync(entries);2156}21572158function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2159 if (!Array.isArray(values)) return undefined;2160 const seen = new Set<string>();2161 const cleaned: string[] = [];2162 for (const value of values) {2163 const trimmed = typeof value === "string" ? value.trim() : "";2164 if (!trimmed) continue;2165 const key = trimmed.toLowerCase();2166 if (seen.has(key)) continue;2167 seen.add(key);2168 cleaned.push(trimmed);2169 }2170 return cleaned.length > 0 ? cleaned : undefined;2171}21722173function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2174 const title = entry.title.trim();2175 2176 2177 2178 let type: VaultEntry["type"] = entry.type ?? "login";2179 if (type === "passkey") type = "login";21802181 if (!title) throw new Error("title is required");21822183 if (entry.passkey) {2184 const p = entry.passkey;2185 if (2186 !p.rpId ||2187 !p.credentialId ||2188 !p.publicKeyCose ||2189 !p.userHandle ||2190 !p.userName ||2191 (p.algorithm !== -7 && p.algorithm !== -8)2192 ) {2193 throw new Error("invalid passkey credential");2194 }2195 }2196 if (type === "card") {2197 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2198 throw new Error("cardholder name and card number are required");2199 }2200 }22012202 return {2203 type,2204 title,2205 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2206 email: type === "login" ? entry.email?.trim() || undefined : undefined,2207 password: type === "login" ? entry.password : undefined,2208 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2209 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2210 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2211 notes: entry.notes?.trim() || undefined,2212 breach: type === "login" ? entry.breach : undefined,2213 canary: type === "login" ? entry.canary : undefined,2214 tags: entry.tags,2215 passkey: entry.passkey,2216 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2217 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2218 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2219 expYear: type === "card" ? entry.expYear?.trim() : undefined,2220 cvc: type === "card" ? entry.cvc?.trim() : undefined,2221 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2222 network: type === "card" ? entry.network : undefined,2223 last4: type === "card" ? entry.last4?.trim() : undefined,2224 };2225}22262227function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2228 const nextPatch: Partial<VaultEntry> = {};22292230 if ("type" in patch) {2231 nextPatch.type = patch.type;2232 }22332234 if ("title" in patch) {2235 const title = patch.title?.trim() ?? "";2236 if (!title) throw new Error("title is required");2237 nextPatch.title = title;2238 }22392240 if ("url" in patch) {2241 nextPatch.url = patch.url?.trim() ?? "";2242 }22432244 if ("additionalUrls" in patch) {2245 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2246 const seen = new Set<string>();2247 const cleaned: string[] = [];2248 for (const value of incoming) {2249 const trimmed = typeof value === "string" ? value.trim() : "";2250 if (!trimmed) continue;2251 const key = trimmed.toLowerCase();2252 if (seen.has(key)) continue;2253 seen.add(key);2254 cleaned.push(trimmed);2255 }2256 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2257 }22582259 if ("username" in patch) {2260 nextPatch.username = patch.username?.trim() ?? "";2261 }22622263 if ("email" in patch) {2264 nextPatch.email = patch.email?.trim() || undefined;2265 }22662267 if ("password" in patch) {2268 const password = patch.password ?? "";2269 2270 nextPatch.password = password || undefined;2271 }22722273 if ("totp" in patch) {2274 nextPatch.totp = patch.totp?.trim() || undefined;2275 }22762277 if ("notes" in patch) {2278 nextPatch.notes = patch.notes?.trim() || undefined;2279 }22802281 if ("breach" in patch) {2282 nextPatch.breach = patch.breach;2283 }22842285 if ("canary" in patch) {2286 nextPatch.canary = patch.canary;2287 }22882289 if ("passkey" in patch) {2290 2291 2292 2293 nextPatch.passkey = patch.passkey;2294 }22952296 if ("tags" in patch) {2297 nextPatch.tags = patch.tags;2298 }22992300 if ("cardholderName" in patch) {2301 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2302 }2303 if ("cardNumber" in patch) {2304 const n = patch.cardNumber ?? "";2305 nextPatch.cardNumber = n || undefined;2306 }2307 if ("expMonth" in patch) {2308 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2309 }2310 if ("expYear" in patch) {2311 nextPatch.expYear = patch.expYear?.trim() || undefined;2312 }2313 if ("cvc" in patch) {2314 nextPatch.cvc = patch.cvc?.trim() || undefined;2315 }2316 if ("cardNickname" in patch) {2317 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2318 }2319 if ("network" in patch) {2320 nextPatch.network = patch.network;2321 }2322 if ("last4" in patch) {2323 nextPatch.last4 = patch.last4?.trim() || undefined;2324 }23252326 return nextPatch;2327}23282329function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2330 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2331 const modifiedAt =2332 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;23332334 const base: VaultEntry = {2335 id: entry.id,2336 type: "type" in entry ? entry.type : "login",2337 title: entry.title,2338 username: entry.username,2339 email: "email" in entry ? entry.email : undefined,2340 password: entry.password,2341 url: entry.url,2342 additionalUrls:2343 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2344 ? entry.additionalUrls2345 : undefined,2346 totp: entry.totp,2347 notes: entry.notes,2348 breach: entry.breach,2349 2350 2351 2352 passwordChangedAt:2353 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2354 2355 2356 2357 passwordHistory:2358 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2359 ? entry.passwordHistory2360 : undefined,2361 canary: "canary" in entry ? entry.canary : undefined,2362 passkey: "passkey" in entry ? entry.passkey : undefined,2363 createdAt,2364 modifiedAt,2365 deletedAt: entry.deletedAt,2366 tags: "tags" in entry ? entry.tags : undefined,2367 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2368 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2369 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2370 expYear: "expYear" in entry ? entry.expYear : undefined,2371 cvc: "cvc" in entry ? entry.cvc : undefined,2372 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2373 network: "network" in entry ? entry.network : undefined,2374 last4: "last4" in entry ? entry.last4 : undefined,2375 };23762377 return migrateLegacyPasskey(base, entry);2378}237923802381238223832384238523862387function migrateLegacyPasskey(2388 base: VaultEntry,2389 raw: VaultEntry | LegacyVaultEntry2390): VaultEntry {2391 if (base.passkey) return base;2392 const flat = raw as Partial<VaultEntry>;2393 const isPasskeyType = base.type === "passkey";2394 const hasFlatPasskeyFields =2395 typeof flat.rpId === "string" &&2396 typeof flat.credentialId === "string" &&2397 typeof flat.publicKeyCose === "string" &&2398 typeof flat.userHandle === "string" &&2399 typeof flat.userName === "string";2400 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2401 if (!hasFlatPasskeyFields) return base;24022403 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2404 base.passkey = {2405 rpId: flat.rpId!,2406 rpName: flat.rpName ?? flat.rpId!,2407 credentialId: flat.credentialId!,2408 publicKeyCose: flat.publicKeyCose!,2409 privateKeyJwk: flat.privateKeyJwk,2410 userHandle: flat.userHandle!,2411 userName: flat.userName!,2412 userDisplayName: flat.userDisplayName ?? flat.userName!,2413 algorithm,2414 signCount: flat.signCount ?? 0,2415 aaguid: flat.aaguid ?? "",2416 backupEligible: flat.backupEligible === true,2417 backupState: flat.backupState === true,2418 lastUsedAt: flat.lastUsedAt,2419 browser: flat.browser,2420 transports: flat.transports,2421 createdAt: base.createdAt,2422 };2423 2424 2425 2426 if (isPasskeyType) base.type = "login";2427 if (!base.username) base.username = flat.userName;2428 if (!base.url) base.url = `https://${flat.rpId}`;2429 return base;2430}2431243224332434243524362437function entryDupeKey(e: VaultEntry): string {2438 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2439 2440 2441 2442 2443 if (e.passkey) {2444 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2445 }2446 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2447}244824492450export function countDuplicates(): number {2451 const best = new Map<string, VaultEntry>();2452 let dupeCount = 0;2453 for (const e of entries.filter((e) => !e.deletedAt)) {2454 const key = entryDupeKey(e);2455 const existing = best.get(key);2456 if (!existing) {2457 best.set(key, e);2458 } else {2459 dupeCount++;2460 2461 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2462 best.set(key, e);2463 }2464 }2465 }2466 return dupeCount;2467}246824692470247124722473export async function deduplicateEntries(): Promise<number> {2474 if (!derivedKey) throw new Error("vault_locked");2475 2476 2477 if (vaultMode === "decoy") {2478 throw new Error("deduplicate disabled in decoy mode");2479 }24802481 const best = new Map<string, VaultEntry>();2482 for (const e of entries.filter((en) => !en.deletedAt)) {2483 const key = entryDupeKey(e);2484 const existing = best.get(key);2485 if (!existing) {2486 best.set(key, e);2487 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2488 best.set(key, e);2489 }2490 }24912492 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2493 let removed = 0;2494 entries = entries.map((e) => {2495 if (!e.deletedAt && !keepIds.has(e.id)) {2496 removed++;2497 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2498 }2499 return e;2500 });25012502 if (removed > 0) await persistEntries();2503 return removed;2504}250525062507250825092510251125122513const HISTORY_KEY = "peach_vault_history";2514const HISTORY_MAX = 5;25152516251725182519252025212522export async function takeVaultSnapshot(2523 reason: VaultSnapshot["reason"],2524): Promise<void> {2525 try {2526 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2527 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2528 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];25292530 2531 2532 2533 if (!slotA) return;25342535 const vault_a = JSON.stringify(slotA);2536 const vault_b = JSON.stringify(slotB ?? slotA);25372538 2539 2540 2541 let entryCount: number | null = null;2542 if (derivedKey && vaultMode === "real") {2543 try {2544 entryCount = entries.filter((e) => !e.deletedAt).length;2545 } catch {2546 entryCount = null;2547 }2548 }25492550 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2551 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2552 ? existingRaw2553 : { snapshots: [] };25542555 2556 2557 2558 const latest = history.snapshots[0];2559 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2560 return;2561 }25622563 const snapshot: VaultSnapshot = {2564 id: crypto.randomUUID(),2565 savedAt: Date.now(),2566 reason,2567 vault_a,2568 vault_b,2569 entryCount,2570 };25712572 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2573 await chrome.storage.local.set({ [HISTORY_KEY]: history });2574 } catch (err) {2575 2576 2577 2578 2579 console.warn('[Peach] takeVaultSnapshot failed:', err);2580 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2581 try {2582 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2583 } catch {2584 2585 }2586 }2587 }2588}258925902591export async function clearVaultHistory(): Promise<void> {2592 try {2593 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2594 } catch {2595 2596 }2597}259825992600export async function getVaultHistory(): Promise<VaultSnapshot[]> {2601 const raw = await chrome.storage.local.get(HISTORY_KEY);2602 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2603 if (!history || !Array.isArray(history.snapshots)) return [];2604 return history.snapshots;2605}26062607260826092610261126122613export async function restoreVaultSnapshot(2614 snapshotId: string,2615): Promise<{ ok: true } | { ok: false; error: string }> {2616 const history = await getVaultHistory();2617 const snapshot = history.find((s) => s.id === snapshotId);2618 if (!snapshot) return { ok: false, error: "snapshot_not_found" };26192620 2621 2622 2623 await takeVaultSnapshot("restore");26242625 let restoredA: StoredVault;2626 let restoredB: StoredVault;2627 try {2628 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2629 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2630 } catch {2631 return { ok: false, error: "snapshot_corrupt" };2632 }26332634 await writeSlot(SLOT_A, restoredA);2635 await writeSlot(SLOT_B, restoredB);2636 await stampLocalVaultMutation();26372638 2639 2640 2641 lock();26422643 return { ok: true };2644}264526462647264826492650265126522653265426552656265726582659export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {2660 if (!derivedKey || vaultMode !== "real") {2661 return { ok: false, error: "vault_locked" };2662 }2663 const slotA = await loadSlot(SLOT_A);2664 if (!slotA) return { ok: false, error: "missing_slot_a" };26652666 2667 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =2668 await generateAndWrapRootKey();26692670 2671 const newRootKey = unhex(rawRootKey);2672 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);26732674 2675 2676 2677 2678 await takeVaultSnapshot("local_save");2679 const nextSlotA: StoredVault = {2680 ...slotA,2681 recovery_salt: recoverySalt,2682 recovery_wrapped_root_key: wrappedRootKey,2683 recovery_nonce: wrappedNonce,2684 recovery_tag: wrappedTag,2685 ...pwRoot,2686 };2687 await writeSlot(SLOT_A, nextSlotA);26882689 2690 if (rootKey) zeroKey(rootKey);2691 rootKey = newRootKey;26922693 2694 const { clearPairedDevices } = await import("./paired-devices");2695 await clearPairedDevices();26962697 2698 await setRecoveryConfirmPending(); 26992700 await stampLocalVaultMutation();2701 return { ok: true };2702}27032704270527062707270827092710export async function changePassword(2711 currentPassword: string,2712 newPassword: string2713): Promise<boolean> {2714 if (!derivedKey || vaultMode !== "real") return false;27152716 2717 const valid = await verifyMainPassword(currentPassword);2718 if (!valid) return false;27192720 const slotA = await loadSlot(SLOT_A);2721 if (!slotA) return false;2722 if (!rootKey) return false;27232724 2725 const newSalt = generateSalt();2726 const newKey = await deriveKey(newPassword, newSalt);27272728 2729 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2730 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");27312732 2733 2734 2735 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);27362737 const finalSlotA: StoredVault = {2738 ...slotA,2739 salt: hex(newSalt),2740 nonce,2741 ciphertext,2742 tag,2743 ...pwRoot,2744 };27452746 2747 2748 2749 await takeVaultSnapshot("local_save");27502751 2752 2753 2754 2755 await writeSlot(SLOT_A, finalSlotA);27562757 2758 2759 2760 zeroKey(derivedKey);2761 derivedKey = newKey;27622763 2764 2765 2766 if (!duressConfigured) {2767 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2768 }2769 await stampLocalVaultMutation();27702771 return true;2772}