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";3536type EntryDraft = Omit<37 VaultEntry,38 "id" | "createdAt" | "modifiedAt" | "deletedAt"39>;404142434445const SLOT_A = "peach_vault_a";46const SLOT_B = "peach_vault_b";474849const LEGACY_MAIN = "peach_vault";50const LEGACY_DECOY = "peach_decoy_vault";51const LEGACY_DURESS_FLAG = "peach_duress";52535455const PAD_BLOCK = 1024;5657585960const PASSWORD_HISTORY_MAX = 50;6162636465666768697071const DURESS_SLOT_SIZE = 262_144;7273let derivedKey: Uint8Array | null = null;7475767778let rootKey: Uint8Array | null = null;79let entries: VaultEntry[] = [];80let domainIndex: Map<string, VaultEntry[]> = new Map();81let vaultMode: "real" | "decoy" = "real";82838485let duressConfigured = false;8687let migrationRan = false;8889909192939495969798let lastUnlockFailureReason: string | null = null;99100export function consumeLastUnlockFailureReason(): string | null {101 const r = lastUnlockFailureReason;102 lastUnlockFailureReason = null;103 return r;104}105106107108109110export type TextSize = "small" | "medium" | "large";111112export interface VaultSettings {113 accentColor?: AccentColorName;114 theme?: "dark" | "light";115 autoLockMinutes?: number;116 clipboardClearSeconds?: number;117 lockOnClose?: boolean;118 breachMonitoring?: boolean;119 passkeyProviderEnabled?: boolean;120 trashRetentionDays?: number;121 manualSummonDomains?: Record<string, ManualSummonRecord>;122 declinedCardFingerprints?: string[];123 124125126127128 textSize?: TextSize;129 130131132133134 reduceMotion?: boolean;135 136137138139140 biometricsEnabled?: boolean;141 142143144145146147148 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;149}150151let settings: VaultSettings = {};152153export function getSettings(): VaultSettings {154 return settings;155}156157export async function ensureSettingsLoaded(): Promise<VaultSettings> {158 await loadSettings();159 return settings;160}161162export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {163 await loadSettings();164 settings = { ...settings, ...patch };165 await persistSettings();166}167168async function persistSettings(): Promise<void> {169 try {170 await chrome.storage.local.set({ peach_settings: settings });171 } catch (err) {172 throw mapStorageError(err);173 }174}175176function mapStorageError(err: unknown): Error {177 const message = err instanceof Error ? err.message : String(err);178 if (/QUOTA_BYTES|quota/i.test(message)) {179 return new Error("STORAGE_QUOTA_EXCEEDED");180 }181 return err instanceof Error ? err : new Error(message);182}183184async function loadSettings(): Promise<void> {185 const result = await chrome.storage.local.get("peach_settings");186 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};187}188189190191192193194function fireSyncTrigger(reason: "vault_change"): void {195 void import("./sync-trigger")196 .then((mod) => mod.triggerSyncIfReachable({ reason }))197 .catch(() => {198 199 });200}201202203204205206export async function checkBreach(password: string): Promise<number> {207 208 209 const encoder = new TextEncoder();210 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));211 const hashHex = Array.from(new Uint8Array(hashBuffer))212 .map((b) => b.toString(16).padStart(2, "0"))213 .join("")214 .toUpperCase();215216 const prefix = hashHex.slice(0, 5);217 const suffix = hashHex.slice(5);218219 try {220 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);221 if (!res.ok) return 0;222 const text = await res.text();223 const match = text.split("\r\n").find((l) => l.startsWith(suffix));224 return match ? parseInt(match.split(":")[1], 10) : 0;225 } catch {226 return 0; 227 }228}229230export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {231 const fingerprint = await cardFingerprint(last4, expMonth, expYear);232 await loadSettings();233 settings = {234 ...settings,235 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],236 };237 await persistSettings();238}239240export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {241 await loadSettings();242 const fingerprint = await cardFingerprint(last4, expMonth, expYear);243 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);244}245246async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {247 const encoder = new TextEncoder();248 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));249 return Array.from(new Uint8Array(buf).slice(0, 8))250 .map((b) => b.toString(16).padStart(2, "0"))251 .join("");252}253254function hex(buf: Uint8Array): string {255 return Array.from(buf)256 .map((b) => b.toString(16).padStart(2, "0"))257 .join("");258}259260function unhex(s: string): Uint8Array {261 const out = new Uint8Array(s.length / 2);262 for (let i = 0; i < s.length; i += 2) {263 out[i / 2] = parseInt(s.slice(i, i + 2), 16);264 }265 return out;266}267268269270271272273274275276277278279async function generateAndWrapRootKey(): Promise<{280 rawRootKey: string; 281 recoverySalt: string;282 wrappedRootKey: string;283 wrappedNonce: string;284 wrappedTag: string;285}> {286 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));287288 289 const words = await rootKeyToWords(rootKeyBytes);290 const wordMaterial = await wordsToRootKeyMaterial(words);291 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));292 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);293294 295 296 const nonce = crypto.getRandomValues(new Uint8Array(12));297 const encoded = new TextEncoder().encode(hex(rootKeyBytes));298 const raw = await crypto.subtle.encrypt(299 {300 name: "AES-GCM",301 iv: nonce,302 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,303 tagLength: 128,304 },305 recoveryKey,306 encoded307 );308 309 const rawU8 = new Uint8Array(raw);310 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));311 const tag = hex(rawU8.slice(rawU8.length - 16));312313 return {314 rawRootKey: hex(rootKeyBytes),315 recoverySalt: hex(recoverySalt),316 wrappedRootKey: ciphertext,317 wrappedNonce: hex(nonce),318 wrappedTag: tag,319 };320}321322323324325326327export async function unwrapRootKeyForRecovery(328 wrappedRootKey: string,329 wrappedNonce: string,330 wrappedTag: string,331 recoverySaltHex: string,332 words: string[]333): Promise<void> {334 const wordMaterial = await wordsToRootKeyMaterial(words);335 const recoverySalt = unhex(recoverySaltHex);336 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);337338 339 340 341 342 343 const nonceRaw = unhex(wrappedNonce);344 const ciphertextRaw = unhex(wrappedRootKey);345 const tagRaw = unhex(wrappedTag);346 347 348 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;349 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);350 combined.set(ciphertextRaw, 0);351 combined.set(tagRaw, ciphertextRaw.length);352 const raw = await crypto.subtle.decrypt(353 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },354 recoveryKey,355 combined356 );357 const decryptedHex = new TextDecoder().decode(raw);358 rootKey = unhex(decryptedHex);359}360361362363364365366367368369370export async function rewrapRootKeyForPassword(371 newDerivedKey: Uint8Array,372 _newSalt: Uint8Array373): Promise<void> {374 if (!rootKey) throw new Error("root key not set");375 const slotA = await loadSlot(SLOT_A);376 if (!slotA) throw new Error("missing slot A");377 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);378 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });379}380381382383384385386async function reencryptVaultForNewKey(387 newKey: Uint8Array,388 slotA: StoredVault389): Promise<void> {390 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);391 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");392 await writeSlot(SLOT_A, {393 ...slotA,394 nonce,395 ciphertext,396 tag,397 });398}399400401402403404405export async function changePasswordWithNewKey(406 newSalt: Uint8Array,407 newKey: Uint8Array408): Promise<void> {409 const slotA = await loadSlot(SLOT_A);410 if (!slotA) throw new Error("missing slot A");411 412 413 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });414 if (derivedKey) zeroKey(derivedKey);415 derivedKey = newKey;416 417 418 419 420 if (rootKey) {421 const slotAfter = await loadSlot(SLOT_A);422 if (slotAfter) {423 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);424 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });425 }426 }427 if (!duressConfigured) {428 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));429 }430 await stampLocalVaultMutation();431}432433434435436437438export { deriveKey, generateSalt };439440export async function loadSlotA(): Promise<StoredVault | null> {441 return loadSlot(SLOT_A);442}443444445446447448449450451452453454455456async function deriveRootKeyFromDerivedKey(457 derivedKeyBytes: Uint8Array,458 slotA: StoredVault,459): Promise<Uint8Array | null> {460 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {461 return null;462 }463 try {464 const rootHex = await decrypt(465 derivedKeyBytes,466 slotA.pw_root_nonce,467 slotA.pw_root_ciphertext,468 slotA.pw_root_tag,469 "pw_root_v1",470 );471 return unhex(rootHex);472 } catch {473 return null;474 }475}476477478479async function wrapRootKeyForDerivedKey(480 derivedKeyBytes: Uint8Array,481 rootKeyBytes: Uint8Array,482): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {483 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");484 return {485 pw_root_ciphertext: ciphertext,486 pw_root_nonce: nonce,487 pw_root_tag: tag,488 };489}490491492493494495496497498export async function migrateToRecovery(): Promise<void> {499 if (!derivedKey || vaultMode !== "real") {500 throw new Error("vault must be unlocked in real mode");501 }502 const slotA = await loadSlot(SLOT_A);503 if (!slotA) throw new Error("missing slot A");504 const hasRecoveryEscrow =505 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;506 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;507 if (hasRecoveryEscrow && hasPwRoot) return; 508509 510 511 512 513 514 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =515 await generateAndWrapRootKey();516 rootKey = unhex(rawRootKey);517518 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);519 const nextSlotA: StoredVault = {520 ...slotA,521 recovery_salt: recoverySalt,522 recovery_wrapped_root_key: wrappedRootKey,523 recovery_nonce: wrappedNonce,524 recovery_tag: wrappedTag,525 ...pwRoot,526 };527 await writeSlot(SLOT_A, nextSlotA);528 await stampLocalVaultMutation();529}530531532533534535536export async function getRecoveryWords(): Promise<string[]> {537 if (!rootKey) {538 throw new Error("recovery not set up — vault may need migration");539 }540 return rootKeyToWords(rootKey);541}542543async function loadSlot(key: string): Promise<StoredVault | null> {544 const result = await chrome.storage.local.get(key);545 return (result as Record<string, StoredVault | undefined>)[key] ?? null;546}547548async function writeSlot(key: string, vault: StoredVault): Promise<void> {549 try {550 await chrome.storage.local.set({ [key]: vault });551 } catch (err) {552 throw mapStorageError(err);553 }554}555556557558559560561562563let lastEntryTimestamp = 0;564function monotonicEntryTimestamp(currentModifiedAt?: number): number {565 const base = Math.max(566 Date.now(),567 (currentModifiedAt ?? 0) + 1,568 lastEntryTimestamp + 1,569 );570 lastEntryTimestamp = base;571 return base;572}573574async function stampLocalVaultMutation(): Promise<void> {575 const raw = await chrome.storage.local.get("peach_last_modified_at");576 const previous = Number(577 (raw as Record<string, unknown>).peach_last_modified_at,578 );579 const next = Math.max(580 Date.now(),581 Number.isFinite(previous) ? previous + 1 : 0,582 );583 await chrome.storage.local.set({584 peach_last_modified_at: String(next),585 });586}587588589590591592593async function ensureMigrated(): Promise<void> {594 if (migrationRan) return;595 migrationRan = true;596597 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];598 const raw = await chrome.storage.local.get(keys);599 const slotAExists = raw[SLOT_A] !== undefined;600 const slotBExists = raw[SLOT_B] !== undefined;601 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;602 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;603604 605 if (!slotAExists && !legacyMain) return;606607 608 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;609610 let didMigrate = false;611 if (legacyMain && !slotAExists) {612 await writeSlot(SLOT_A, legacyMain);613 didMigrate = true;614 }615616 617 if (!slotBExists) {618 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;619 if (legacyDecoy) {620 await writeSlot(SLOT_B, legacyDecoy);621 didMigrate = true;622 } else if (target) {623 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));624 didMigrate = true;625 }626 }627628 const toRemove: string[] = [];629 if (legacyMain) toRemove.push(LEGACY_MAIN);630 if (legacyDecoy) toRemove.push(LEGACY_DECOY);631 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);632 if (toRemove.length > 0) {633 await chrome.storage.local.remove(toRemove);634 didMigrate = true;635 }636 if (didMigrate) {637 await stampLocalVaultMutation();638 }639}640641642643644645646647648649650651function padPlaintext(json: string, minBytes = 0): string {652 const encoded = new TextEncoder().encode(json);653 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);654 655 const target = Math.max(bucket, minBytes);656 const padLen = target - encoded.length;657 return json + " ".repeat(padLen);658}659660661662663664665666667668669670const CSPRNG_CHUNK = 65_536;671672function fillRandom(buf: Uint8Array): Uint8Array {673 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {674 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);675 const chunk = new Uint8Array(end - offset);676 crypto.getRandomValues(chunk);677 buf.set(chunk, offset);678 }679 return buf;680}681682function generateNoiseBlob(ciphertextBytes: number): StoredVault {683 const salt = crypto.getRandomValues(new Uint8Array(16));684 const nonce = crypto.getRandomValues(new Uint8Array(12));685 const cipher = fillRandom(new Uint8Array(ciphertextBytes));686 const tag = crypto.getRandomValues(new Uint8Array(16));687 return {688 version: 1,689 salt: hex(salt),690 nonce: hex(nonce),691 ciphertext: hex(cipher),692 tag: hex(tag),693 };694}695696697698699700type VaultPayload = {701 entries: VaultEntry[];702 duressConfigured?: boolean;703};704705type DecryptedPayload = {706 entries: VaultEntry[];707 duressConfigured: boolean;708 709710711712713714715 migratedFromLegacy: boolean;716};717718function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {719 const envelope: VaultPayload = { entries, duressConfigured: duress };720 return padPlaintext(JSON.stringify(envelope), minBytes);721}722723function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {724 if ("created" in raw || "updated" in raw) return true;725 const flat = raw as Partial<VaultEntry>;726 if (flat.type === "passkey") return true;727 728 if (flat.rpId !== undefined && flat.passkey === undefined) return true;729 return false;730}731732function decodePayload(plain: string): DecryptedPayload {733 const parsed = JSON.parse(plain) as734 | Array<VaultEntry | LegacyVaultEntry>735 | VaultPayload;736737 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];738 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);739740 const normalized = rawEntries.map(normalizeEntry);741 const { merged } = mergePasskeyOnlyIntoLogins(normalized);742743 if (Array.isArray(parsed)) {744 return {745 entries: normalized,746 duressConfigured: false,747 migratedFromLegacy: true, 748 };749 }750751 return {752 entries: normalized,753 duressConfigured: parsed.duressConfigured === true,754 migratedFromLegacy: flatMigrationNeeded || merged,755 };756}757758759760761762763764765766767768769770771772773774775776777778779780781function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {782 let merged = false;783 const now = Date.now();784785 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);786 const byRpId = new Map<string, VaultEntry[]>();787 for (const e of candidates) {788 const rpId = e.passkey!.rpId;789 const list = byRpId.get(rpId);790 if (list) list.push(e);791 else byRpId.set(rpId, [e]);792 }793794 for (const [rpId, group] of byRpId) {795 if (group.length === 0) continue;796 group.sort((a, b) => {797 const aT =798 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;799 const bT =800 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;801 return bT - aT;802 });803 const winner = group[0]!;804805 806 807 808 const loginTarget = entries.find((e) => {809 if (e.deletedAt || e.passkey || e.type !== "login") return false;810 if (e.url && extractDomain(e.url) === rpId) return true;811 for (const extra of e.additionalUrls ?? []) {812 if (extractDomain(extra) === rpId) return true;813 }814 return false;815 });816817 if (loginTarget) {818 loginTarget.passkey = winner.passkey;819 loginTarget.modifiedAt = now;820 for (const dup of group) {821 if (!dup.deletedAt) {822 dup.deletedAt = now;823 dup.modifiedAt = now;824 }825 }826 merged = true;827 } else if (group.length > 1) {828 for (let i = 1; i < group.length; i += 1) {829 const dup = group[i]!;830 if (!dup.deletedAt) {831 dup.deletedAt = now;832 dup.modifiedAt = now;833 }834 }835 merged = true;836 }837 }838839 return { merged };840}841842843844845846847848function hasValidVaultSchema(v: unknown): v is StoredVault {849 if (typeof v !== "object" || v === null) return false;850 const r = v as Record<string, unknown>;851 return (852 r.version === 1 &&853 typeof r.salt === "string" &&854 typeof r.nonce === "string" &&855 typeof r.ciphertext === "string" &&856 typeof r.tag === "string"857 );858}859860export type TryDecryptResult =861 | { status: "ok"; payload: DecryptedPayload }862 | { status: "wrong_key" }863 | { status: "corrupted"; reason: string };864865866867868869870export async function tryDecryptSlot(871 vault: StoredVault,872 key: Uint8Array,873 aadTag?: string,874): Promise<DecryptedPayload | null> {875 const res = await tryDecryptSlotResult(vault, key, aadTag);876 return res.status === "ok" ? res.payload : null;877}878879export async function tryDecryptSlotResult(880 vault: StoredVault,881 key: Uint8Array,882 aadTag?: string,883): Promise<TryDecryptResult> {884 if (!hasValidVaultSchema(vault)) {885 return { status: "corrupted", reason: "invalid_slot_schema" };886 }887 let plain: string;888 try {889 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);890 } catch {891 return { status: "wrong_key" };892 }893 try {894 const parsed = JSON.parse(plain) as unknown;895 if (typeof parsed !== "object" || parsed === null) {896 return { status: "corrupted", reason: "invalid_payload_shape" };897 }898 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {899 return { status: "corrupted", reason: "invalid_payload_shape" };900 }901 return { status: "ok", payload: decodePayload(plain) };902 } catch {903 return { status: "corrupted", reason: "invalid_payload_json" };904 }905}906907908909910911function buildDomainIndexSync(all: VaultEntry[]): void {912 domainIndex = new Map();913914 for (const entry of all) {915 if (entry.deletedAt) continue;916 if (entry.type === "note" || entry.type === "card") continue;917918 919 920 921 922 const domains = new Set<string>();923 const primary = extractDomain(entry.url ?? "");924 if (primary) domains.add(primary);925 for (const extra of entry.additionalUrls ?? []) {926 const d = extractDomain(extra);927 if (d) domains.add(d);928 }929 if (domains.size === 0) continue;930931 for (const domain of domains) {932 const bucket = domainIndex.get(domain) ?? [];933 bucket.push(entry);934 domainIndex.set(domain, bucket);935 }936 }937}938939export async function createVault(password: string): Promise<void> {940 await ensureMigrated();941942 const salt = generateSalt();943 derivedKey = await deriveKey(password, salt);944 entries = [];945 vaultMode = "real";946 duressConfigured = false;947 buildDomainIndexSync(entries);948949 950 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =951 await generateAndWrapRootKey();952 rootKey = unhex(rawRootKey);953954 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);955 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");956957 958 959 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);960961 const slotA: StoredVault = {962 version: 1,963 salt: hex(salt),964 nonce,965 ciphertext,966 tag,967 recovery_salt: recoverySalt,968 recovery_wrapped_root_key: wrappedRootKey,969 recovery_nonce: wrappedNonce,970 recovery_tag: wrappedTag,971 ...pwRoot,972 };973 await writeSlot(SLOT_A, slotA);974975 976 977 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));978 await stampLocalVaultMutation();979980 touchAutoLock();981}982983export async function hasVault(): Promise<boolean> {984 await ensureMigrated();985 return (await loadSlot(SLOT_A)) !== null;986}987988989990991992993export async function isDuressEnabled(): Promise<boolean> {994 return vaultMode === "real" && duressConfigured;995}996997export async function setupDuress(duressPassword: string): Promise<void> {998 if (!derivedKey || vaultMode !== "real") {999 throw new Error("vault must be unlocked in real mode");1000 }1001 await ensureMigrated();10021003 1004 1005 1006 const slotA = await loadSlot(SLOT_A);1007 if (!slotA) throw new Error("missing slot A");10081009 const decoySalt = generateSalt();1010 const decoyKey = await deriveKey(duressPassword, decoySalt);1011 try {1012 1013 1014 1015 1016 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1017 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1018 await writeSlot(SLOT_B, {1019 version: 1,1020 salt: hex(decoySalt),1021 nonce,1022 ciphertext,1023 tag,1024 });1025 } finally {1026 zeroKey(decoyKey);1027 }10281029 1030 1031 duressConfigured = true;1032 await persistSlotA();1033}10341035export async function disableDuress(): Promise<void> {1036 if (!derivedKey || vaultMode !== "real") {1037 throw new Error("vault must be unlocked in real mode");1038 }1039 await ensureMigrated();10401041 1042 1043 1044 const slotA = await loadSlot(SLOT_A);1045 if (slotA) {1046 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1047 }10481049 duressConfigured = false;1050 await persistSlotA();1051}10521053105410551056105710581059export async function verifyMainPassword(password: string): Promise<boolean> {1060 await ensureMigrated();1061 const slotA = await loadSlot(SLOT_A);1062 if (!slotA) return false;1063 const key = await deriveKey(password, unhex(slotA.salt));1064 try {1065 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1066 } finally {1067 zeroKey(key);1068 }1069}10701071export async function unlock(password: string): Promise<boolean> {1072 await ensureMigrated();10731074 1075 1076 1077 1078 1079 const [slotA, slotB] = await Promise.all([1080 loadSlot(SLOT_A),1081 loadSlot(SLOT_B),1082 ]);10831084 1085 const [keyA, keyB] = await Promise.all([1086 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),1087 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),1088 ]);10891090 1091 1092 1093 const [resultA, resultB] = await Promise.all([1094 keyA && slotA1095 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1096 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1097 keyB && slotB1098 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1099 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1100 ]);1101 const payloadA = resultA.status === "ok" ? resultA.payload : null;1102 const payloadB = resultB.status === "ok" ? resultB.payload : null;11031104 if (payloadA && keyA) {1105 derivedKey = keyA;1106 void saveSessionKey(keyA, "real");1107 vaultMode = "real";1108 entries = payloadA.entries;1109 duressConfigured = payloadA.duressConfigured;1110 buildDomainIndexSync(entries);1111 await loadSettings();1112 touchAutoLock();1113 if (keyB) zeroKey(keyB);11141115 1116 if (slotA) {1117 const needsRecoverySetup =1118 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1119 if (!needsRecoverySetup) {1120 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1121 }1122 1123 1124 1125 1126 if (needsRecoverySetup || !rootKey) {1127 try {1128 await migrateToRecovery();1129 if (needsRecoverySetup) {1130 1131 1132 1133 await chrome.storage.local.set({ peach_recovery_pending: '1' });1134 }1135 } catch (migrationErr) {1136 console.warn('[Peach] recovery migration failed:', migrationErr);1137 }1138 }1139 }11401141 1142 1143 1144 if (payloadA.migratedFromLegacy) {1145 await persistSlotA();1146 }1147 return true;1148 }11491150 if (payloadB && keyB) {1151 derivedKey = keyB;1152 void saveSessionKey(keyB, "decoy");1153 vaultMode = "decoy";1154 entries = payloadB.entries;1155 duressConfigured = false;1156 buildDomainIndexSync(entries);1157 await loadSettings();1158 touchAutoLock();1159 if (keyA) zeroKey(keyA);1160 return true;1161 }11621163 if (keyA) zeroKey(keyA);1164 if (keyB) zeroKey(keyB);1165 1166 1167 if (resultA.status === "corrupted") {1168 lastUnlockFailureReason = resultA.reason;1169 } else if (resultB.status === "corrupted") {1170 lastUnlockFailureReason = resultB.reason;1171 }1172 return false;1173}11741175117611771178117911801181const SESSION_KEY_STORAGE = "peach_session_dk";11821183async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1184 if (!chrome.storage.session) return;1185 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1186}11871188async function clearSessionKey(): Promise<void> {1189 if (!chrome.storage.session) return;1190 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1191}11921193119411951196119711981199export async function restoreFromSession(): Promise<boolean> {1200 if (derivedKey !== null) return true;1201 if (!chrome.storage.session) return false;1202 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1203 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1204 if (!entry?.keyHex) return false;12051206 const key = unhex(entry.keyHex);1207 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1208 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1209 const slot = await loadSlot(slotKey);1210 if (!slot) { await clearSessionKey(); return false; }12111212 const result = await tryDecryptSlotResult(slot, key, aadTag);1213 if (result.status !== "ok") { await clearSessionKey(); return false; }12141215 derivedKey = key;1216 vaultMode = entry.mode;1217 entries = result.payload.entries;1218 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1219 buildDomainIndexSync(entries);1220 await loadSettings();1221 touchAutoLock();12221223 if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1224 try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { }1225 }12261227 return true;1228}12291230export function lock(): void {1231 void clearSessionKey();1232 if (derivedKey) {1233 zeroKey(derivedKey);1234 derivedKey = null;1235 }1236 if (rootKey) {1237 rootKey.fill(0);1238 rootKey = null;1239 }12401241 entries = [];1242 domainIndex = new Map();1243 vaultMode = "real";1244 duressConfigured = false;1245 clearAutoLock();1246}12471248export function isUnlocked(): boolean {1249 return derivedKey !== null;1250}1251125212531254export async function unlockWithKey(1255 masterKeyHex: string,1256): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1257 try {1258 const masterKeyBytes = unhex(masterKeyHex);1259 const stored = await loadSlot(SLOT_A);1260 if (!stored) return { ok: false, error: "invalid_key" };1261 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1262 if (!payload) return { ok: false, error: "invalid_key" };12631264 derivedKey = masterKeyBytes;1265 vaultMode = "real";1266 entries = payload.entries;1267 duressConfigured = payload.duressConfigured;1268 buildDomainIndexSync(entries);1269 await loadSettings();1270 touchAutoLock();1271 return { ok: true, entries };1272 } catch {1273 return { ok: false, error: "invalid_key" };1274 }1275}12761277127812791280128112821283128412851286128712881289129012911292129312941295export async function unlockFromCodex(params: {1296 slotA: string; 1297 slotB: string | null; 1298 timestamp: number;1299 masterPassphrase: string;1300}): Promise<1301 | { ok: true; entryCount: number }1302 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1303> {1304 let parsedSlotA: StoredVault;1305 let parsedSlotB: StoredVault | null = null;1306 try {1307 const rawA = JSON.parse(params.slotA) as unknown;1308 if (!hasValidVaultSchema(rawA)) {1309 return { ok: false, reason: "invalid_slot_format" };1310 }1311 parsedSlotA = rawA;1312 if (params.slotB !== null) {1313 const rawB = JSON.parse(params.slotB) as unknown;1314 if (!hasValidVaultSchema(rawB)) {1315 return { ok: false, reason: "invalid_slot_format" };1316 }1317 parsedSlotB = rawB;1318 }1319 } catch {1320 return { ok: false, reason: "invalid_slot_format" };1321 }13221323 1324 1325 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1326 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1327 if (!trialPayload) {1328 zeroKey(trialKey);1329 return { ok: false, reason: "wrong_passphrase" };1330 }1331 1332 1333 zeroKey(trialKey);13341335 1336 1337 await takeVaultSnapshot("restore");13381339 await writeSlot(SLOT_A, parsedSlotA);1340 if (parsedSlotB) {1341 await writeSlot(SLOT_B, parsedSlotB);1342 } else {1343 1344 1345 1346 await writeSlot(1347 SLOT_B,1348 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1349 );1350 }1351 await chrome.storage.local.set({1352 peach_last_modified_at: String(params.timestamp),1353 });13541355 1356 1357 const ok = await unlock(params.masterPassphrase);1358 if (!ok) {1359 1360 1361 1362 return { ok: false, reason: "wrong_passphrase" };1363 }1364 return { ok: true, entryCount: getEntries().length };1365}1366136713681369export function getDerivedKeyHex(): string {1370 if (!derivedKey) throw new Error("vault_not_unlocked");1371 return hex(derivedKey);1372}13731374137513761377137813791380138113821383138413851386138713881389export function getRootKeyHex(): string | null {1390 return rootKey ? hex(rootKey) : null;1391}13921393export function getEntries(): VaultEntry[] {1394 if (!derivedKey) throw new Error("vault_locked");1395 return entries.filter((entry) => !entry.deletedAt);1396}13971398const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;13991400export function getIdentities(): SavedIdentity[] {1401 if (!derivedKey) throw new Error("vault_locked");1402 const active = entries.filter((e) => !e.deletedAt && e.type === "login");14031404 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1405 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();14061407 for (const entry of active) {1408 const domain = extractDomain(entry.url ?? "") ?? "";1409 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;14101411 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1412 const existing = map.get(v);1413 if (!existing || modifiedAt > existing.lastUsed) {1414 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1415 } else {1416 map.set(v, { ...existing, count: existing.count + 1 });1417 }1418 }14191420 const uVal = entry.username?.trim();1421 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);14221423 1424 1425 const eVal = entry.email?.trim();1426 if (eVal) tally(emailMap, eVal);1427 }14281429 const toIdentity = (type: "email" | "username") =>1430 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1431 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1432 });14331434 return [1435 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1436 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1437 ];1438}14391440export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1441 if (!derivedKey) throw new Error("vault_locked");14421443 const now = Date.now();1444 const added: VaultEntry[] = [];1445 for (const draft of drafts) {1446 try {1447 const sanitized = sanitizeEntryDraft(draft);1448 const nextEntry: VaultEntry = {1449 ...sanitized,1450 id: crypto.randomUUID(),1451 createdAt: now,1452 modifiedAt: monotonicEntryTimestamp(),1453 };1454 entries.push(nextEntry);1455 added.push(nextEntry);1456 } catch {1457 1458 }1459 }14601461 if (added.length > 0) {1462 await persistEntries();1463 }14641465 return added;1466}146714681469147014711472147314741475export async function restoreEntries(1476 incoming: VaultEntry[],1477 mode: "replace" | "merge"1478): Promise<{ added: number; updated: number; skipped: number }> {1479 if (!derivedKey) throw new Error("vault_locked");14801481 1482 1483 1484 if (mode === "replace" && incoming.length === 0) {1485 throw new Error("refuse to replace with empty entry set");1486 }14871488 let added = 0;1489 let updated = 0;1490 let skipped = 0;14911492 if (mode === "replace") {1493 entries = [];1494 for (const entry of incoming) {1495 const normalized = sanitizeRestoredEntry(entry);1496 if (!normalized) {1497 skipped += 1;1498 continue;1499 }1500 entries.push(normalized);1501 added += 1;1502 }1503 } else {1504 const byId = new Map<string, VaultEntry>();1505 for (const e of entries) byId.set(e.id, e);15061507 for (const entry of incoming) {1508 const normalized = sanitizeRestoredEntry(entry);1509 if (!normalized) {1510 skipped += 1;1511 continue;1512 }1513 const existing = byId.get(normalized.id);1514 if (!existing) {1515 entries.push(normalized);1516 added += 1;1517 } else if (normalized.modifiedAt > existing.modifiedAt) {1518 const idx = entries.findIndex((e) => e.id === existing.id);1519 if (idx !== -1) {1520 entries[idx] = normalized;1521 updated += 1;1522 }1523 } else {1524 skipped += 1;1525 }1526 }1527 }15281529 await persistEntries("import");1530 return { added, updated, skipped };1531}153215331534153515361537153815391540function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1541 if (!entry || typeof entry !== "object") return null;1542 const title = typeof entry.title === "string" ? entry.title.trim() : "";1543 if (!title) return null;1544 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1545 const now = Date.now();1546 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1547 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;15481549 1550 1551 1552 1553 const normalized = normalizeEntry({1554 ...entry,1555 id,1556 title,1557 type: entry.type ?? "login",1558 createdAt,1559 modifiedAt,1560 });15611562 if (normalized.passkey) {1563 normalized.passkey = sanitizePasskey(normalized.passkey);1564 }1565 if (normalized.canary) {1566 normalized.canary = sanitizeCanary(normalized.canary);1567 }15681569 1570 1571 if (1572 normalized.type === "login" &&1573 !normalized.passkey &&1574 typeof normalized.password !== "string"1575 ) {1576 return null;1577 }1578 if (entry.passkey && !normalized.passkey) {1579 1580 1581 return null;1582 }15831584 return normalized;1585}1586158715881589159015911592function sanitizePasskey(pk: unknown): PasskeyData | undefined {1593 if (!pk || typeof pk !== "object") return undefined;1594 const r = pk as Record<string, unknown>;1595 if (1596 typeof r.rpId !== "string" || !r.rpId ||1597 typeof r.credentialId !== "string" || !r.credentialId ||1598 typeof r.publicKeyCose !== "string" ||1599 typeof r.userHandle !== "string" ||1600 typeof r.userName !== "string"1601 ) {1602 return undefined;1603 }1604 return {1605 rpId: r.rpId,1606 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1607 credentialId: r.credentialId,1608 publicKeyCose: r.publicKeyCose,1609 privateKeyJwk:1610 r.privateKeyJwk && typeof r.privateKeyJwk === "object"1611 ? (r.privateKeyJwk as JsonWebKey)1612 : undefined,1613 userHandle: r.userHandle,1614 userName: r.userName,1615 userDisplayName:1616 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1617 algorithm: r.algorithm === -8 ? -8 : -7,1618 signCount: typeof r.signCount === "number" ? r.signCount : 0,1619 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1620 backupEligible: r.backupEligible === true,1621 backupState: r.backupState === true,1622 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1623 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1624 transports: Array.isArray(r.transports)1625 ? r.transports.filter((t): t is string => typeof t === "string")1626 : undefined,1627 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1628 };1629}16301631163216331634163516361637163816391640function sanitizeCanary(c: unknown): Canary | undefined {1641 if (!c || typeof c !== "object") return undefined;1642 const r = c as Record<string, unknown>;1643 if (typeof r.webhookUrl !== "string") return undefined;1644 let url: URL;1645 try {1646 url = new URL(r.webhookUrl);1647 } catch {1648 return undefined;1649 }1650 if (url.protocol !== "https:") return undefined;1651 return {1652 webhookUrl: url.toString(),1653 label: typeof r.label === "string" ? r.label : undefined,1654 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,1655 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,1656 };1657}165816591660166116621663export async function clearVaultEntries(1664 confirmation: "REPLACE_ALL"1665): Promise<void> {1666 if (confirmation !== "REPLACE_ALL") {1667 throw new Error("clearVaultEntries requires explicit confirmation");1668 }1669 if (!derivedKey) throw new Error("vault_locked");1670 for (const entry of entries) {1671 if (!entry.deletedAt) {1672 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1673 entry.deletedAt = stamp;1674 entry.modifiedAt = stamp;1675 }1676 }1677 await persistEntries();1678}16791680export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1681 if (!derivedKey) throw new Error("vault_locked");16821683 const sanitized = sanitizeEntryDraft(entry);1684 const now = Date.now();1685 const stamp = monotonicEntryTimestamp();1686 const nextEntry: VaultEntry = {1687 ...sanitized,1688 id: crypto.randomUUID(),1689 createdAt: now,1690 modifiedAt: stamp,1691 1692 1693 passwordChangedAt: sanitized.password ? stamp : undefined,1694 };16951696 1697 1698 1699 const rollback = entries;1700 entries = [...entries, nextEntry];1701 try {1702 await persistEntries();1703 } catch (err) {1704 entries = rollback;1705 throw err;1706 }17071708 1709 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1710 checkBreach(nextEntry.password).then(async (count) => {1711 if (count > 0) {1712 const idx = entries.findIndex((e) => e.id === nextEntry.id);1713 if (idx !== -1) {1714 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };1715 await persistEntries();1716 }1717 }1718 });1719 }17201721 fireSyncTrigger("vault_change");17221723 return nextEntry;1724}17251726172717281729173017311732export async function consolidatePasskeyEntries(): Promise<void> {1733 if (!derivedKey) return;1734 const { merged } = mergePasskeyOnlyIntoLogins(entries);1735 if (merged) {1736 await persistEntries();1737 }1738}17391740export async function updateEntry(1741 id: string,1742 patch: Partial<VaultEntry>1743): Promise<void> {1744 if (!derivedKey) throw new Error("vault_locked");17451746 const idx = entries.findIndex((entry) => entry.id === id);1747 if (idx === -1 || entries[idx].deletedAt) {1748 throw new Error("entry_not_found");1749 }17501751 const current = entries[idx];1752 const sanitizedPatch = sanitizeEntryPatch(patch);1753 1754 1755 1756 const passwordChanged =1757 "password" in patch &&1758 typeof sanitizedPatch.password === "string" &&1759 sanitizedPatch.password !== current.password;1760 const stamp = monotonicEntryTimestamp(current.modifiedAt);1761 1762 1763 1764 1765 1766 1767 1768 const priorPassword = current.password;1769 const shouldCapture =1770 passwordChanged &&1771 typeof priorPassword === "string" &&1772 priorPassword.length > 0 &&1773 current.passwordHistory?.[0]?.password !== priorPassword;1774 const nextHistory = shouldCapture1775 ? [1776 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },1777 ...(current.passwordHistory ?? []),1778 ].slice(0, PASSWORD_HISTORY_MAX)1779 : current.passwordHistory;1780 entries[idx] = {1781 ...current,1782 ...sanitizedPatch,1783 modifiedAt: stamp,1784 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),1785 };1786178717881789 1790 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1791 entries[idx] = current;1792 throw new Error("card number is required for card entries");1793 }17941795 1796 1797 1798 1799 try {1800 await persistEntries();1801 } catch (err) {1802 entries[idx] = current;1803 throw err;1804 }18051806 1807 1808 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1809 entries[idx] = { ...entries[idx], breach: undefined };1810 await persistEntries();1811 const password = entries[idx].password;1812 if (password) {1813 checkBreach(password).then(async (count) => {1814 const i = entries.findIndex((e) => e.id === id);1815 if (i !== -1 && count > 0) {1816 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };1817 await persistEntries();1818 }1819 });1820 }1821 }18221823 fireSyncTrigger("vault_change");1824}18251826export async function deleteEntry(id: string): Promise<void> {1827 if (!derivedKey) throw new Error("vault_locked");18281829 const entry = entries.find((item) => item.id === id);1830 if (!entry || entry.deletedAt) {1831 throw new Error("entry_not_found");1832 }18331834 1835 1836 1837 const previousModifiedAt = entry.modifiedAt;1838 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1839 entry.deletedAt = stamp;1840 entry.modifiedAt = stamp;1841 1842 1843 try {1844 await persistEntries();1845 } catch (err) {1846 delete entry.deletedAt;1847 entry.modifiedAt = previousModifiedAt;1848 throw err;1849 }1850 fireSyncTrigger("vault_change");1851}18521853export function getDeletedEntries(): VaultEntry[] {1854 if (!derivedKey) return [];1855 return entries.filter((e) => !!e.deletedAt);1856}1857185818591860186118621863export async function getVaultVersion(): Promise<number> {1864 try {1865 const raw = await chrome.storage.local.get("peach_last_modified_at");1866 const v = (raw as Record<string, unknown>).peach_last_modified_at;1867 if (typeof v === "number") return v;1868 if (typeof v === "string" && v !== "") {1869 const n = Number.parseInt(v, 10);1870 return Number.isFinite(n) ? n : 0;1871 }1872 return 0;1873 } catch {1874 return 0;1875 }1876}187718781879188018811882188318841885188618871888export async function addEntryDirect(entry: VaultEntry): Promise<void> {1889 if (!derivedKey) throw new Error("vault_locked");1890 entries = [...entries, entry];1891 await persistEntries("sync_received");1892}18931894export async function updateEntryDirect(patch: VaultEntry): Promise<void> {1895 if (!derivedKey) throw new Error("vault_locked");1896 const idx = entries.findIndex((e) => e.id === patch.id);1897 if (idx === -1) throw new Error("entry_not_found");1898 entries[idx] = { ...entries[idx], ...patch };1899 await persistEntries("sync_received");1900}19011902export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {1903 if (!derivedKey) throw new Error("vault_locked");1904 const idx = entries.findIndex((e) => e.id === id);1905 if (idx === -1) throw new Error("entry_not_found");1906 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };1907 await persistEntries("sync_received");1908}1909191019111912191319141915191619171918191919201921export async function applyMergedRemoteEntries(1922 remoteEntries: VaultEntry[],1923 remoteTombstones: Record<string, number>,1924 context: { deviceName?: string } = {},1925): Promise<{ received: number; applied: number; conflicts: number }> {1926 1927 1928 1929 1930 1931 1932 if (!derivedKey) throw new Error("vault_locked");19331934 const received = remoteEntries.length + Object.keys(remoteTombstones).length;1935 let applied = 0;1936 let conflicts = 0;19371938 const byId = new Map<string, VaultEntry>();1939 for (const e of entries) byId.set(e.id, e);19401941 const alerts: SyncAlert[] = [];1942 const deviceName = context.deviceName ?? "paired device";1943 const mergeNow = Date.now();19441945 for (const remote of remoteEntries) {1946 const sanitized = sanitizeRestoredEntry(remote);1947 if (!sanitized) continue;1948 1949 1950 1951 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);1952 if (typeof sanitized.deletedAt === "number") {1953 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);1954 }1955 const tombstoneTs = remoteTombstones[sanitized.id];1956 if (tombstoneTs !== undefined && clampSyncTimestamp(tombstoneTs, mergeNow) >= sanitized.modifiedAt) {1957 continue;1958 }1959 const local = byId.get(sanitized.id);1960 if (!local) {1961 entries.push(sanitized);1962 byId.set(sanitized.id, sanitized);1963 applied += 1;1964 } else if (sanitized.modifiedAt > local.modifiedAt) {1965 const idx = entries.findIndex((e) => e.id === local.id);1966 if (idx !== -1) {1967 1968 1969 if (1970 !local.deletedAt &&1971 typeof sanitized.password === "string" &&1972 typeof local.password === "string" &&1973 sanitized.password !== local.password1974 ) {1975 alerts.push({1976 type: "REMOTE_PASSWORD_CHANGE",1977 entryId: sanitized.id,1978 entryTitle: sanitized.title,1979 deviceName,1980 timestamp: mergeNow,1981 });1982 }1983 entries[idx] = sanitized;1984 byId.set(sanitized.id, sanitized);1985 applied += 1;1986 if (!local.deletedAt) conflicts += 1;1987 }1988 }1989 }19901991 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {1992 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt)) continue;1993 1994 const deletedAt = clampSyncTimestamp(rawDeletedAt, mergeNow);1995 const local = byId.get(id);1996 if (!local) continue;1997 if (local.deletedAt && local.deletedAt >= deletedAt) continue;1998 if (deletedAt < local.modifiedAt) continue;1999 const idx = entries.findIndex((e) => e.id === id);2000 if (idx !== -1) {2001 2002 if (!local.deletedAt) {2003 alerts.push({2004 type: "REMOTE_DELETE",2005 entryId: id,2006 entryTitle: local.title,2007 deviceName,2008 timestamp: mergeNow,2009 });2010 }2011 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2012 applied += 1;2013 }2014 }20152016 if (applied > 0) {2017 await persistEntries("sync_received");2018 buildDomainIndexSync(entries);2019 }2020 2021 2022 await recordSyncAlerts(alerts);2023 return { received, applied, conflicts };2024}20252026export async function restoreEntry(id: string): Promise<void> {2027 if (!derivedKey) throw new Error("vault_locked");2028 const entry = entries.find((e) => e.id === id && e.deletedAt);2029 if (!entry) throw new Error("deleted entry not found");2030 delete entry.deletedAt;2031 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);2032 await persistEntries();2033 buildDomainIndexSync(entries);2034}20352036export async function purgeEntry(id: string): Promise<void> {2037 if (!derivedKey) throw new Error("vault_locked");2038 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2039 if (idx === -1) throw new Error("deleted entry not found");2040 entries.splice(idx, 1);2041 await persistEntries();2042}204320442045export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2046 if (!derivedKey || retentionMs === 0) return 0;2047 const cutoff = Date.now() - retentionMs;2048 const before = entries.length;2049 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2050 const purged = before - entries.length;2051 if (purged > 0) await persistEntries();2052 return purged;2053}20542055export function queryDomain(domain: string): VaultEntry[] {2056 return domainIndex.get(domain) ?? [];2057}20582059export function getEntryById(id: string): VaultEntry | undefined {2060 return entries.find((entry) => entry.id === id && !entry.deletedAt);2061}20622063async function persistSlotA(2064 reason: VaultSnapshot["reason"] = "local_save",2065): Promise<StoredVault> {2066 if (!derivedKey) throw new Error("vault_locked");20672068 const slotA = await loadSlot(SLOT_A);2069 if (!slotA) throw new Error("missing slot A");20702071 2072 2073 await takeVaultSnapshot(reason);20742075 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2076 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");20772078 2079 const nextSlotA: StoredVault = {2080 ...slotA,2081 nonce,2082 ciphertext,2083 tag,2084 2085 };2086 await writeSlot(SLOT_A, nextSlotA);20872088 2089 2090 2091 2092 2093 2094 try {2095 2096 2097 2098 if (!duressConfigured) {2099 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2100 }2101 2102 2103 2104 2105 2106 if (reason !== "sync_received") {2107 await stampLocalVaultMutation();2108 }2109 } catch (err) {2110 2111 2112 await writeSlot(SLOT_A, slotA).catch(() => {});2113 throw err;2114 }21152116 return nextSlotA;2117}21182119async function persistSlotB(): Promise<void> {2120 if (!derivedKey) throw new Error("vault_locked");21212122 const slotB = await loadSlot(SLOT_B);2123 if (!slotB) throw new Error("missing slot B");21242125 2126 2127 2128 2129 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2130 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");21312132 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2133 2134 2135 2136 try {2137 await stampLocalVaultMutation();2138 } catch (err) {2139 await writeSlot(SLOT_B, slotB).catch(() => {});2140 throw err;2141 }2142}21432144async function persistEntries(2145 reason: VaultSnapshot["reason"] = "local_save",2146): Promise<void> {2147 if (!derivedKey) throw new Error("vault_locked");21482149 if (vaultMode === "decoy") {2150 await persistSlotB();2151 } else {2152 await persistSlotA(reason);2153 }2154 buildDomainIndexSync(entries);2155}21562157function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2158 if (!Array.isArray(values)) return undefined;2159 const seen = new Set<string>();2160 const cleaned: string[] = [];2161 for (const value of values) {2162 const trimmed = typeof value === "string" ? value.trim() : "";2163 if (!trimmed) continue;2164 const key = trimmed.toLowerCase();2165 if (seen.has(key)) continue;2166 seen.add(key);2167 cleaned.push(trimmed);2168 }2169 return cleaned.length > 0 ? cleaned : undefined;2170}21712172function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2173 const title = entry.title.trim();2174 2175 2176 2177 let type: VaultEntry["type"] = entry.type ?? "login";2178 if (type === "passkey") type = "login";21792180 if (!title) throw new Error("title is required");21812182 if (entry.passkey) {2183 const p = entry.passkey;2184 if (2185 !p.rpId ||2186 !p.credentialId ||2187 !p.publicKeyCose ||2188 !p.userHandle ||2189 !p.userName ||2190 (p.algorithm !== -7 && p.algorithm !== -8)2191 ) {2192 throw new Error("invalid passkey credential");2193 }2194 }2195 if (type === "card") {2196 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2197 throw new Error("cardholder name and card number are required");2198 }2199 }22002201 return {2202 type,2203 title,2204 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2205 email: type === "login" ? entry.email?.trim() || undefined : undefined,2206 password: type === "login" ? entry.password : undefined,2207 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2208 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2209 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2210 notes: entry.notes?.trim() || undefined,2211 breach: type === "login" ? entry.breach : undefined,2212 canary: type === "login" ? entry.canary : undefined,2213 tags: entry.tags,2214 passkey: entry.passkey,2215 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2216 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2217 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2218 expYear: type === "card" ? entry.expYear?.trim() : undefined,2219 cvc: type === "card" ? entry.cvc?.trim() : undefined,2220 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2221 network: type === "card" ? entry.network : undefined,2222 last4: type === "card" ? entry.last4?.trim() : undefined,2223 };2224}22252226function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2227 const nextPatch: Partial<VaultEntry> = {};22282229 if ("type" in patch) {2230 nextPatch.type = patch.type;2231 }22322233 if ("title" in patch) {2234 const title = patch.title?.trim() ?? "";2235 if (!title) throw new Error("title is required");2236 nextPatch.title = title;2237 }22382239 if ("url" in patch) {2240 nextPatch.url = patch.url?.trim() ?? "";2241 }22422243 if ("additionalUrls" in patch) {2244 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2245 const seen = new Set<string>();2246 const cleaned: string[] = [];2247 for (const value of incoming) {2248 const trimmed = typeof value === "string" ? value.trim() : "";2249 if (!trimmed) continue;2250 const key = trimmed.toLowerCase();2251 if (seen.has(key)) continue;2252 seen.add(key);2253 cleaned.push(trimmed);2254 }2255 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2256 }22572258 if ("username" in patch) {2259 nextPatch.username = patch.username?.trim() ?? "";2260 }22612262 if ("email" in patch) {2263 nextPatch.email = patch.email?.trim() || undefined;2264 }22652266 if ("password" in patch) {2267 const password = patch.password ?? "";2268 2269 nextPatch.password = password || undefined;2270 }22712272 if ("totp" in patch) {2273 nextPatch.totp = patch.totp?.trim() || undefined;2274 }22752276 if ("notes" in patch) {2277 nextPatch.notes = patch.notes?.trim() || undefined;2278 }22792280 if ("breach" in patch) {2281 nextPatch.breach = patch.breach;2282 }22832284 if ("canary" in patch) {2285 nextPatch.canary = patch.canary;2286 }22872288 if ("passkey" in patch) {2289 2290 2291 2292 nextPatch.passkey = patch.passkey;2293 }22942295 if ("tags" in patch) {2296 nextPatch.tags = patch.tags;2297 }22982299 if ("cardholderName" in patch) {2300 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2301 }2302 if ("cardNumber" in patch) {2303 const n = patch.cardNumber ?? "";2304 nextPatch.cardNumber = n || undefined;2305 }2306 if ("expMonth" in patch) {2307 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2308 }2309 if ("expYear" in patch) {2310 nextPatch.expYear = patch.expYear?.trim() || undefined;2311 }2312 if ("cvc" in patch) {2313 nextPatch.cvc = patch.cvc?.trim() || undefined;2314 }2315 if ("cardNickname" in patch) {2316 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2317 }2318 if ("network" in patch) {2319 nextPatch.network = patch.network;2320 }2321 if ("last4" in patch) {2322 nextPatch.last4 = patch.last4?.trim() || undefined;2323 }23242325 return nextPatch;2326}23272328function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2329 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2330 const modifiedAt =2331 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;23322333 const base: VaultEntry = {2334 id: entry.id,2335 type: "type" in entry ? entry.type : "login",2336 title: entry.title,2337 username: entry.username,2338 email: "email" in entry ? entry.email : undefined,2339 password: entry.password,2340 url: entry.url,2341 additionalUrls:2342 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2343 ? entry.additionalUrls2344 : undefined,2345 totp: entry.totp,2346 notes: entry.notes,2347 breach: entry.breach,2348 2349 2350 2351 passwordChangedAt:2352 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2353 2354 2355 2356 passwordHistory:2357 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2358 ? entry.passwordHistory2359 : undefined,2360 canary: "canary" in entry ? entry.canary : undefined,2361 passkey: "passkey" in entry ? entry.passkey : undefined,2362 createdAt,2363 modifiedAt,2364 deletedAt: entry.deletedAt,2365 tags: "tags" in entry ? entry.tags : undefined,2366 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2367 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2368 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2369 expYear: "expYear" in entry ? entry.expYear : undefined,2370 cvc: "cvc" in entry ? entry.cvc : undefined,2371 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2372 network: "network" in entry ? entry.network : undefined,2373 last4: "last4" in entry ? entry.last4 : undefined,2374 };23752376 return migrateLegacyPasskey(base, entry);2377}237823792380238123822383238423852386function migrateLegacyPasskey(2387 base: VaultEntry,2388 raw: VaultEntry | LegacyVaultEntry2389): VaultEntry {2390 if (base.passkey) return base;2391 const flat = raw as Partial<VaultEntry>;2392 const isPasskeyType = base.type === "passkey";2393 const hasFlatPasskeyFields =2394 typeof flat.rpId === "string" &&2395 typeof flat.credentialId === "string" &&2396 typeof flat.publicKeyCose === "string" &&2397 typeof flat.userHandle === "string" &&2398 typeof flat.userName === "string";2399 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2400 if (!hasFlatPasskeyFields) return base;24012402 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2403 base.passkey = {2404 rpId: flat.rpId!,2405 rpName: flat.rpName ?? flat.rpId!,2406 credentialId: flat.credentialId!,2407 publicKeyCose: flat.publicKeyCose!,2408 privateKeyJwk: flat.privateKeyJwk,2409 userHandle: flat.userHandle!,2410 userName: flat.userName!,2411 userDisplayName: flat.userDisplayName ?? flat.userName!,2412 algorithm,2413 signCount: flat.signCount ?? 0,2414 aaguid: flat.aaguid ?? "",2415 backupEligible: flat.backupEligible === true,2416 backupState: flat.backupState === true,2417 lastUsedAt: flat.lastUsedAt,2418 browser: flat.browser,2419 transports: flat.transports,2420 createdAt: base.createdAt,2421 };2422 2423 2424 2425 if (isPasskeyType) base.type = "login";2426 if (!base.username) base.username = flat.userName;2427 if (!base.url) base.url = `https://${flat.rpId}`;2428 return base;2429}2430243124322433243424352436function entryDupeKey(e: VaultEntry): string {2437 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2438 2439 2440 2441 2442 if (e.passkey) {2443 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2444 }2445 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2446}244724482449export function countDuplicates(): number {2450 const best = new Map<string, VaultEntry>();2451 let dupeCount = 0;2452 for (const e of entries.filter((e) => !e.deletedAt)) {2453 const key = entryDupeKey(e);2454 const existing = best.get(key);2455 if (!existing) {2456 best.set(key, e);2457 } else {2458 dupeCount++;2459 2460 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2461 best.set(key, e);2462 }2463 }2464 }2465 return dupeCount;2466}246724682469247024712472export async function deduplicateEntries(): Promise<number> {2473 if (!derivedKey) throw new Error("vault_locked");2474 2475 2476 if (vaultMode === "decoy") {2477 throw new Error("deduplicate disabled in decoy mode");2478 }24792480 const best = new Map<string, VaultEntry>();2481 for (const e of entries.filter((en) => !en.deletedAt)) {2482 const key = entryDupeKey(e);2483 const existing = best.get(key);2484 if (!existing) {2485 best.set(key, e);2486 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2487 best.set(key, e);2488 }2489 }24902491 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2492 let removed = 0;2493 entries = entries.map((e) => {2494 if (!e.deletedAt && !keepIds.has(e.id)) {2495 removed++;2496 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2497 }2498 return e;2499 });25002501 if (removed > 0) await persistEntries();2502 return removed;2503}250425052506250725082509251025112512const HISTORY_KEY = "peach_vault_history";2513const HISTORY_MAX = 5;25142515251625172518251925202521export async function takeVaultSnapshot(2522 reason: VaultSnapshot["reason"],2523): Promise<void> {2524 try {2525 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2526 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2527 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];25282529 2530 2531 2532 if (!slotA) return;25332534 const vault_a = JSON.stringify(slotA);2535 const vault_b = JSON.stringify(slotB ?? slotA);25362537 2538 2539 2540 let entryCount: number | null = null;2541 if (derivedKey && vaultMode === "real") {2542 try {2543 entryCount = entries.filter((e) => !e.deletedAt).length;2544 } catch {2545 entryCount = null;2546 }2547 }25482549 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2550 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2551 ? existingRaw2552 : { snapshots: [] };25532554 2555 2556 2557 const latest = history.snapshots[0];2558 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2559 return;2560 }25612562 const snapshot: VaultSnapshot = {2563 id: crypto.randomUUID(),2564 savedAt: Date.now(),2565 reason,2566 vault_a,2567 vault_b,2568 entryCount,2569 };25702571 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2572 await chrome.storage.local.set({ [HISTORY_KEY]: history });2573 } catch (err) {2574 2575 2576 2577 2578 console.warn('[Peach] takeVaultSnapshot failed:', err);2579 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2580 try {2581 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2582 } catch {2583 2584 }2585 }2586 }2587}258825892590export async function clearVaultHistory(): Promise<void> {2591 try {2592 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2593 } catch {2594 2595 }2596}259725982599export async function getVaultHistory(): Promise<VaultSnapshot[]> {2600 const raw = await chrome.storage.local.get(HISTORY_KEY);2601 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2602 if (!history || !Array.isArray(history.snapshots)) return [];2603 return history.snapshots;2604}26052606260726082609261026112612export async function restoreVaultSnapshot(2613 snapshotId: string,2614): Promise<{ ok: true } | { ok: false; error: string }> {2615 const history = await getVaultHistory();2616 const snapshot = history.find((s) => s.id === snapshotId);2617 if (!snapshot) return { ok: false, error: "snapshot_not_found" };26182619 2620 2621 2622 await takeVaultSnapshot("restore");26232624 let restoredA: StoredVault;2625 let restoredB: StoredVault;2626 try {2627 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2628 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2629 } catch {2630 return { ok: false, error: "snapshot_corrupt" };2631 }26322633 await writeSlot(SLOT_A, restoredA);2634 await writeSlot(SLOT_B, restoredB);2635 await stampLocalVaultMutation();26362637 2638 2639 2640 lock();26412642 return { ok: true };2643}26442645264626472648264926502651export async function changePassword(2652 currentPassword: string,2653 newPassword: string2654): Promise<boolean> {2655 if (!derivedKey || vaultMode !== "real") return false;26562657 2658 const valid = await verifyMainPassword(currentPassword);2659 if (!valid) return false;26602661 const slotA = await loadSlot(SLOT_A);2662 if (!slotA) return false;2663 if (!rootKey) return false;26642665 2666 const newSalt = generateSalt();2667 const newKey = await deriveKey(newPassword, newSalt);26682669 2670 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2671 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");26722673 2674 2675 2676 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);26772678 const finalSlotA: StoredVault = {2679 ...slotA,2680 salt: hex(newSalt),2681 nonce,2682 ciphertext,2683 tag,2684 ...pwRoot,2685 };26862687 2688 2689 2690 await takeVaultSnapshot("local_save");26912692 2693 2694 2695 2696 await writeSlot(SLOT_A, finalSlotA);26972698 2699 2700 2701 zeroKey(derivedKey);2702 derivedKey = newKey;27032704 2705 2706 2707 if (!duressConfigured) {2708 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2709 }2710 await stampLocalVaultMutation();27112712 return true;2713}