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 LegacyVaultEntry,24 ManualSummonRecord,25 SavedIdentity,26 StoredVault,27 VaultEntry,28 VaultHistory,29 VaultSnapshot,30} from "../shared/types";31import { decrypt, encrypt, zeroKey } from "./aes";32import { clearAutoLock, touchAutoLock } from "./alarms";3334type EntryDraft = Omit<35 VaultEntry,36 "id" | "createdAt" | "modifiedAt" | "deletedAt"37>;383940414243const SLOT_A = "peach_vault_a";44const SLOT_B = "peach_vault_b";454647const LEGACY_MAIN = "peach_vault";48const LEGACY_DECOY = "peach_decoy_vault";49const LEGACY_DURESS_FLAG = "peach_duress";50515253const PAD_BLOCK = 1024;5455565758const PASSWORD_HISTORY_MAX = 50;5960616263646566676869const DURESS_SLOT_SIZE = 262_144;7071let derivedKey: Uint8Array | null = null;7273747576let rootKey: Uint8Array | null = null;77let entries: VaultEntry[] = [];78let domainIndex: Map<string, VaultEntry[]> = new Map();79let vaultMode: "real" | "decoy" = "real";80818283let duressConfigured = false;8485let migrationRan = false;8687888990919293949596let lastUnlockFailureReason: string | null = null;9798export function consumeLastUnlockFailureReason(): string | null {99 const r = lastUnlockFailureReason;100 lastUnlockFailureReason = null;101 return r;102}103104105106107108export type TextSize = "small" | "medium" | "large";109110export interface VaultSettings {111 accentColor?: AccentColorName;112 theme?: "dark" | "light";113 autoLockMinutes?: number;114 clipboardClearSeconds?: number;115 lockOnClose?: boolean;116 breachMonitoring?: boolean;117 passkeyProviderEnabled?: boolean;118 trashRetentionDays?: number;119 manualSummonDomains?: Record<string, ManualSummonRecord>;120 declinedCardFingerprints?: string[];121 122123124125126 textSize?: TextSize;127 128129130131132 reduceMotion?: boolean;133 134135136137138 biometricsEnabled?: boolean;139 140141142143144145146 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;147}148149let settings: VaultSettings = {};150151export function getSettings(): VaultSettings {152 return settings;153}154155export async function ensureSettingsLoaded(): Promise<VaultSettings> {156 await loadSettings();157 return settings;158}159160export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {161 await loadSettings();162 settings = { ...settings, ...patch };163 await persistSettings();164}165166async function persistSettings(): Promise<void> {167 try {168 await chrome.storage.local.set({ peach_settings: settings });169 } catch (err) {170 throw mapStorageError(err);171 }172}173174function mapStorageError(err: unknown): Error {175 const message = err instanceof Error ? err.message : String(err);176 if (/QUOTA_BYTES|quota/i.test(message)) {177 return new Error("STORAGE_QUOTA_EXCEEDED");178 }179 return err instanceof Error ? err : new Error(message);180}181182async function loadSettings(): Promise<void> {183 const result = await chrome.storage.local.get("peach_settings");184 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};185}186187188189190191192function fireSyncTrigger(reason: "vault_change"): void {193 void import("./sync-trigger")194 .then((mod) => mod.triggerSyncIfReachable({ reason }))195 .catch(() => {196 197 });198}199200201202203204export async function checkBreach(password: string): Promise<number> {205 206 207 const encoder = new TextEncoder();208 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));209 const hashHex = Array.from(new Uint8Array(hashBuffer))210 .map((b) => b.toString(16).padStart(2, "0"))211 .join("")212 .toUpperCase();213214 const prefix = hashHex.slice(0, 5);215 const suffix = hashHex.slice(5);216217 try {218 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);219 if (!res.ok) return 0;220 const text = await res.text();221 const match = text.split("\r\n").find((l) => l.startsWith(suffix));222 return match ? parseInt(match.split(":")[1], 10) : 0;223 } catch {224 return 0; 225 }226}227228export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {229 const fingerprint = await cardFingerprint(last4, expMonth, expYear);230 await loadSettings();231 settings = {232 ...settings,233 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],234 };235 await persistSettings();236}237238export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {239 await loadSettings();240 const fingerprint = await cardFingerprint(last4, expMonth, expYear);241 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);242}243244async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {245 const encoder = new TextEncoder();246 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));247 return Array.from(new Uint8Array(buf).slice(0, 8))248 .map((b) => b.toString(16).padStart(2, "0"))249 .join("");250}251252function hex(buf: Uint8Array): string {253 return Array.from(buf)254 .map((b) => b.toString(16).padStart(2, "0"))255 .join("");256}257258function unhex(s: string): Uint8Array {259 const out = new Uint8Array(s.length / 2);260 for (let i = 0; i < s.length; i += 2) {261 out[i / 2] = parseInt(s.slice(i, i + 2), 16);262 }263 return out;264}265266267268269270271272273274275276277async function generateAndWrapRootKey(): Promise<{278 rawRootKey: string; 279 recoverySalt: string;280 wrappedRootKey: string;281 wrappedNonce: string;282 wrappedTag: string;283}> {284 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));285286 287 const words = await rootKeyToWords(rootKeyBytes);288 const wordMaterial = await wordsToRootKeyMaterial(words);289 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));290 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);291292 293 294 const nonce = crypto.getRandomValues(new Uint8Array(12));295 const encoded = new TextEncoder().encode(hex(rootKeyBytes));296 const raw = await crypto.subtle.encrypt(297 {298 name: "AES-GCM",299 iv: nonce,300 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,301 tagLength: 128,302 },303 recoveryKey,304 encoded305 );306 307 const rawU8 = new Uint8Array(raw);308 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));309 const tag = hex(rawU8.slice(rawU8.length - 16));310311 return {312 rawRootKey: hex(rootKeyBytes),313 recoverySalt: hex(recoverySalt),314 wrappedRootKey: ciphertext,315 wrappedNonce: hex(nonce),316 wrappedTag: tag,317 };318}319320321322323324325export async function unwrapRootKeyForRecovery(326 wrappedRootKey: string,327 wrappedNonce: string,328 wrappedTag: string,329 recoverySaltHex: string,330 words: string[]331): Promise<void> {332 const wordMaterial = await wordsToRootKeyMaterial(words);333 const recoverySalt = unhex(recoverySaltHex);334 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);335336 337 338 339 340 341 const nonceRaw = unhex(wrappedNonce);342 const ciphertextRaw = unhex(wrappedRootKey);343 const tagRaw = unhex(wrappedTag);344 345 346 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;347 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);348 combined.set(ciphertextRaw, 0);349 combined.set(tagRaw, ciphertextRaw.length);350 const raw = await crypto.subtle.decrypt(351 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },352 recoveryKey,353 combined354 );355 const decryptedHex = new TextDecoder().decode(raw);356 rootKey = unhex(decryptedHex);357}358359360361362363364365366367368export async function rewrapRootKeyForPassword(369 newDerivedKey: Uint8Array,370 _newSalt: Uint8Array371): Promise<void> {372 if (!rootKey) throw new Error("root key not set");373 const slotA = await loadSlot(SLOT_A);374 if (!slotA) throw new Error("missing slot A");375 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);376 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });377}378379380381382383384async function reencryptVaultForNewKey(385 newKey: Uint8Array,386 slotA: StoredVault387): Promise<void> {388 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);389 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");390 await writeSlot(SLOT_A, {391 ...slotA,392 nonce,393 ciphertext,394 tag,395 });396}397398399400401402403export async function changePasswordWithNewKey(404 newSalt: Uint8Array,405 newKey: Uint8Array406): Promise<void> {407 const slotA = await loadSlot(SLOT_A);408 if (!slotA) throw new Error("missing slot A");409 410 411 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });412 if (derivedKey) zeroKey(derivedKey);413 derivedKey = newKey;414 415 416 417 418 if (rootKey) {419 const slotAfter = await loadSlot(SLOT_A);420 if (slotAfter) {421 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);422 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });423 }424 }425 if (!duressConfigured) {426 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));427 }428 await stampLocalVaultMutation();429}430431432433434435436export { deriveKey, generateSalt };437438export async function loadSlotA(): Promise<StoredVault | null> {439 return loadSlot(SLOT_A);440}441442443444445446447448449450451452453454async function deriveRootKeyFromDerivedKey(455 derivedKeyBytes: Uint8Array,456 slotA: StoredVault,457): Promise<Uint8Array | null> {458 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {459 return null;460 }461 try {462 const rootHex = await decrypt(463 derivedKeyBytes,464 slotA.pw_root_nonce,465 slotA.pw_root_ciphertext,466 slotA.pw_root_tag,467 "pw_root_v1",468 );469 return unhex(rootHex);470 } catch {471 return null;472 }473}474475476477async function wrapRootKeyForDerivedKey(478 derivedKeyBytes: Uint8Array,479 rootKeyBytes: Uint8Array,480): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {481 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");482 return {483 pw_root_ciphertext: ciphertext,484 pw_root_nonce: nonce,485 pw_root_tag: tag,486 };487}488489490491492493494495496export async function migrateToRecovery(): Promise<void> {497 if (!derivedKey || vaultMode !== "real") {498 throw new Error("vault must be unlocked in real mode");499 }500 const slotA = await loadSlot(SLOT_A);501 if (!slotA) throw new Error("missing slot A");502 const hasRecoveryEscrow =503 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;504 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;505 if (hasRecoveryEscrow && hasPwRoot) return; 506507 508 509 510 511 512 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =513 await generateAndWrapRootKey();514 rootKey = unhex(rawRootKey);515516 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);517 const nextSlotA: StoredVault = {518 ...slotA,519 recovery_salt: recoverySalt,520 recovery_wrapped_root_key: wrappedRootKey,521 recovery_nonce: wrappedNonce,522 recovery_tag: wrappedTag,523 ...pwRoot,524 };525 await writeSlot(SLOT_A, nextSlotA);526 await stampLocalVaultMutation();527}528529530531532533534export async function getRecoveryWords(): Promise<string[]> {535 if (!rootKey) {536 throw new Error("recovery not set up — vault may need migration");537 }538 return rootKeyToWords(rootKey);539}540541async function loadSlot(key: string): Promise<StoredVault | null> {542 const result = await chrome.storage.local.get(key);543 return (result as Record<string, StoredVault | undefined>)[key] ?? null;544}545546async function writeSlot(key: string, vault: StoredVault): Promise<void> {547 try {548 await chrome.storage.local.set({ [key]: vault });549 } catch (err) {550 throw mapStorageError(err);551 }552}553554555556557558559560561let lastEntryTimestamp = 0;562function monotonicEntryTimestamp(currentModifiedAt?: number): number {563 const base = Math.max(564 Date.now(),565 (currentModifiedAt ?? 0) + 1,566 lastEntryTimestamp + 1,567 );568 lastEntryTimestamp = base;569 return base;570}571572async function stampLocalVaultMutation(): Promise<void> {573 const raw = await chrome.storage.local.get("peach_last_modified_at");574 const previous = Number(575 (raw as Record<string, unknown>).peach_last_modified_at,576 );577 const next = Math.max(578 Date.now(),579 Number.isFinite(previous) ? previous + 1 : 0,580 );581 await chrome.storage.local.set({582 peach_last_modified_at: String(next),583 });584}585586587588589590591async function ensureMigrated(): Promise<void> {592 if (migrationRan) return;593 migrationRan = true;594595 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];596 const raw = await chrome.storage.local.get(keys);597 const slotAExists = raw[SLOT_A] !== undefined;598 const slotBExists = raw[SLOT_B] !== undefined;599 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;600 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;601602 603 if (!slotAExists && !legacyMain) return;604605 606 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;607608 let didMigrate = false;609 if (legacyMain && !slotAExists) {610 await writeSlot(SLOT_A, legacyMain);611 didMigrate = true;612 }613614 615 if (!slotBExists) {616 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;617 if (legacyDecoy) {618 await writeSlot(SLOT_B, legacyDecoy);619 didMigrate = true;620 } else if (target) {621 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));622 didMigrate = true;623 }624 }625626 const toRemove: string[] = [];627 if (legacyMain) toRemove.push(LEGACY_MAIN);628 if (legacyDecoy) toRemove.push(LEGACY_DECOY);629 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);630 if (toRemove.length > 0) {631 await chrome.storage.local.remove(toRemove);632 didMigrate = true;633 }634 if (didMigrate) {635 await stampLocalVaultMutation();636 }637}638639640641642643644645646647648649function padPlaintext(json: string, minBytes = 0): string {650 const encoded = new TextEncoder().encode(json);651 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);652 653 const target = Math.max(bucket, minBytes);654 const padLen = target - encoded.length;655 return json + " ".repeat(padLen);656}657658659660661662663664665666667668const CSPRNG_CHUNK = 65_536;669670function fillRandom(buf: Uint8Array): Uint8Array {671 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {672 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);673 const chunk = new Uint8Array(end - offset);674 crypto.getRandomValues(chunk);675 buf.set(chunk, offset);676 }677 return buf;678}679680function generateNoiseBlob(ciphertextBytes: number): StoredVault {681 const salt = crypto.getRandomValues(new Uint8Array(16));682 const nonce = crypto.getRandomValues(new Uint8Array(12));683 const cipher = fillRandom(new Uint8Array(ciphertextBytes));684 const tag = crypto.getRandomValues(new Uint8Array(16));685 return {686 version: 1,687 salt: hex(salt),688 nonce: hex(nonce),689 ciphertext: hex(cipher),690 tag: hex(tag),691 };692}693694695696697698type VaultPayload = {699 entries: VaultEntry[];700 duressConfigured?: boolean;701};702703type DecryptedPayload = {704 entries: VaultEntry[];705 duressConfigured: boolean;706 707708709710711712713 migratedFromLegacy: boolean;714};715716function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {717 const envelope: VaultPayload = { entries, duressConfigured: duress };718 return padPlaintext(JSON.stringify(envelope), minBytes);719}720721function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {722 if ("created" in raw || "updated" in raw) return true;723 const flat = raw as Partial<VaultEntry>;724 if (flat.type === "passkey") return true;725 726 if (flat.rpId !== undefined && flat.passkey === undefined) return true;727 return false;728}729730function decodePayload(plain: string): DecryptedPayload {731 const parsed = JSON.parse(plain) as732 | Array<VaultEntry | LegacyVaultEntry>733 | VaultPayload;734735 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];736 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);737738 const normalized = rawEntries.map(normalizeEntry);739 const { merged } = mergePasskeyOnlyIntoLogins(normalized);740741 if (Array.isArray(parsed)) {742 return {743 entries: normalized,744 duressConfigured: false,745 migratedFromLegacy: true, 746 };747 }748749 return {750 entries: normalized,751 duressConfigured: parsed.duressConfigured === true,752 migratedFromLegacy: flatMigrationNeeded || merged,753 };754}755756757758759760761762763764765766767768769770771772773774775776777778779function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {780 let merged = false;781 const now = Date.now();782783 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);784 const byRpId = new Map<string, VaultEntry[]>();785 for (const e of candidates) {786 const rpId = e.passkey!.rpId;787 const list = byRpId.get(rpId);788 if (list) list.push(e);789 else byRpId.set(rpId, [e]);790 }791792 for (const [rpId, group] of byRpId) {793 if (group.length === 0) continue;794 group.sort((a, b) => {795 const aT =796 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;797 const bT =798 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;799 return bT - aT;800 });801 const winner = group[0]!;802803 804 805 806 const loginTarget = entries.find((e) => {807 if (e.deletedAt || e.passkey || e.type !== "login") return false;808 if (e.url && extractDomain(e.url) === rpId) return true;809 for (const extra of e.additionalUrls ?? []) {810 if (extractDomain(extra) === rpId) return true;811 }812 return false;813 });814815 if (loginTarget) {816 loginTarget.passkey = winner.passkey;817 loginTarget.modifiedAt = now;818 for (const dup of group) {819 if (!dup.deletedAt) {820 dup.deletedAt = now;821 dup.modifiedAt = now;822 }823 }824 merged = true;825 } else if (group.length > 1) {826 for (let i = 1; i < group.length; i += 1) {827 const dup = group[i]!;828 if (!dup.deletedAt) {829 dup.deletedAt = now;830 dup.modifiedAt = now;831 }832 }833 merged = true;834 }835 }836837 return { merged };838}839840841842843844845846function hasValidVaultSchema(v: unknown): v is StoredVault {847 if (typeof v !== "object" || v === null) return false;848 const r = v as Record<string, unknown>;849 return (850 r.version === 1 &&851 typeof r.salt === "string" &&852 typeof r.nonce === "string" &&853 typeof r.ciphertext === "string" &&854 typeof r.tag === "string"855 );856}857858export type TryDecryptResult =859 | { status: "ok"; payload: DecryptedPayload }860 | { status: "wrong_key" }861 | { status: "corrupted"; reason: string };862863864865866867868export async function tryDecryptSlot(869 vault: StoredVault,870 key: Uint8Array,871 aadTag?: string,872): Promise<DecryptedPayload | null> {873 const res = await tryDecryptSlotResult(vault, key, aadTag);874 return res.status === "ok" ? res.payload : null;875}876877export async function tryDecryptSlotResult(878 vault: StoredVault,879 key: Uint8Array,880 aadTag?: string,881): Promise<TryDecryptResult> {882 if (!hasValidVaultSchema(vault)) {883 return { status: "corrupted", reason: "invalid_slot_schema" };884 }885 let plain: string;886 try {887 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);888 } catch {889 return { status: "wrong_key" };890 }891 try {892 const parsed = JSON.parse(plain) as unknown;893 if (typeof parsed !== "object" || parsed === null) {894 return { status: "corrupted", reason: "invalid_payload_shape" };895 }896 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {897 return { status: "corrupted", reason: "invalid_payload_shape" };898 }899 return { status: "ok", payload: decodePayload(plain) };900 } catch {901 return { status: "corrupted", reason: "invalid_payload_json" };902 }903}904905906907908909function buildDomainIndexSync(all: VaultEntry[]): void {910 domainIndex = new Map();911912 for (const entry of all) {913 if (entry.deletedAt) continue;914 if (entry.type === "note" || entry.type === "card") continue;915916 917 918 919 920 const domains = new Set<string>();921 const primary = extractDomain(entry.url ?? "");922 if (primary) domains.add(primary);923 for (const extra of entry.additionalUrls ?? []) {924 const d = extractDomain(extra);925 if (d) domains.add(d);926 }927 if (domains.size === 0) continue;928929 for (const domain of domains) {930 const bucket = domainIndex.get(domain) ?? [];931 bucket.push(entry);932 domainIndex.set(domain, bucket);933 }934 }935}936937export async function createVault(password: string): Promise<void> {938 await ensureMigrated();939940 const salt = generateSalt();941 derivedKey = await deriveKey(password, salt);942 entries = [];943 vaultMode = "real";944 duressConfigured = false;945 buildDomainIndexSync(entries);946947 948 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =949 await generateAndWrapRootKey();950 rootKey = unhex(rawRootKey);951952 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);953 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");954955 956 957 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);958959 const slotA: StoredVault = {960 version: 1,961 salt: hex(salt),962 nonce,963 ciphertext,964 tag,965 recovery_salt: recoverySalt,966 recovery_wrapped_root_key: wrappedRootKey,967 recovery_nonce: wrappedNonce,968 recovery_tag: wrappedTag,969 ...pwRoot,970 };971 await writeSlot(SLOT_A, slotA);972973 974 975 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));976 await stampLocalVaultMutation();977978 touchAutoLock();979}980981export async function hasVault(): Promise<boolean> {982 await ensureMigrated();983 return (await loadSlot(SLOT_A)) !== null;984}985986987988989990991export async function isDuressEnabled(): Promise<boolean> {992 return vaultMode === "real" && duressConfigured;993}994995export async function setupDuress(duressPassword: string): Promise<void> {996 if (!derivedKey || vaultMode !== "real") {997 throw new Error("vault must be unlocked in real mode");998 }999 await ensureMigrated();10001001 1002 1003 1004 const slotA = await loadSlot(SLOT_A);1005 if (!slotA) throw new Error("missing slot A");10061007 const decoySalt = generateSalt();1008 const decoyKey = await deriveKey(duressPassword, decoySalt);1009 try {1010 1011 1012 1013 1014 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1015 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1016 await writeSlot(SLOT_B, {1017 version: 1,1018 salt: hex(decoySalt),1019 nonce,1020 ciphertext,1021 tag,1022 });1023 } finally {1024 zeroKey(decoyKey);1025 }10261027 1028 1029 duressConfigured = true;1030 await persistSlotA();1031}10321033export async function disableDuress(): Promise<void> {1034 if (!derivedKey || vaultMode !== "real") {1035 throw new Error("vault must be unlocked in real mode");1036 }1037 await ensureMigrated();10381039 1040 1041 1042 const slotA = await loadSlot(SLOT_A);1043 if (slotA) {1044 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1045 }10461047 duressConfigured = false;1048 await persistSlotA();1049}10501051105210531054105510561057export async function verifyMainPassword(password: string): Promise<boolean> {1058 await ensureMigrated();1059 const slotA = await loadSlot(SLOT_A);1060 if (!slotA) return false;1061 const key = await deriveKey(password, unhex(slotA.salt));1062 try {1063 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1064 } finally {1065 zeroKey(key);1066 }1067}10681069export async function unlock(password: string): Promise<boolean> {1070 await ensureMigrated();10711072 1073 1074 1075 1076 1077 const [slotA, slotB] = await Promise.all([1078 loadSlot(SLOT_A),1079 loadSlot(SLOT_B),1080 ]);10811082 1083 const [keyA, keyB] = await Promise.all([1084 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),1085 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),1086 ]);10871088 1089 1090 1091 const [resultA, resultB] = await Promise.all([1092 keyA && slotA1093 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1094 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1095 keyB && slotB1096 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1097 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1098 ]);1099 const payloadA = resultA.status === "ok" ? resultA.payload : null;1100 const payloadB = resultB.status === "ok" ? resultB.payload : null;11011102 if (payloadA && keyA) {1103 derivedKey = keyA;1104 void saveSessionKey(keyA, "real");1105 vaultMode = "real";1106 entries = payloadA.entries;1107 duressConfigured = payloadA.duressConfigured;1108 buildDomainIndexSync(entries);1109 await loadSettings();1110 touchAutoLock();1111 if (keyB) zeroKey(keyB);11121113 1114 if (slotA) {1115 const needsRecoverySetup =1116 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1117 if (!needsRecoverySetup) {1118 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1119 }1120 1121 1122 1123 1124 if (needsRecoverySetup || !rootKey) {1125 try {1126 await migrateToRecovery();1127 if (needsRecoverySetup) {1128 1129 1130 1131 await chrome.storage.local.set({ peach_recovery_pending: '1' });1132 }1133 } catch (migrationErr) {1134 console.warn('[Peach] recovery migration failed:', migrationErr);1135 }1136 }1137 }11381139 1140 1141 1142 if (payloadA.migratedFromLegacy) {1143 await persistSlotA();1144 }1145 return true;1146 }11471148 if (payloadB && keyB) {1149 derivedKey = keyB;1150 void saveSessionKey(keyB, "decoy");1151 vaultMode = "decoy";1152 entries = payloadB.entries;1153 duressConfigured = false;1154 buildDomainIndexSync(entries);1155 await loadSettings();1156 touchAutoLock();1157 if (keyA) zeroKey(keyA);1158 return true;1159 }11601161 if (keyA) zeroKey(keyA);1162 if (keyB) zeroKey(keyB);1163 1164 1165 if (resultA.status === "corrupted") {1166 lastUnlockFailureReason = resultA.reason;1167 } else if (resultB.status === "corrupted") {1168 lastUnlockFailureReason = resultB.reason;1169 }1170 return false;1171}11721173117411751176117711781179const SESSION_KEY_STORAGE = "peach_session_dk";11801181async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1182 if (!chrome.storage.session) return;1183 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1184}11851186async function clearSessionKey(): Promise<void> {1187 if (!chrome.storage.session) return;1188 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1189}11901191119211931194119511961197export async function restoreFromSession(): Promise<boolean> {1198 if (derivedKey !== null) return true;1199 if (!chrome.storage.session) return false;1200 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1201 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1202 if (!entry?.keyHex) return false;12031204 const key = unhex(entry.keyHex);1205 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1206 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1207 const slot = await loadSlot(slotKey);1208 if (!slot) { await clearSessionKey(); return false; }12091210 const result = await tryDecryptSlotResult(slot, key, aadTag);1211 if (result.status !== "ok") { await clearSessionKey(); return false; }12121213 derivedKey = key;1214 vaultMode = entry.mode;1215 entries = result.payload.entries;1216 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1217 buildDomainIndexSync(entries);1218 await loadSettings();1219 touchAutoLock();12201221 if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1222 try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { }1223 }12241225 return true;1226}12271228export function lock(): void {1229 void clearSessionKey();1230 if (derivedKey) {1231 zeroKey(derivedKey);1232 derivedKey = null;1233 }1234 if (rootKey) {1235 rootKey.fill(0);1236 rootKey = null;1237 }12381239 entries = [];1240 domainIndex = new Map();1241 vaultMode = "real";1242 duressConfigured = false;1243 clearAutoLock();1244}12451246export function isUnlocked(): boolean {1247 return derivedKey !== null;1248}1249125012511252export async function unlockWithKey(1253 masterKeyHex: string,1254): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1255 try {1256 const masterKeyBytes = unhex(masterKeyHex);1257 const stored = await loadSlot(SLOT_A);1258 if (!stored) return { ok: false, error: "invalid_key" };1259 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1260 if (!payload) return { ok: false, error: "invalid_key" };12611262 derivedKey = masterKeyBytes;1263 vaultMode = "real";1264 entries = payload.entries;1265 duressConfigured = payload.duressConfigured;1266 buildDomainIndexSync(entries);1267 await loadSettings();1268 touchAutoLock();1269 return { ok: true, entries };1270 } catch {1271 return { ok: false, error: "invalid_key" };1272 }1273}12741275127612771278127912801281128212831284128512861287128812891290129112921293export async function unlockFromCodex(params: {1294 slotA: string; 1295 slotB: string | null; 1296 timestamp: number;1297 masterPassphrase: string;1298}): Promise<1299 | { ok: true; entryCount: number }1300 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1301> {1302 let parsedSlotA: StoredVault;1303 let parsedSlotB: StoredVault | null = null;1304 try {1305 const rawA = JSON.parse(params.slotA) as unknown;1306 if (!hasValidVaultSchema(rawA)) {1307 return { ok: false, reason: "invalid_slot_format" };1308 }1309 parsedSlotA = rawA;1310 if (params.slotB !== null) {1311 const rawB = JSON.parse(params.slotB) as unknown;1312 if (!hasValidVaultSchema(rawB)) {1313 return { ok: false, reason: "invalid_slot_format" };1314 }1315 parsedSlotB = rawB;1316 }1317 } catch {1318 return { ok: false, reason: "invalid_slot_format" };1319 }13201321 1322 1323 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1324 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1325 if (!trialPayload) {1326 zeroKey(trialKey);1327 return { ok: false, reason: "wrong_passphrase" };1328 }1329 1330 1331 zeroKey(trialKey);13321333 1334 1335 await takeVaultSnapshot("restore");13361337 await writeSlot(SLOT_A, parsedSlotA);1338 if (parsedSlotB) {1339 await writeSlot(SLOT_B, parsedSlotB);1340 } else {1341 1342 1343 1344 await writeSlot(1345 SLOT_B,1346 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1347 );1348 }1349 await chrome.storage.local.set({1350 peach_last_modified_at: String(params.timestamp),1351 });13521353 1354 1355 const ok = await unlock(params.masterPassphrase);1356 if (!ok) {1357 1358 1359 1360 return { ok: false, reason: "wrong_passphrase" };1361 }1362 return { ok: true, entryCount: getEntries().length };1363}1364136513661367export function getDerivedKeyHex(): string {1368 if (!derivedKey) throw new Error("vault_not_unlocked");1369 return hex(derivedKey);1370}13711372137313741375137613771378137913801381138213831384138513861387export function getRootKeyHex(): string | null {1388 return rootKey ? hex(rootKey) : null;1389}13901391export function getEntries(): VaultEntry[] {1392 if (!derivedKey) throw new Error("vault_locked");1393 return entries.filter((entry) => !entry.deletedAt);1394}13951396const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;13971398export function getIdentities(): SavedIdentity[] {1399 if (!derivedKey) throw new Error("vault_locked");1400 const active = entries.filter((e) => !e.deletedAt && e.type === "login");14011402 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1403 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();14041405 for (const entry of active) {1406 const domain = extractDomain(entry.url ?? "") ?? "";1407 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;14081409 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1410 const existing = map.get(v);1411 if (!existing || modifiedAt > existing.lastUsed) {1412 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1413 } else {1414 map.set(v, { ...existing, count: existing.count + 1 });1415 }1416 }14171418 const uVal = entry.username?.trim();1419 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);14201421 1422 1423 const eVal = entry.email?.trim();1424 if (eVal) tally(emailMap, eVal);1425 }14261427 const toIdentity = (type: "email" | "username") =>1428 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1429 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1430 });14311432 return [1433 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1434 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1435 ];1436}14371438export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1439 if (!derivedKey) throw new Error("vault_locked");14401441 const now = Date.now();1442 const added: VaultEntry[] = [];1443 for (const draft of drafts) {1444 try {1445 const sanitized = sanitizeEntryDraft(draft);1446 const nextEntry: VaultEntry = {1447 ...sanitized,1448 id: crypto.randomUUID(),1449 createdAt: now,1450 modifiedAt: monotonicEntryTimestamp(),1451 };1452 entries.push(nextEntry);1453 added.push(nextEntry);1454 } catch {1455 1456 }1457 }14581459 if (added.length > 0) {1460 await persistEntries();1461 }14621463 return added;1464}146514661467146814691470147114721473export async function restoreEntries(1474 incoming: VaultEntry[],1475 mode: "replace" | "merge"1476): Promise<{ added: number; updated: number; skipped: number }> {1477 if (!derivedKey) throw new Error("vault_locked");14781479 1480 1481 1482 if (mode === "replace" && incoming.length === 0) {1483 throw new Error("refuse to replace with empty entry set");1484 }14851486 let added = 0;1487 let updated = 0;1488 let skipped = 0;14891490 if (mode === "replace") {1491 entries = [];1492 for (const entry of incoming) {1493 const normalized = sanitizeRestoredEntry(entry);1494 if (!normalized) {1495 skipped += 1;1496 continue;1497 }1498 entries.push(normalized);1499 added += 1;1500 }1501 } else {1502 const byId = new Map<string, VaultEntry>();1503 for (const e of entries) byId.set(e.id, e);15041505 for (const entry of incoming) {1506 const normalized = sanitizeRestoredEntry(entry);1507 if (!normalized) {1508 skipped += 1;1509 continue;1510 }1511 const existing = byId.get(normalized.id);1512 if (!existing) {1513 entries.push(normalized);1514 added += 1;1515 } else if (normalized.modifiedAt > existing.modifiedAt) {1516 const idx = entries.findIndex((e) => e.id === existing.id);1517 if (idx !== -1) {1518 entries[idx] = normalized;1519 updated += 1;1520 }1521 } else {1522 skipped += 1;1523 }1524 }1525 }15261527 await persistEntries("import");1528 return { added, updated, skipped };1529}1530153115321533153415351536function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1537 if (!entry || typeof entry !== "object") return null;1538 const title = typeof entry.title === "string" ? entry.title.trim() : "";1539 if (!title) return null;1540 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1541 const now = Date.now();1542 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1543 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;15441545 1546 1547 1548 const migrated: VaultEntry = migrateLegacyPasskey(1549 {1550 ...entry,1551 id,1552 title,1553 type: entry.type ?? "login",1554 createdAt,1555 modifiedAt,1556 },1557 entry1558 );15591560 1561 1562 if (1563 migrated.type === "login" &&1564 !migrated.passkey &&1565 typeof migrated.password !== "string"1566 ) {1567 return null;1568 }1569 if (migrated.passkey && !migrated.passkey.credentialId) {1570 return null;1571 }15721573 return migrated;1574}157515761577157815791580export async function clearVaultEntries(1581 confirmation: "REPLACE_ALL"1582): Promise<void> {1583 if (confirmation !== "REPLACE_ALL") {1584 throw new Error("clearVaultEntries requires explicit confirmation");1585 }1586 if (!derivedKey) throw new Error("vault_locked");1587 for (const entry of entries) {1588 if (!entry.deletedAt) {1589 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1590 entry.deletedAt = stamp;1591 entry.modifiedAt = stamp;1592 }1593 }1594 await persistEntries();1595}15961597export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1598 if (!derivedKey) throw new Error("vault_locked");15991600 const sanitized = sanitizeEntryDraft(entry);1601 const now = Date.now();1602 const stamp = monotonicEntryTimestamp();1603 const nextEntry: VaultEntry = {1604 ...sanitized,1605 id: crypto.randomUUID(),1606 createdAt: now,1607 modifiedAt: stamp,1608 1609 1610 passwordChangedAt: sanitized.password ? stamp : undefined,1611 };16121613 1614 1615 1616 const rollback = entries;1617 entries = [...entries, nextEntry];1618 try {1619 await persistEntries();1620 } catch (err) {1621 entries = rollback;1622 throw err;1623 }16241625 1626 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1627 checkBreach(nextEntry.password).then(async (count) => {1628 if (count > 0) {1629 const idx = entries.findIndex((e) => e.id === nextEntry.id);1630 if (idx !== -1) {1631 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };1632 await persistEntries();1633 }1634 }1635 });1636 }16371638 fireSyncTrigger("vault_change");16391640 return nextEntry;1641}16421643164416451646164716481649export async function consolidatePasskeyEntries(): Promise<void> {1650 if (!derivedKey) return;1651 const { merged } = mergePasskeyOnlyIntoLogins(entries);1652 if (merged) {1653 await persistEntries();1654 }1655}16561657export async function updateEntry(1658 id: string,1659 patch: Partial<VaultEntry>1660): Promise<void> {1661 if (!derivedKey) throw new Error("vault_locked");16621663 const idx = entries.findIndex((entry) => entry.id === id);1664 if (idx === -1 || entries[idx].deletedAt) {1665 throw new Error("entry_not_found");1666 }16671668 const current = entries[idx];1669 const sanitizedPatch = sanitizeEntryPatch(patch);1670 1671 1672 1673 const passwordChanged =1674 "password" in patch &&1675 typeof sanitizedPatch.password === "string" &&1676 sanitizedPatch.password !== current.password;1677 const stamp = monotonicEntryTimestamp(current.modifiedAt);1678 1679 1680 1681 1682 1683 1684 1685 const priorPassword = current.password;1686 const shouldCapture =1687 passwordChanged &&1688 typeof priorPassword === "string" &&1689 priorPassword.length > 0 &&1690 current.passwordHistory?.[0]?.password !== priorPassword;1691 const nextHistory = shouldCapture1692 ? [1693 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },1694 ...(current.passwordHistory ?? []),1695 ].slice(0, PASSWORD_HISTORY_MAX)1696 : current.passwordHistory;1697 entries[idx] = {1698 ...current,1699 ...sanitizedPatch,1700 modifiedAt: stamp,1701 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),1702 };1703170417051706 1707 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1708 entries[idx] = current;1709 throw new Error("card number is required for card entries");1710 }17111712 1713 1714 1715 1716 try {1717 await persistEntries();1718 } catch (err) {1719 entries[idx] = current;1720 throw err;1721 }17221723 1724 1725 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1726 entries[idx] = { ...entries[idx], breach: undefined };1727 await persistEntries();1728 const password = entries[idx].password;1729 if (password) {1730 checkBreach(password).then(async (count) => {1731 const i = entries.findIndex((e) => e.id === id);1732 if (i !== -1 && count > 0) {1733 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };1734 await persistEntries();1735 }1736 });1737 }1738 }17391740 fireSyncTrigger("vault_change");1741}17421743export async function deleteEntry(id: string): Promise<void> {1744 if (!derivedKey) throw new Error("vault_locked");17451746 const entry = entries.find((item) => item.id === id);1747 if (!entry || entry.deletedAt) {1748 throw new Error("entry_not_found");1749 }17501751 1752 1753 1754 const previousModifiedAt = entry.modifiedAt;1755 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1756 entry.deletedAt = stamp;1757 entry.modifiedAt = stamp;1758 1759 1760 try {1761 await persistEntries();1762 } catch (err) {1763 delete entry.deletedAt;1764 entry.modifiedAt = previousModifiedAt;1765 throw err;1766 }1767 fireSyncTrigger("vault_change");1768}17691770export function getDeletedEntries(): VaultEntry[] {1771 if (!derivedKey) return [];1772 return entries.filter((e) => !!e.deletedAt);1773}1774177517761777177817791780export async function getVaultVersion(): Promise<number> {1781 try {1782 const raw = await chrome.storage.local.get("peach_last_modified_at");1783 const v = (raw as Record<string, unknown>).peach_last_modified_at;1784 if (typeof v === "number") return v;1785 if (typeof v === "string" && v !== "") {1786 const n = Number.parseInt(v, 10);1787 return Number.isFinite(n) ? n : 0;1788 }1789 return 0;1790 } catch {1791 return 0;1792 }1793}179417951796179717981799180018011802180318041805export async function addEntryDirect(entry: VaultEntry): Promise<void> {1806 if (!derivedKey) throw new Error("vault_locked");1807 entries = [...entries, entry];1808 await persistEntries("sync_received");1809}18101811export async function updateEntryDirect(patch: VaultEntry): Promise<void> {1812 if (!derivedKey) throw new Error("vault_locked");1813 const idx = entries.findIndex((e) => e.id === patch.id);1814 if (idx === -1) throw new Error("entry_not_found");1815 entries[idx] = { ...entries[idx], ...patch };1816 await persistEntries("sync_received");1817}18181819export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {1820 if (!derivedKey) throw new Error("vault_locked");1821 const idx = entries.findIndex((e) => e.id === id);1822 if (idx === -1) throw new Error("entry_not_found");1823 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };1824 await persistEntries("sync_received");1825}1826182718281829183018311832183318341835183618371838export async function applyMergedRemoteEntries(1839 remoteEntries: VaultEntry[],1840 remoteTombstones: Record<string, number>,1841 context: { deviceName?: string } = {},1842): Promise<{ received: number; applied: number; conflicts: number }> {1843 1844 1845 1846 1847 1848 1849 if (!derivedKey) throw new Error("vault_locked");18501851 const received = remoteEntries.length + Object.keys(remoteTombstones).length;1852 let applied = 0;1853 let conflicts = 0;18541855 const byId = new Map<string, VaultEntry>();1856 for (const e of entries) byId.set(e.id, e);18571858 const alerts: SyncAlert[] = [];1859 const deviceName = context.deviceName ?? "paired device";1860 const mergeNow = Date.now();18611862 for (const remote of remoteEntries) {1863 const sanitized = sanitizeRestoredEntry(remote);1864 if (!sanitized) continue;1865 1866 1867 1868 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);1869 if (typeof sanitized.deletedAt === "number") {1870 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);1871 }1872 const tombstoneTs = remoteTombstones[sanitized.id];1873 if (tombstoneTs !== undefined && clampSyncTimestamp(tombstoneTs, mergeNow) >= sanitized.modifiedAt) {1874 continue;1875 }1876 const local = byId.get(sanitized.id);1877 if (!local) {1878 entries.push(sanitized);1879 byId.set(sanitized.id, sanitized);1880 applied += 1;1881 } else if (sanitized.modifiedAt > local.modifiedAt) {1882 const idx = entries.findIndex((e) => e.id === local.id);1883 if (idx !== -1) {1884 1885 1886 if (1887 !local.deletedAt &&1888 typeof sanitized.password === "string" &&1889 typeof local.password === "string" &&1890 sanitized.password !== local.password1891 ) {1892 alerts.push({1893 type: "REMOTE_PASSWORD_CHANGE",1894 entryId: sanitized.id,1895 entryTitle: sanitized.title,1896 deviceName,1897 timestamp: mergeNow,1898 });1899 }1900 entries[idx] = sanitized;1901 byId.set(sanitized.id, sanitized);1902 applied += 1;1903 if (!local.deletedAt) conflicts += 1;1904 }1905 }1906 }19071908 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {1909 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt)) continue;1910 1911 const deletedAt = clampSyncTimestamp(rawDeletedAt, mergeNow);1912 const local = byId.get(id);1913 if (!local) continue;1914 if (local.deletedAt && local.deletedAt >= deletedAt) continue;1915 if (deletedAt < local.modifiedAt) continue;1916 const idx = entries.findIndex((e) => e.id === id);1917 if (idx !== -1) {1918 1919 if (!local.deletedAt) {1920 alerts.push({1921 type: "REMOTE_DELETE",1922 entryId: id,1923 entryTitle: local.title,1924 deviceName,1925 timestamp: mergeNow,1926 });1927 }1928 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };1929 applied += 1;1930 }1931 }19321933 if (applied > 0) {1934 await persistEntries("sync_received");1935 buildDomainIndexSync(entries);1936 }1937 1938 1939 await recordSyncAlerts(alerts);1940 return { received, applied, conflicts };1941}19421943export async function restoreEntry(id: string): Promise<void> {1944 if (!derivedKey) throw new Error("vault_locked");1945 const entry = entries.find((e) => e.id === id && e.deletedAt);1946 if (!entry) throw new Error("deleted entry not found");1947 delete entry.deletedAt;1948 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);1949 await persistEntries();1950 buildDomainIndexSync(entries);1951}19521953export async function purgeEntry(id: string): Promise<void> {1954 if (!derivedKey) throw new Error("vault_locked");1955 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1956 if (idx === -1) throw new Error("deleted entry not found");1957 entries.splice(idx, 1);1958 await persistEntries();1959}196019611962export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1963 if (!derivedKey || retentionMs === 0) return 0;1964 const cutoff = Date.now() - retentionMs;1965 const before = entries.length;1966 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1967 const purged = before - entries.length;1968 if (purged > 0) await persistEntries();1969 return purged;1970}19711972export function queryDomain(domain: string): VaultEntry[] {1973 return domainIndex.get(domain) ?? [];1974}19751976export function getEntryById(id: string): VaultEntry | undefined {1977 return entries.find((entry) => entry.id === id && !entry.deletedAt);1978}19791980async function persistSlotA(1981 reason: VaultSnapshot["reason"] = "local_save",1982): Promise<StoredVault> {1983 if (!derivedKey) throw new Error("vault_locked");19841985 const slotA = await loadSlot(SLOT_A);1986 if (!slotA) throw new Error("missing slot A");19871988 1989 1990 await takeVaultSnapshot(reason);19911992 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1993 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");19941995 1996 const nextSlotA: StoredVault = {1997 ...slotA,1998 nonce,1999 ciphertext,2000 tag,2001 2002 };2003 await writeSlot(SLOT_A, nextSlotA);20042005 2006 2007 2008 2009 2010 2011 try {2012 2013 2014 2015 if (!duressConfigured) {2016 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2017 }2018 2019 2020 2021 2022 2023 if (reason !== "sync_received") {2024 await stampLocalVaultMutation();2025 }2026 } catch (err) {2027 2028 2029 await writeSlot(SLOT_A, slotA).catch(() => {});2030 throw err;2031 }20322033 return nextSlotA;2034}20352036async function persistSlotB(): Promise<void> {2037 if (!derivedKey) throw new Error("vault_locked");20382039 const slotB = await loadSlot(SLOT_B);2040 if (!slotB) throw new Error("missing slot B");20412042 2043 2044 2045 2046 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2047 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");20482049 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2050 2051 2052 2053 try {2054 await stampLocalVaultMutation();2055 } catch (err) {2056 await writeSlot(SLOT_B, slotB).catch(() => {});2057 throw err;2058 }2059}20602061async function persistEntries(2062 reason: VaultSnapshot["reason"] = "local_save",2063): Promise<void> {2064 if (!derivedKey) throw new Error("vault_locked");20652066 if (vaultMode === "decoy") {2067 await persistSlotB();2068 } else {2069 await persistSlotA(reason);2070 }2071 buildDomainIndexSync(entries);2072}20732074function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2075 if (!Array.isArray(values)) return undefined;2076 const seen = new Set<string>();2077 const cleaned: string[] = [];2078 for (const value of values) {2079 const trimmed = typeof value === "string" ? value.trim() : "";2080 if (!trimmed) continue;2081 const key = trimmed.toLowerCase();2082 if (seen.has(key)) continue;2083 seen.add(key);2084 cleaned.push(trimmed);2085 }2086 return cleaned.length > 0 ? cleaned : undefined;2087}20882089function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2090 const title = entry.title.trim();2091 2092 2093 2094 let type: VaultEntry["type"] = entry.type ?? "login";2095 if (type === "passkey") type = "login";20962097 if (!title) throw new Error("title is required");20982099 if (entry.passkey) {2100 const p = entry.passkey;2101 if (2102 !p.rpId ||2103 !p.credentialId ||2104 !p.publicKeyCose ||2105 !p.userHandle ||2106 !p.userName ||2107 (p.algorithm !== -7 && p.algorithm !== -8)2108 ) {2109 throw new Error("invalid passkey credential");2110 }2111 }2112 if (type === "card") {2113 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2114 throw new Error("cardholder name and card number are required");2115 }2116 }21172118 return {2119 type,2120 title,2121 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2122 email: type === "login" ? entry.email?.trim() || undefined : undefined,2123 password: type === "login" ? entry.password : undefined,2124 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2125 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2126 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2127 notes: entry.notes?.trim() || undefined,2128 breach: type === "login" ? entry.breach : undefined,2129 canary: type === "login" ? entry.canary : undefined,2130 tags: entry.tags,2131 passkey: entry.passkey,2132 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2133 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2134 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2135 expYear: type === "card" ? entry.expYear?.trim() : undefined,2136 cvc: type === "card" ? entry.cvc?.trim() : undefined,2137 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2138 network: type === "card" ? entry.network : undefined,2139 last4: type === "card" ? entry.last4?.trim() : undefined,2140 };2141}21422143function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2144 const nextPatch: Partial<VaultEntry> = {};21452146 if ("type" in patch) {2147 nextPatch.type = patch.type;2148 }21492150 if ("title" in patch) {2151 const title = patch.title?.trim() ?? "";2152 if (!title) throw new Error("title is required");2153 nextPatch.title = title;2154 }21552156 if ("url" in patch) {2157 nextPatch.url = patch.url?.trim() ?? "";2158 }21592160 if ("additionalUrls" in patch) {2161 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2162 const seen = new Set<string>();2163 const cleaned: string[] = [];2164 for (const value of incoming) {2165 const trimmed = typeof value === "string" ? value.trim() : "";2166 if (!trimmed) continue;2167 const key = trimmed.toLowerCase();2168 if (seen.has(key)) continue;2169 seen.add(key);2170 cleaned.push(trimmed);2171 }2172 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2173 }21742175 if ("username" in patch) {2176 nextPatch.username = patch.username?.trim() ?? "";2177 }21782179 if ("email" in patch) {2180 nextPatch.email = patch.email?.trim() || undefined;2181 }21822183 if ("password" in patch) {2184 const password = patch.password ?? "";2185 2186 nextPatch.password = password || undefined;2187 }21882189 if ("totp" in patch) {2190 nextPatch.totp = patch.totp?.trim() || undefined;2191 }21922193 if ("notes" in patch) {2194 nextPatch.notes = patch.notes?.trim() || undefined;2195 }21962197 if ("breach" in patch) {2198 nextPatch.breach = patch.breach;2199 }22002201 if ("canary" in patch) {2202 nextPatch.canary = patch.canary;2203 }22042205 if ("passkey" in patch) {2206 2207 2208 2209 nextPatch.passkey = patch.passkey;2210 }22112212 if ("tags" in patch) {2213 nextPatch.tags = patch.tags;2214 }22152216 if ("cardholderName" in patch) {2217 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2218 }2219 if ("cardNumber" in patch) {2220 const n = patch.cardNumber ?? "";2221 nextPatch.cardNumber = n || undefined;2222 }2223 if ("expMonth" in patch) {2224 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2225 }2226 if ("expYear" in patch) {2227 nextPatch.expYear = patch.expYear?.trim() || undefined;2228 }2229 if ("cvc" in patch) {2230 nextPatch.cvc = patch.cvc?.trim() || undefined;2231 }2232 if ("cardNickname" in patch) {2233 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2234 }2235 if ("network" in patch) {2236 nextPatch.network = patch.network;2237 }2238 if ("last4" in patch) {2239 nextPatch.last4 = patch.last4?.trim() || undefined;2240 }22412242 return nextPatch;2243}22442245function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2246 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2247 const modifiedAt =2248 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;22492250 const base: VaultEntry = {2251 id: entry.id,2252 type: "type" in entry ? entry.type : "login",2253 title: entry.title,2254 username: entry.username,2255 email: "email" in entry ? entry.email : undefined,2256 password: entry.password,2257 url: entry.url,2258 additionalUrls:2259 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2260 ? entry.additionalUrls2261 : undefined,2262 totp: entry.totp,2263 notes: entry.notes,2264 breach: entry.breach,2265 2266 2267 2268 passwordChangedAt:2269 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2270 2271 2272 2273 passwordHistory:2274 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2275 ? entry.passwordHistory2276 : undefined,2277 canary: "canary" in entry ? entry.canary : undefined,2278 passkey: "passkey" in entry ? entry.passkey : undefined,2279 createdAt,2280 modifiedAt,2281 deletedAt: entry.deletedAt,2282 tags: "tags" in entry ? entry.tags : undefined,2283 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2284 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2285 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2286 expYear: "expYear" in entry ? entry.expYear : undefined,2287 cvc: "cvc" in entry ? entry.cvc : undefined,2288 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2289 network: "network" in entry ? entry.network : undefined,2290 last4: "last4" in entry ? entry.last4 : undefined,2291 };22922293 return migrateLegacyPasskey(base, entry);2294}229522962297229822992300230123022303function migrateLegacyPasskey(2304 base: VaultEntry,2305 raw: VaultEntry | LegacyVaultEntry2306): VaultEntry {2307 if (base.passkey) return base;2308 const flat = raw as Partial<VaultEntry>;2309 const isPasskeyType = base.type === "passkey";2310 const hasFlatPasskeyFields =2311 typeof flat.rpId === "string" &&2312 typeof flat.credentialId === "string" &&2313 typeof flat.publicKeyCose === "string" &&2314 typeof flat.userHandle === "string" &&2315 typeof flat.userName === "string";2316 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2317 if (!hasFlatPasskeyFields) return base;23182319 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2320 base.passkey = {2321 rpId: flat.rpId!,2322 rpName: flat.rpName ?? flat.rpId!,2323 credentialId: flat.credentialId!,2324 publicKeyCose: flat.publicKeyCose!,2325 privateKeyJwk: flat.privateKeyJwk,2326 userHandle: flat.userHandle!,2327 userName: flat.userName!,2328 userDisplayName: flat.userDisplayName ?? flat.userName!,2329 algorithm,2330 signCount: flat.signCount ?? 0,2331 aaguid: flat.aaguid ?? "",2332 backupEligible: flat.backupEligible === true,2333 backupState: flat.backupState === true,2334 lastUsedAt: flat.lastUsedAt,2335 browser: flat.browser,2336 transports: flat.transports,2337 createdAt: base.createdAt,2338 };2339 2340 2341 2342 if (isPasskeyType) base.type = "login";2343 if (!base.username) base.username = flat.userName;2344 if (!base.url) base.url = `https://${flat.rpId}`;2345 return base;2346}2347234823492350235123522353function entryDupeKey(e: VaultEntry): string {2354 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2355 2356 2357 2358 2359 if (e.passkey) {2360 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2361 }2362 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2363}236423652366export function countDuplicates(): number {2367 const best = new Map<string, VaultEntry>();2368 let dupeCount = 0;2369 for (const e of entries.filter((e) => !e.deletedAt)) {2370 const key = entryDupeKey(e);2371 const existing = best.get(key);2372 if (!existing) {2373 best.set(key, e);2374 } else {2375 dupeCount++;2376 2377 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2378 best.set(key, e);2379 }2380 }2381 }2382 return dupeCount;2383}238423852386238723882389export async function deduplicateEntries(): Promise<number> {2390 if (!derivedKey) throw new Error("vault_locked");2391 2392 2393 if (vaultMode === "decoy") {2394 throw new Error("deduplicate disabled in decoy mode");2395 }23962397 const best = new Map<string, VaultEntry>();2398 for (const e of entries.filter((en) => !en.deletedAt)) {2399 const key = entryDupeKey(e);2400 const existing = best.get(key);2401 if (!existing) {2402 best.set(key, e);2403 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2404 best.set(key, e);2405 }2406 }24072408 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2409 let removed = 0;2410 entries = entries.map((e) => {2411 if (!e.deletedAt && !keepIds.has(e.id)) {2412 removed++;2413 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2414 }2415 return e;2416 });24172418 if (removed > 0) await persistEntries();2419 return removed;2420}242124222423242424252426242724282429const HISTORY_KEY = "peach_vault_history";2430const HISTORY_MAX = 5;24312432243324342435243624372438export async function takeVaultSnapshot(2439 reason: VaultSnapshot["reason"],2440): Promise<void> {2441 try {2442 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2443 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2444 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];24452446 2447 2448 2449 if (!slotA) return;24502451 const vault_a = JSON.stringify(slotA);2452 const vault_b = JSON.stringify(slotB ?? slotA);24532454 2455 2456 2457 let entryCount: number | null = null;2458 if (derivedKey && vaultMode === "real") {2459 try {2460 entryCount = entries.filter((e) => !e.deletedAt).length;2461 } catch {2462 entryCount = null;2463 }2464 }24652466 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2467 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2468 ? existingRaw2469 : { snapshots: [] };24702471 2472 2473 2474 const latest = history.snapshots[0];2475 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2476 return;2477 }24782479 const snapshot: VaultSnapshot = {2480 id: crypto.randomUUID(),2481 savedAt: Date.now(),2482 reason,2483 vault_a,2484 vault_b,2485 entryCount,2486 };24872488 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2489 await chrome.storage.local.set({ [HISTORY_KEY]: history });2490 } catch (err) {2491 2492 2493 2494 2495 console.warn('[Peach] takeVaultSnapshot failed:', err);2496 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2497 try {2498 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2499 } catch {2500 2501 }2502 }2503 }2504}250525062507export async function clearVaultHistory(): Promise<void> {2508 try {2509 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2510 } catch {2511 2512 }2513}251425152516export async function getVaultHistory(): Promise<VaultSnapshot[]> {2517 const raw = await chrome.storage.local.get(HISTORY_KEY);2518 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2519 if (!history || !Array.isArray(history.snapshots)) return [];2520 return history.snapshots;2521}25222523252425252526252725282529export async function restoreVaultSnapshot(2530 snapshotId: string,2531): Promise<{ ok: true } | { ok: false; error: string }> {2532 const history = await getVaultHistory();2533 const snapshot = history.find((s) => s.id === snapshotId);2534 if (!snapshot) return { ok: false, error: "snapshot_not_found" };25352536 2537 2538 2539 await takeVaultSnapshot("restore");25402541 let restoredA: StoredVault;2542 let restoredB: StoredVault;2543 try {2544 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2545 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2546 } catch {2547 return { ok: false, error: "snapshot_corrupt" };2548 }25492550 await writeSlot(SLOT_A, restoredA);2551 await writeSlot(SLOT_B, restoredB);2552 await stampLocalVaultMutation();25532554 2555 2556 2557 lock();25582559 return { ok: true };2560}25612562256325642565256625672568export async function changePassword(2569 currentPassword: string,2570 newPassword: string2571): Promise<boolean> {2572 if (!derivedKey || vaultMode !== "real") return false;25732574 2575 const valid = await verifyMainPassword(currentPassword);2576 if (!valid) return false;25772578 const slotA = await loadSlot(SLOT_A);2579 if (!slotA) return false;2580 if (!rootKey) return false;25812582 2583 const newSalt = generateSalt();2584 const newKey = await deriveKey(newPassword, newSalt);25852586 2587 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2588 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");25892590 2591 2592 2593 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);25942595 const finalSlotA: StoredVault = {2596 ...slotA,2597 salt: hex(newSalt),2598 nonce,2599 ciphertext,2600 tag,2601 ...pwRoot,2602 };26032604 2605 2606 2607 await takeVaultSnapshot("local_save");26082609 2610 2611 2612 2613 await writeSlot(SLOT_A, finalSlotA);26142615 2616 2617 2618 zeroKey(derivedKey);2619 derivedKey = newKey;26202621 2622 2623 2624 if (!duressConfigured) {2625 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2626 }2627 await stampLocalVaultMutation();26282629 return true;2630}