123456789101112import { deriveKey, deriveKeyRaw, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import { clampSyncTimestamp } from "../shared/sync-limits";15import { recordSyncAlerts, type SyncAlert } from "./sync-alerts";16import {17 rootKeyToWords,18 wordsToRootKeyMaterial,19 deriveRecoveryKey,20} from "../shared/recovery-words";21import type {22 AccentColorName,23 Canary,24 LegacyVaultEntry,25 ManualSummonRecord,26 PasskeyData,27 SavedIdentity,28 StoredVault,29 VaultEntry,30 VaultHistory,31 VaultSnapshot,32} from "../shared/types";33import { decrypt, encrypt, zeroKey } from "./aes";34import { clearAutoLock, touchAutoLock } from "./alarms";35import { setRecoveryConfirmPending } from "./recovery-pending";3637type EntryDraft = Omit<38 VaultEntry,39 "id" | "createdAt" | "modifiedAt" | "deletedAt"40>;414243444546const SLOT_A = "peach_vault_a";47const SLOT_B = "peach_vault_b";484950const LEGACY_MAIN = "peach_vault";51const LEGACY_DECOY = "peach_decoy_vault";52const LEGACY_DURESS_FLAG = "peach_duress";53545556const PAD_BLOCK = 1024;5758596061const PASSWORD_HISTORY_MAX = 50;6263646566676869707172const DURESS_SLOT_SIZE = 262_144;7374let derivedKey: Uint8Array | null = null;7576777879let rootKey: Uint8Array | null = null;80let entries: VaultEntry[] = [];81let domainIndex: Map<string, VaultEntry[]> = new Map();82let vaultMode: "real" | "decoy" = "real";83848586let duressConfigured = false;8788let migrationRan = false;8990919293949596979899let lastUnlockFailureReason: string | null = null;100101export function consumeLastUnlockFailureReason(): string | null {102 const r = lastUnlockFailureReason;103 lastUnlockFailureReason = null;104 return r;105}106107108109110111export type TextSize = "small" | "medium" | "large";112113export interface VaultSettings {114 accentColor?: AccentColorName;115 theme?: "dark" | "light";116 autoLockMinutes?: number;117 clipboardClearSeconds?: number;118 lockOnClose?: boolean;119 breachMonitoring?: boolean;120 passkeyProviderEnabled?: boolean;121 trashRetentionDays?: number;122 manualSummonDomains?: Record<string, ManualSummonRecord>;123 declinedCardFingerprints?: string[];124 125126127128129 textSize?: TextSize;130 131132133134135 reduceMotion?: boolean;136 137138139140141 biometricsEnabled?: boolean;142 143144145146147148149 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;150}151152let settings: VaultSettings = {};153154export function getSettings(): VaultSettings {155 return settings;156}157158export async function ensureSettingsLoaded(): Promise<VaultSettings> {159 await loadSettings();160 return settings;161}162163export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {164 await loadSettings();165 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 1074 1075 1076 if (await attemptUnlockWithDerive(password, deriveKey)) return true;10771078 const nfc = password.normalize("NFC");1079 if (password === nfc) return false; 10801081 if (await attemptUnlockWithDerive(password, deriveKeyRaw)) {1082 1083 1084 1085 if (vaultMode === "real") {1086 try {1087 const newSalt = generateSalt();1088 const newKey = await deriveKey(password, newSalt);1089 await changePasswordWithNewKey(newSalt, newKey);1090 } catch (healErr) {1091 console.warn("[Peach] LV-1 NFC heal failed:", healErr);1092 }1093 }1094 return true;1095 }1096 return false;1097}10981099async function attemptUnlockWithDerive(1100 password: string,1101 derive: typeof deriveKey,1102): Promise<boolean> {1103 await ensureMigrated();11041105 1106 1107 1108 1109 1110 const [slotA, slotB] = await Promise.all([1111 loadSlot(SLOT_A),1112 loadSlot(SLOT_B),1113 ]);11141115 1116 const [keyA, keyB] = await Promise.all([1117 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1118 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1119 ]);11201121 1122 1123 1124 const [resultA, resultB] = await Promise.all([1125 keyA && slotA1126 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1127 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1128 keyB && slotB1129 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1130 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1131 ]);1132 const payloadA = resultA.status === "ok" ? resultA.payload : null;1133 const payloadB = resultB.status === "ok" ? resultB.payload : null;11341135 if (payloadA && keyA) {1136 derivedKey = keyA;1137 void saveSessionKey(keyA, "real");1138 vaultMode = "real";1139 entries = payloadA.entries;1140 duressConfigured = payloadA.duressConfigured;1141 buildDomainIndexSync(entries);1142 await loadSettings();1143 touchAutoLock();1144 if (keyB) zeroKey(keyB);11451146 1147 if (slotA) {1148 const needsRecoverySetup =1149 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1150 if (!needsRecoverySetup) {1151 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1152 }1153 1154 1155 1156 1157 if (needsRecoverySetup || !rootKey) {1158 try {1159 await migrateToRecovery();1160 if (needsRecoverySetup) {1161 1162 1163 1164 await chrome.storage.local.set({ peach_recovery_pending: '1' });1165 }1166 } catch (migrationErr) {1167 console.warn('[Peach] recovery migration failed:', migrationErr);1168 }1169 }1170 }11711172 1173 1174 1175 if (payloadA.migratedFromLegacy) {1176 await persistSlotA();1177 }1178 return true;1179 }11801181 if (payloadB && keyB) {1182 derivedKey = keyB;1183 void saveSessionKey(keyB, "decoy");1184 vaultMode = "decoy";1185 entries = payloadB.entries;1186 duressConfigured = false;1187 buildDomainIndexSync(entries);1188 await loadSettings();1189 touchAutoLock();1190 if (keyA) zeroKey(keyA);1191 return true;1192 }11931194 if (keyA) zeroKey(keyA);1195 if (keyB) zeroKey(keyB);1196 1197 1198 if (resultA.status === "corrupted") {1199 lastUnlockFailureReason = resultA.reason;1200 } else if (resultB.status === "corrupted") {1201 lastUnlockFailureReason = resultB.reason;1202 }1203 return false;1204}12051206120712081209121012111212const SESSION_KEY_STORAGE = "peach_session_dk";12131214async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1215 if (!chrome.storage.session) return;1216 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1217}12181219async function clearSessionKey(): Promise<void> {1220 if (!chrome.storage.session) return;1221 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1222}12231224122512261227122812291230export async function restoreFromSession(): Promise<boolean> {1231 if (derivedKey !== null) return true;1232 if (!chrome.storage.session) return false;1233 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1234 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1235 if (!entry?.keyHex) return false;12361237 const key = unhex(entry.keyHex);1238 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1239 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1240 const slot = await loadSlot(slotKey);1241 if (!slot) { await clearSessionKey(); return false; }12421243 const result = await tryDecryptSlotResult(slot, key, aadTag);1244 if (result.status !== "ok") { await clearSessionKey(); return false; }12451246 derivedKey = key;1247 vaultMode = entry.mode;1248 entries = result.payload.entries;1249 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1250 buildDomainIndexSync(entries);1251 await loadSettings();1252 touchAutoLock();12531254 if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1255 try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { }1256 }12571258 return true;1259}12601261export function lock(): void {1262 void clearSessionKey();1263 if (derivedKey) {1264 zeroKey(derivedKey);1265 derivedKey = null;1266 }1267 if (rootKey) {1268 rootKey.fill(0);1269 rootKey = null;1270 }12711272 entries = [];1273 domainIndex = new Map();1274 vaultMode = "real";1275 duressConfigured = false;1276 clearAutoLock();1277}12781279export function isUnlocked(): boolean {1280 return derivedKey !== null;1281}1282128312841285export async function unlockWithKey(1286 masterKeyHex: string,1287): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1288 try {1289 const masterKeyBytes = unhex(masterKeyHex);1290 const stored = await loadSlot(SLOT_A);1291 if (!stored) return { ok: false, error: "invalid_key" };1292 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1293 if (!payload) return { ok: false, error: "invalid_key" };12941295 derivedKey = masterKeyBytes;1296 vaultMode = "real";1297 entries = payload.entries;1298 duressConfigured = payload.duressConfigured;1299 buildDomainIndexSync(entries);1300 await loadSettings();1301 touchAutoLock();1302 return { ok: true, entries };1303 } catch {1304 return { ok: false, error: "invalid_key" };1305 }1306}13071308130913101311131213131314131513161317131813191320132113221323132413251326export async function unlockFromCodex(params: {1327 slotA: string; 1328 slotB: string | null; 1329 timestamp: number;1330 masterPassphrase: string;1331}): Promise<1332 | { ok: true; entryCount: number }1333 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1334> {1335 let parsedSlotA: StoredVault;1336 let parsedSlotB: StoredVault | null = null;1337 try {1338 const rawA = JSON.parse(params.slotA) as unknown;1339 if (!hasValidVaultSchema(rawA)) {1340 return { ok: false, reason: "invalid_slot_format" };1341 }1342 parsedSlotA = rawA;1343 if (params.slotB !== null) {1344 const rawB = JSON.parse(params.slotB) as unknown;1345 if (!hasValidVaultSchema(rawB)) {1346 return { ok: false, reason: "invalid_slot_format" };1347 }1348 parsedSlotB = rawB;1349 }1350 } catch {1351 return { ok: false, reason: "invalid_slot_format" };1352 }13531354 1355 1356 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1357 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1358 if (!trialPayload) {1359 zeroKey(trialKey);1360 return { ok: false, reason: "wrong_passphrase" };1361 }1362 1363 1364 zeroKey(trialKey);13651366 1367 1368 await takeVaultSnapshot("restore");13691370 await writeSlot(SLOT_A, parsedSlotA);1371 if (parsedSlotB) {1372 await writeSlot(SLOT_B, parsedSlotB);1373 } else {1374 1375 1376 1377 await writeSlot(1378 SLOT_B,1379 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1380 );1381 }1382 await chrome.storage.local.set({1383 peach_last_modified_at: String(params.timestamp),1384 });13851386 1387 1388 const ok = await unlock(params.masterPassphrase);1389 if (!ok) {1390 1391 1392 1393 return { ok: false, reason: "wrong_passphrase" };1394 }1395 return { ok: true, entryCount: getEntries().length };1396}1397139813991400export function getDerivedKeyHex(): string {1401 if (!derivedKey) throw new Error("vault_not_unlocked");1402 return hex(derivedKey);1403}14041405140614071408140914101411141214131414141514161417141814191420export function getRootKeyHex(): string | null {1421 return rootKey ? hex(rootKey) : null;1422}14231424export function getEntries(): VaultEntry[] {1425 if (!derivedKey) throw new Error("vault_locked");1426 return entries.filter((entry) => !entry.deletedAt);1427}14281429const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;14301431export function getIdentities(): SavedIdentity[] {1432 if (!derivedKey) throw new Error("vault_locked");1433 const active = entries.filter((e) => !e.deletedAt && e.type === "login");14341435 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1436 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();14371438 for (const entry of active) {1439 const domain = extractDomain(entry.url ?? "") ?? "";1440 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;14411442 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1443 const existing = map.get(v);1444 if (!existing || modifiedAt > existing.lastUsed) {1445 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1446 } else {1447 map.set(v, { ...existing, count: existing.count + 1 });1448 }1449 }14501451 const uVal = entry.username?.trim();1452 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);14531454 1455 1456 const eVal = entry.email?.trim();1457 if (eVal) tally(emailMap, eVal);1458 }14591460 const toIdentity = (type: "email" | "username") =>1461 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1462 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1463 });14641465 return [1466 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1467 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1468 ];1469}14701471export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1472 if (!derivedKey) throw new Error("vault_locked");14731474 const now = Date.now();1475 const added: VaultEntry[] = [];1476 for (const draft of drafts) {1477 try {1478 const sanitized = sanitizeEntryDraft(draft);1479 const nextEntry: VaultEntry = {1480 ...sanitized,1481 id: crypto.randomUUID(),1482 createdAt: now,1483 modifiedAt: monotonicEntryTimestamp(),1484 };1485 entries.push(nextEntry);1486 added.push(nextEntry);1487 } catch {1488 1489 }1490 }14911492 if (added.length > 0) {1493 await persistEntries();1494 }14951496 return added;1497}149814991500150115021503150415051506export async function restoreEntries(1507 incoming: VaultEntry[],1508 mode: "replace" | "merge"1509): Promise<{ added: number; updated: number; skipped: number }> {1510 if (!derivedKey) throw new Error("vault_locked");15111512 1513 1514 1515 if (mode === "replace" && incoming.length === 0) {1516 throw new Error("refuse to replace with empty entry set");1517 }15181519 let added = 0;1520 let updated = 0;1521 let skipped = 0;15221523 if (mode === "replace") {1524 entries = [];1525 for (const entry of incoming) {1526 const normalized = sanitizeRestoredEntry(entry);1527 if (!normalized) {1528 skipped += 1;1529 continue;1530 }1531 entries.push(normalized);1532 added += 1;1533 }1534 } else {1535 const byId = new Map<string, VaultEntry>();1536 for (const e of entries) byId.set(e.id, e);15371538 for (const entry of incoming) {1539 const normalized = sanitizeRestoredEntry(entry);1540 if (!normalized) {1541 skipped += 1;1542 continue;1543 }1544 const existing = byId.get(normalized.id);1545 if (!existing) {1546 entries.push(normalized);1547 added += 1;1548 } else if (normalized.modifiedAt > existing.modifiedAt) {1549 const idx = entries.findIndex((e) => e.id === existing.id);1550 if (idx !== -1) {1551 entries[idx] = normalized;1552 updated += 1;1553 }1554 } else {1555 skipped += 1;1556 }1557 }1558 }15591560 await persistEntries("import");1561 return { added, updated, skipped };1562}156315641565156615671568156915701571function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1572 if (!entry || typeof entry !== "object") return null;1573 const title = typeof entry.title === "string" ? entry.title.trim() : "";1574 if (!title) return null;1575 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1576 const now = Date.now();1577 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1578 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;15791580 1581 1582 1583 1584 const normalized = normalizeEntry({1585 ...entry,1586 id,1587 title,1588 type: entry.type ?? "login",1589 createdAt,1590 modifiedAt,1591 });15921593 if (normalized.passkey) {1594 normalized.passkey = sanitizePasskey(normalized.passkey);1595 }1596 if (normalized.canary) {1597 normalized.canary = sanitizeCanary(normalized.canary);1598 }15991600 1601 1602 if (1603 normalized.type === "login" &&1604 !normalized.passkey &&1605 typeof normalized.password !== "string"1606 ) {1607 return null;1608 }1609 if (entry.passkey && !normalized.passkey) {1610 1611 1612 return null;1613 }16141615 return normalized;1616}1617161816191620162116221623function sanitizePasskey(pk: unknown): PasskeyData | undefined {1624 if (!pk || typeof pk !== "object") return undefined;1625 const r = pk as Record<string, unknown>;1626 if (1627 typeof r.rpId !== "string" || !r.rpId ||1628 typeof r.credentialId !== "string" || !r.credentialId ||1629 typeof r.publicKeyCose !== "string" ||1630 typeof r.userHandle !== "string" ||1631 typeof r.userName !== "string"1632 ) {1633 return undefined;1634 }1635 return {1636 rpId: r.rpId,1637 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1638 credentialId: r.credentialId,1639 publicKeyCose: r.publicKeyCose,1640 privateKeyJwk:1641 r.privateKeyJwk && typeof r.privateKeyJwk === "object"1642 ? (r.privateKeyJwk as JsonWebKey)1643 : undefined,1644 userHandle: r.userHandle,1645 userName: r.userName,1646 userDisplayName:1647 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1648 algorithm: r.algorithm === -8 ? -8 : -7,1649 signCount: typeof r.signCount === "number" ? r.signCount : 0,1650 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1651 backupEligible: r.backupEligible === true,1652 backupState: r.backupState === true,1653 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1654 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1655 transports: Array.isArray(r.transports)1656 ? r.transports.filter((t): t is string => typeof t === "string")1657 : undefined,1658 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1659 };1660}16611662166316641665166616671668166916701671function sanitizeCanary(c: unknown): Canary | undefined {1672 if (!c || typeof c !== "object") return undefined;1673 const r = c as Record<string, unknown>;1674 if (typeof r.webhookUrl !== "string") return undefined;1675 let url: URL;1676 try {1677 url = new URL(r.webhookUrl);1678 } catch {1679 return undefined;1680 }1681 if (url.protocol !== "https:") return undefined;1682 return {1683 webhookUrl: url.toString(),1684 label: typeof r.label === "string" ? r.label : undefined,1685 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,1686 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,1687 };1688}168916901691169216931694export async function clearVaultEntries(1695 confirmation: "REPLACE_ALL"1696): Promise<void> {1697 if (confirmation !== "REPLACE_ALL") {1698 throw new Error("clearVaultEntries requires explicit confirmation");1699 }1700 if (!derivedKey) throw new Error("vault_locked");1701 for (const entry of entries) {1702 if (!entry.deletedAt) {1703 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1704 entry.deletedAt = stamp;1705 entry.modifiedAt = stamp;1706 }1707 }1708 await persistEntries();1709}17101711export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1712 if (!derivedKey) throw new Error("vault_locked");17131714 const sanitized = sanitizeEntryDraft(entry);1715 const now = Date.now();1716 const stamp = monotonicEntryTimestamp();1717 const nextEntry: VaultEntry = {1718 ...sanitized,1719 id: crypto.randomUUID(),1720 createdAt: now,1721 modifiedAt: stamp,1722 1723 1724 passwordChangedAt: sanitized.password ? stamp : undefined,1725 };17261727 1728 1729 1730 const rollback = entries;1731 entries = [...entries, nextEntry];1732 try {1733 await persistEntries();1734 } catch (err) {1735 entries = rollback;1736 throw err;1737 }17381739 1740 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1741 checkBreach(nextEntry.password).then(async (count) => {1742 if (count > 0) {1743 const idx = entries.findIndex((e) => e.id === nextEntry.id);1744 if (idx !== -1) {1745 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };1746 await persistEntries();1747 }1748 }1749 });1750 }17511752 fireSyncTrigger("vault_change");17531754 return nextEntry;1755}17561757175817591760176117621763export async function consolidatePasskeyEntries(): Promise<void> {1764 if (!derivedKey) return;1765 const { merged } = mergePasskeyOnlyIntoLogins(entries);1766 if (merged) {1767 await persistEntries();1768 }1769}17701771export async function updateEntry(1772 id: string,1773 patch: Partial<VaultEntry>1774): Promise<void> {1775 if (!derivedKey) throw new Error("vault_locked");17761777 const idx = entries.findIndex((entry) => entry.id === id);1778 if (idx === -1 || entries[idx].deletedAt) {1779 throw new Error("entry_not_found");1780 }17811782 const current = entries[idx];1783 const sanitizedPatch = sanitizeEntryPatch(patch);1784 1785 1786 1787 const passwordChanged =1788 "password" in patch &&1789 typeof sanitizedPatch.password === "string" &&1790 sanitizedPatch.password !== current.password;1791 const stamp = monotonicEntryTimestamp(current.modifiedAt);1792 1793 1794 1795 1796 1797 1798 1799 const priorPassword = current.password;1800 const shouldCapture =1801 passwordChanged &&1802 typeof priorPassword === "string" &&1803 priorPassword.length > 0 &&1804 current.passwordHistory?.[0]?.password !== priorPassword;1805 const nextHistory = shouldCapture1806 ? [1807 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },1808 ...(current.passwordHistory ?? []),1809 ].slice(0, PASSWORD_HISTORY_MAX)1810 : current.passwordHistory;1811 entries[idx] = {1812 ...current,1813 ...sanitizedPatch,1814 modifiedAt: stamp,1815 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),1816 };1817181818191820 1821 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1822 entries[idx] = current;1823 throw new Error("card number is required for card entries");1824 }18251826 1827 1828 1829 1830 try {1831 await persistEntries();1832 } catch (err) {1833 entries[idx] = current;1834 throw err;1835 }18361837 1838 1839 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1840 entries[idx] = { ...entries[idx], breach: undefined };1841 await persistEntries();1842 const password = entries[idx].password;1843 if (password) {1844 checkBreach(password).then(async (count) => {1845 const i = entries.findIndex((e) => e.id === id);1846 if (i !== -1 && count > 0) {1847 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };1848 await persistEntries();1849 }1850 });1851 }1852 }18531854 fireSyncTrigger("vault_change");1855}18561857export async function deleteEntry(id: string): Promise<void> {1858 if (!derivedKey) throw new Error("vault_locked");18591860 const entry = entries.find((item) => item.id === id);1861 if (!entry || entry.deletedAt) {1862 throw new Error("entry_not_found");1863 }18641865 1866 1867 1868 const previousModifiedAt = entry.modifiedAt;1869 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1870 entry.deletedAt = stamp;1871 entry.modifiedAt = stamp;1872 1873 1874 try {1875 await persistEntries();1876 } catch (err) {1877 delete entry.deletedAt;1878 entry.modifiedAt = previousModifiedAt;1879 throw err;1880 }1881 fireSyncTrigger("vault_change");1882}18831884export function getDeletedEntries(): VaultEntry[] {1885 if (!derivedKey) return [];1886 return entries.filter((e) => !!e.deletedAt);1887}1888188918901891189218931894export async function getVaultVersion(): Promise<number> {1895 try {1896 const raw = await chrome.storage.local.get("peach_last_modified_at");1897 const v = (raw as Record<string, unknown>).peach_last_modified_at;1898 if (typeof v === "number") return v;1899 if (typeof v === "string" && v !== "") {1900 const n = Number.parseInt(v, 10);1901 return Number.isFinite(n) ? n : 0;1902 }1903 return 0;1904 } catch {1905 return 0;1906 }1907}190819091910191119121913191419151916191719181919export async function addEntryDirect(entry: VaultEntry): Promise<void> {1920 if (!derivedKey) throw new Error("vault_locked");1921 entries = [...entries, entry];1922 await persistEntries("sync_received");1923}19241925export async function updateEntryDirect(patch: VaultEntry): Promise<void> {1926 if (!derivedKey) throw new Error("vault_locked");1927 const idx = entries.findIndex((e) => e.id === patch.id);1928 if (idx === -1) throw new Error("entry_not_found");1929 entries[idx] = { ...entries[idx], ...patch };1930 await persistEntries("sync_received");1931}19321933export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {1934 if (!derivedKey) throw new Error("vault_locked");1935 const idx = entries.findIndex((e) => e.id === id);1936 if (idx === -1) throw new Error("entry_not_found");1937 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };1938 await persistEntries("sync_received");1939}1940194119421943194419451946194719481949195019511952export async function applyMergedRemoteEntries(1953 remoteEntries: VaultEntry[],1954 remoteTombstones: Record<string, number>,1955 context: { deviceName?: string } = {},1956): Promise<{ received: number; applied: number; conflicts: number }> {1957 1958 1959 1960 1961 1962 1963 if (!derivedKey) throw new Error("vault_locked");19641965 const received = remoteEntries.length + Object.keys(remoteTombstones).length;1966 let applied = 0;1967 let conflicts = 0;19681969 const byId = new Map<string, VaultEntry>();1970 for (const e of entries) byId.set(e.id, e);19711972 const alerts: SyncAlert[] = [];1973 const deviceName = context.deviceName ?? "paired device";1974 const mergeNow = Date.now();19751976 for (const remote of remoteEntries) {1977 const sanitized = sanitizeRestoredEntry(remote);1978 if (!sanitized) continue;1979 1980 1981 1982 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);1983 if (typeof sanitized.deletedAt === "number") {1984 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);1985 }1986 const tombstoneTs = remoteTombstones[sanitized.id];1987 if (tombstoneTs !== undefined && clampSyncTimestamp(tombstoneTs, mergeNow) >= sanitized.modifiedAt) {1988 continue;1989 }1990 const local = byId.get(sanitized.id);1991 if (!local) {1992 entries.push(sanitized);1993 byId.set(sanitized.id, sanitized);1994 applied += 1;1995 } else if (sanitized.modifiedAt > local.modifiedAt) {1996 const idx = entries.findIndex((e) => e.id === local.id);1997 if (idx !== -1) {1998 1999 2000 if (2001 !local.deletedAt &&2002 typeof sanitized.password === "string" &&2003 typeof local.password === "string" &&2004 sanitized.password !== local.password2005 ) {2006 alerts.push({2007 type: "REMOTE_PASSWORD_CHANGE",2008 entryId: sanitized.id,2009 entryTitle: sanitized.title,2010 deviceName,2011 timestamp: mergeNow,2012 });2013 }2014 entries[idx] = sanitized;2015 byId.set(sanitized.id, sanitized);2016 applied += 1;2017 if (!local.deletedAt) conflicts += 1;2018 }2019 }2020 }20212022 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2023 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt)) continue;2024 2025 const deletedAt = clampSyncTimestamp(rawDeletedAt, mergeNow);2026 const local = byId.get(id);2027 if (!local) continue;2028 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2029 if (deletedAt < local.modifiedAt) continue;2030 const idx = entries.findIndex((e) => e.id === id);2031 if (idx !== -1) {2032 2033 if (!local.deletedAt) {2034 alerts.push({2035 type: "REMOTE_DELETE",2036 entryId: id,2037 entryTitle: local.title,2038 deviceName,2039 timestamp: mergeNow,2040 });2041 }2042 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2043 applied += 1;2044 }2045 }20462047 if (applied > 0) {2048 await persistEntries("sync_received");2049 buildDomainIndexSync(entries);2050 }2051 2052 2053 await recordSyncAlerts(alerts);2054 return { received, applied, conflicts };2055}20562057export async function restoreEntry(id: string): Promise<void> {2058 if (!derivedKey) throw new Error("vault_locked");2059 const entry = entries.find((e) => e.id === id && e.deletedAt);2060 if (!entry) throw new Error("deleted entry not found");2061 delete entry.deletedAt;2062 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);2063 await persistEntries();2064 buildDomainIndexSync(entries);2065}20662067export async function purgeEntry(id: string): Promise<void> {2068 if (!derivedKey) throw new Error("vault_locked");2069 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2070 if (idx === -1) throw new Error("deleted entry not found");2071 entries.splice(idx, 1);2072 await persistEntries();2073}207420752076export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2077 if (!derivedKey || retentionMs === 0) return 0;2078 const cutoff = Date.now() - retentionMs;2079 const before = entries.length;2080 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2081 const purged = before - entries.length;2082 if (purged > 0) await persistEntries();2083 return purged;2084}20852086export function queryDomain(domain: string): VaultEntry[] {2087 return domainIndex.get(domain) ?? [];2088}20892090export function getEntryById(id: string): VaultEntry | undefined {2091 return entries.find((entry) => entry.id === id && !entry.deletedAt);2092}20932094async function persistSlotA(2095 reason: VaultSnapshot["reason"] = "local_save",2096): Promise<StoredVault> {2097 if (!derivedKey) throw new Error("vault_locked");20982099 const slotA = await loadSlot(SLOT_A);2100 if (!slotA) throw new Error("missing slot A");21012102 2103 2104 await takeVaultSnapshot(reason);21052106 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2107 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");21082109 2110 const nextSlotA: StoredVault = {2111 ...slotA,2112 nonce,2113 ciphertext,2114 tag,2115 2116 };2117 await writeSlot(SLOT_A, nextSlotA);21182119 2120 2121 2122 2123 2124 2125 try {2126 2127 2128 2129 if (!duressConfigured) {2130 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2131 }2132 2133 2134 2135 2136 2137 if (reason !== "sync_received") {2138 await stampLocalVaultMutation();2139 }2140 } catch (err) {2141 2142 2143 await writeSlot(SLOT_A, slotA).catch(() => {});2144 throw err;2145 }21462147 return nextSlotA;2148}21492150async function persistSlotB(): Promise<void> {2151 if (!derivedKey) throw new Error("vault_locked");21522153 const slotB = await loadSlot(SLOT_B);2154 if (!slotB) throw new Error("missing slot B");21552156 2157 2158 2159 2160 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2161 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");21622163 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2164 2165 2166 2167 try {2168 await stampLocalVaultMutation();2169 } catch (err) {2170 await writeSlot(SLOT_B, slotB).catch(() => {});2171 throw err;2172 }2173}21742175async function persistEntries(2176 reason: VaultSnapshot["reason"] = "local_save",2177): Promise<void> {2178 if (!derivedKey) throw new Error("vault_locked");21792180 if (vaultMode === "decoy") {2181 await persistSlotB();2182 } else {2183 await persistSlotA(reason);2184 }2185 buildDomainIndexSync(entries);2186}21872188function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2189 if (!Array.isArray(values)) return undefined;2190 const seen = new Set<string>();2191 const cleaned: string[] = [];2192 for (const value of values) {2193 const trimmed = typeof value === "string" ? value.trim() : "";2194 if (!trimmed) continue;2195 const key = trimmed.toLowerCase();2196 if (seen.has(key)) continue;2197 seen.add(key);2198 cleaned.push(trimmed);2199 }2200 return cleaned.length > 0 ? cleaned : undefined;2201}22022203function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2204 const title = entry.title.trim();2205 2206 2207 2208 let type: VaultEntry["type"] = entry.type ?? "login";2209 if (type === "passkey") type = "login";22102211 if (!title) throw new Error("title is required");22122213 if (entry.passkey) {2214 const p = entry.passkey;2215 if (2216 !p.rpId ||2217 !p.credentialId ||2218 !p.publicKeyCose ||2219 !p.userHandle ||2220 !p.userName ||2221 (p.algorithm !== -7 && p.algorithm !== -8)2222 ) {2223 throw new Error("invalid passkey credential");2224 }2225 }2226 if (type === "card") {2227 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2228 throw new Error("cardholder name and card number are required");2229 }2230 }22312232 return {2233 type,2234 title,2235 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2236 email: type === "login" ? entry.email?.trim() || undefined : undefined,2237 password: type === "login" ? entry.password : undefined,2238 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2239 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2240 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2241 notes: entry.notes?.trim() || undefined,2242 breach: type === "login" ? entry.breach : undefined,2243 canary: type === "login" ? entry.canary : undefined,2244 tags: entry.tags,2245 passkey: entry.passkey,2246 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2247 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2248 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2249 expYear: type === "card" ? entry.expYear?.trim() : undefined,2250 cvc: type === "card" ? entry.cvc?.trim() : undefined,2251 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2252 network: type === "card" ? entry.network : undefined,2253 last4: type === "card" ? entry.last4?.trim() : undefined,2254 };2255}22562257function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2258 const nextPatch: Partial<VaultEntry> = {};22592260 if ("type" in patch) {2261 nextPatch.type = patch.type;2262 }22632264 if ("title" in patch) {2265 const title = patch.title?.trim() ?? "";2266 if (!title) throw new Error("title is required");2267 nextPatch.title = title;2268 }22692270 if ("url" in patch) {2271 nextPatch.url = patch.url?.trim() ?? "";2272 }22732274 if ("additionalUrls" in patch) {2275 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2276 const seen = new Set<string>();2277 const cleaned: string[] = [];2278 for (const value of incoming) {2279 const trimmed = typeof value === "string" ? value.trim() : "";2280 if (!trimmed) continue;2281 const key = trimmed.toLowerCase();2282 if (seen.has(key)) continue;2283 seen.add(key);2284 cleaned.push(trimmed);2285 }2286 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2287 }22882289 if ("username" in patch) {2290 nextPatch.username = patch.username?.trim() ?? "";2291 }22922293 if ("email" in patch) {2294 nextPatch.email = patch.email?.trim() || undefined;2295 }22962297 if ("password" in patch) {2298 const password = patch.password ?? "";2299 2300 nextPatch.password = password || undefined;2301 }23022303 if ("totp" in patch) {2304 nextPatch.totp = patch.totp?.trim() || undefined;2305 }23062307 if ("notes" in patch) {2308 nextPatch.notes = patch.notes?.trim() || undefined;2309 }23102311 if ("breach" in patch) {2312 nextPatch.breach = patch.breach;2313 }23142315 if ("canary" in patch) {2316 nextPatch.canary = patch.canary;2317 }23182319 if ("passkey" in patch) {2320 2321 2322 2323 nextPatch.passkey = patch.passkey;2324 }23252326 if ("tags" in patch) {2327 nextPatch.tags = patch.tags;2328 }23292330 if ("cardholderName" in patch) {2331 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2332 }2333 if ("cardNumber" in patch) {2334 const n = patch.cardNumber ?? "";2335 nextPatch.cardNumber = n || undefined;2336 }2337 if ("expMonth" in patch) {2338 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2339 }2340 if ("expYear" in patch) {2341 nextPatch.expYear = patch.expYear?.trim() || undefined;2342 }2343 if ("cvc" in patch) {2344 nextPatch.cvc = patch.cvc?.trim() || undefined;2345 }2346 if ("cardNickname" in patch) {2347 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2348 }2349 if ("network" in patch) {2350 nextPatch.network = patch.network;2351 }2352 if ("last4" in patch) {2353 nextPatch.last4 = patch.last4?.trim() || undefined;2354 }23552356 return nextPatch;2357}23582359function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2360 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2361 const modifiedAt =2362 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;23632364 const base: VaultEntry = {2365 id: entry.id,2366 type: "type" in entry ? entry.type : "login",2367 title: entry.title,2368 username: entry.username,2369 email: "email" in entry ? entry.email : undefined,2370 password: entry.password,2371 url: entry.url,2372 additionalUrls:2373 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2374 ? entry.additionalUrls2375 : undefined,2376 totp: entry.totp,2377 notes: entry.notes,2378 breach: entry.breach,2379 2380 2381 2382 passwordChangedAt:2383 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2384 2385 2386 2387 passwordHistory:2388 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2389 ? entry.passwordHistory2390 : undefined,2391 canary: "canary" in entry ? entry.canary : undefined,2392 passkey: "passkey" in entry ? entry.passkey : undefined,2393 createdAt,2394 modifiedAt,2395 deletedAt: entry.deletedAt,2396 tags: "tags" in entry ? entry.tags : undefined,2397 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2398 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2399 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2400 expYear: "expYear" in entry ? entry.expYear : undefined,2401 cvc: "cvc" in entry ? entry.cvc : undefined,2402 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2403 network: "network" in entry ? entry.network : undefined,2404 last4: "last4" in entry ? entry.last4 : undefined,2405 };24062407 return migrateLegacyPasskey(base, entry);2408}240924102411241224132414241524162417function migrateLegacyPasskey(2418 base: VaultEntry,2419 raw: VaultEntry | LegacyVaultEntry2420): VaultEntry {2421 if (base.passkey) return base;2422 const flat = raw as Partial<VaultEntry>;2423 const isPasskeyType = base.type === "passkey";2424 const hasFlatPasskeyFields =2425 typeof flat.rpId === "string" &&2426 typeof flat.credentialId === "string" &&2427 typeof flat.publicKeyCose === "string" &&2428 typeof flat.userHandle === "string" &&2429 typeof flat.userName === "string";2430 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2431 if (!hasFlatPasskeyFields) return base;24322433 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2434 base.passkey = {2435 rpId: flat.rpId!,2436 rpName: flat.rpName ?? flat.rpId!,2437 credentialId: flat.credentialId!,2438 publicKeyCose: flat.publicKeyCose!,2439 privateKeyJwk: flat.privateKeyJwk,2440 userHandle: flat.userHandle!,2441 userName: flat.userName!,2442 userDisplayName: flat.userDisplayName ?? flat.userName!,2443 algorithm,2444 signCount: flat.signCount ?? 0,2445 aaguid: flat.aaguid ?? "",2446 backupEligible: flat.backupEligible === true,2447 backupState: flat.backupState === true,2448 lastUsedAt: flat.lastUsedAt,2449 browser: flat.browser,2450 transports: flat.transports,2451 createdAt: base.createdAt,2452 };2453 2454 2455 2456 if (isPasskeyType) base.type = "login";2457 if (!base.username) base.username = flat.userName;2458 if (!base.url) base.url = `https://${flat.rpId}`;2459 return base;2460}2461246224632464246524662467function entryDupeKey(e: VaultEntry): string {2468 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2469 2470 2471 2472 2473 if (e.passkey) {2474 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2475 }2476 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2477}247824792480export function countDuplicates(): number {2481 const best = new Map<string, VaultEntry>();2482 let dupeCount = 0;2483 for (const e of entries.filter((e) => !e.deletedAt)) {2484 const key = entryDupeKey(e);2485 const existing = best.get(key);2486 if (!existing) {2487 best.set(key, e);2488 } else {2489 dupeCount++;2490 2491 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2492 best.set(key, e);2493 }2494 }2495 }2496 return dupeCount;2497}249824992500250125022503export async function deduplicateEntries(): Promise<number> {2504 if (!derivedKey) throw new Error("vault_locked");2505 2506 2507 if (vaultMode === "decoy") {2508 throw new Error("deduplicate disabled in decoy mode");2509 }25102511 const best = new Map<string, VaultEntry>();2512 for (const e of entries.filter((en) => !en.deletedAt)) {2513 const key = entryDupeKey(e);2514 const existing = best.get(key);2515 if (!existing) {2516 best.set(key, e);2517 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2518 best.set(key, e);2519 }2520 }25212522 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2523 let removed = 0;2524 entries = entries.map((e) => {2525 if (!e.deletedAt && !keepIds.has(e.id)) {2526 removed++;2527 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2528 }2529 return e;2530 });25312532 if (removed > 0) await persistEntries();2533 return removed;2534}253525362537253825392540254125422543const HISTORY_KEY = "peach_vault_history";2544const HISTORY_MAX = 5;25452546254725482549255025512552export async function takeVaultSnapshot(2553 reason: VaultSnapshot["reason"],2554): Promise<void> {2555 try {2556 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2557 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2558 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];25592560 2561 2562 2563 if (!slotA) return;25642565 const vault_a = JSON.stringify(slotA);2566 const vault_b = JSON.stringify(slotB ?? slotA);25672568 2569 2570 2571 let entryCount: number | null = null;2572 if (derivedKey && vaultMode === "real") {2573 try {2574 entryCount = entries.filter((e) => !e.deletedAt).length;2575 } catch {2576 entryCount = null;2577 }2578 }25792580 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2581 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2582 ? existingRaw2583 : { snapshots: [] };25842585 2586 2587 2588 const latest = history.snapshots[0];2589 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2590 return;2591 }25922593 const snapshot: VaultSnapshot = {2594 id: crypto.randomUUID(),2595 savedAt: Date.now(),2596 reason,2597 vault_a,2598 vault_b,2599 entryCount,2600 };26012602 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2603 await chrome.storage.local.set({ [HISTORY_KEY]: history });2604 } catch (err) {2605 2606 2607 2608 2609 console.warn('[Peach] takeVaultSnapshot failed:', err);2610 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2611 try {2612 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2613 } catch {2614 2615 }2616 }2617 }2618}261926202621export async function clearVaultHistory(): Promise<void> {2622 try {2623 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2624 } catch {2625 2626 }2627}262826292630export async function getVaultHistory(): Promise<VaultSnapshot[]> {2631 const raw = await chrome.storage.local.get(HISTORY_KEY);2632 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2633 if (!history || !Array.isArray(history.snapshots)) return [];2634 return history.snapshots;2635}26362637263826392640264126422643export async function restoreVaultSnapshot(2644 snapshotId: string,2645): Promise<{ ok: true } | { ok: false; error: string }> {2646 const history = await getVaultHistory();2647 const snapshot = history.find((s) => s.id === snapshotId);2648 if (!snapshot) return { ok: false, error: "snapshot_not_found" };26492650 2651 2652 2653 await takeVaultSnapshot("restore");26542655 let restoredA: StoredVault;2656 let restoredB: StoredVault;2657 try {2658 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2659 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2660 } catch {2661 return { ok: false, error: "snapshot_corrupt" };2662 }26632664 await writeSlot(SLOT_A, restoredA);2665 await writeSlot(SLOT_B, restoredB);2666 await stampLocalVaultMutation();26672668 2669 2670 2671 lock();26722673 return { ok: true };2674}267526762677267826792680268126822683268426852686268726882689export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {2690 if (!derivedKey || vaultMode !== "real") {2691 return { ok: false, error: "vault_locked" };2692 }2693 const slotA = await loadSlot(SLOT_A);2694 if (!slotA) return { ok: false, error: "missing_slot_a" };26952696 2697 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =2698 await generateAndWrapRootKey();26992700 2701 const newRootKey = unhex(rawRootKey);2702 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);27032704 2705 2706 2707 2708 await takeVaultSnapshot("local_save");2709 const nextSlotA: StoredVault = {2710 ...slotA,2711 recovery_salt: recoverySalt,2712 recovery_wrapped_root_key: wrappedRootKey,2713 recovery_nonce: wrappedNonce,2714 recovery_tag: wrappedTag,2715 ...pwRoot,2716 };2717 await writeSlot(SLOT_A, nextSlotA);27182719 2720 if (rootKey) zeroKey(rootKey);2721 rootKey = newRootKey;27222723 2724 const { clearPairedDevices } = await import("./paired-devices");2725 await clearPairedDevices();27262727 2728 await setRecoveryConfirmPending(); 27292730 await stampLocalVaultMutation();2731 return { ok: true };2732}27332734273527362737273827392740export async function changePassword(2741 currentPassword: string,2742 newPassword: string2743): Promise<boolean> {2744 if (!derivedKey || vaultMode !== "real") return false;27452746 2747 const valid = await verifyMainPassword(currentPassword);2748 if (!valid) return false;27492750 const slotA = await loadSlot(SLOT_A);2751 if (!slotA) return false;2752 if (!rootKey) return false;27532754 2755 const newSalt = generateSalt();2756 const newKey = await deriveKey(newPassword, newSalt);27572758 2759 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2760 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");27612762 2763 2764 2765 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);27662767 const finalSlotA: StoredVault = {2768 ...slotA,2769 salt: hex(newSalt),2770 nonce,2771 ciphertext,2772 tag,2773 ...pwRoot,2774 };27752776 2777 2778 2779 await takeVaultSnapshot("local_save");27802781 2782 2783 2784 2785 await writeSlot(SLOT_A, finalSlotA);27862787 2788 2789 2790 zeroKey(derivedKey);2791 derivedKey = newKey;27922793 2794 2795 2796 if (!duressConfigured) {2797 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2798 }2799 await stampLocalVaultMutation();28002801 return true;2802}