123456789101112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import {15 rootKeyToWords,16 wordsToRootKeyMaterial,17 deriveRecoveryKey,18} from "../shared/recovery-words";19import type {20 AccentColorName,21 LegacyVaultEntry,22 ManualSummonRecord,23 SavedIdentity,24 StoredVault,25 VaultEntry,26 VaultHistory,27 VaultSnapshot,28} from "../shared/types";29import { decrypt, encrypt, zeroKey } from "./aes";30import { clearAutoLock, touchAutoLock } from "./alarms";3132type EntryDraft = Omit<33 VaultEntry,34 "id" | "createdAt" | "modifiedAt" | "deletedAt"35>;363738394041const SLOT_A = "peach_vault_a";42const SLOT_B = "peach_vault_b";434445const LEGACY_MAIN = "peach_vault";46const LEGACY_DECOY = "peach_decoy_vault";47const LEGACY_DURESS_FLAG = "peach_duress";48495051const PAD_BLOCK = 1024;5253let derivedKey: Uint8Array | null = null;5455565758let rootKey: Uint8Array | null = null;59let entries: VaultEntry[] = [];60let domainIndex: Map<string, VaultEntry[]> = new Map();61let vaultMode: "real" | "decoy" = "real";62636465let duressConfigured = false;6667let migrationRan = false;6869707172737475767778let lastUnlockFailureReason: string | null = null;7980export function consumeLastUnlockFailureReason(): string | null {81 const r = lastUnlockFailureReason;82 lastUnlockFailureReason = null;83 return r;84}858687888990export type TextSize = "small" | "medium" | "large";9192export interface VaultSettings {93 accentColor?: AccentColorName;94 theme?: "dark" | "light";95 autoLockMinutes?: number;96 clipboardClearSeconds?: number;97 lockOnClose?: boolean;98 breachMonitoring?: boolean;99 passkeyProviderEnabled?: boolean;100 trashRetentionDays?: number;101 manualSummonDomains?: Record<string, ManualSummonRecord>;102 declinedCardFingerprints?: string[];103 104105106107108 textSize?: TextSize;109 110111112113114 reduceMotion?: boolean;115 116117118119120 biometricsEnabled?: boolean;121 122123124125126127128 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;129}130131let settings: VaultSettings = {};132133export function getSettings(): VaultSettings {134 return settings;135}136137export async function ensureSettingsLoaded(): Promise<VaultSettings> {138 await loadSettings();139 return settings;140}141142export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {143 await loadSettings();144 settings = { ...settings, ...patch };145 await persistSettings();146}147148async function persistSettings(): Promise<void> {149 try {150 await chrome.storage.local.set({ peach_settings: settings });151 } catch (err) {152 throw mapStorageError(err);153 }154}155156function mapStorageError(err: unknown): Error {157 const message = err instanceof Error ? err.message : String(err);158 if (/QUOTA_BYTES|quota/i.test(message)) {159 return new Error("STORAGE_QUOTA_EXCEEDED");160 }161 return err instanceof Error ? err : new Error(message);162}163164async function loadSettings(): Promise<void> {165 const result = await chrome.storage.local.get("peach_settings");166 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};167}168169170171172173174function fireSyncTrigger(reason: "vault_change"): void {175 void import("./sync-trigger")176 .then((mod) => mod.triggerSyncIfReachable({ reason }))177 .catch(() => {178 179 });180}181182183184185186export async function checkBreach(password: string): Promise<number> {187 188 189 const encoder = new TextEncoder();190 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));191 const hashHex = Array.from(new Uint8Array(hashBuffer))192 .map((b) => b.toString(16).padStart(2, "0"))193 .join("")194 .toUpperCase();195196 const prefix = hashHex.slice(0, 5);197 const suffix = hashHex.slice(5);198199 try {200 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);201 if (!res.ok) return 0;202 const text = await res.text();203 const match = text.split("\r\n").find((l) => l.startsWith(suffix));204 return match ? parseInt(match.split(":")[1], 10) : 0;205 } catch {206 return 0; 207 }208}209210export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {211 const fingerprint = await cardFingerprint(last4, expMonth, expYear);212 await loadSettings();213 settings = {214 ...settings,215 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],216 };217 await persistSettings();218}219220export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {221 await loadSettings();222 const fingerprint = await cardFingerprint(last4, expMonth, expYear);223 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);224}225226async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {227 const encoder = new TextEncoder();228 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));229 return Array.from(new Uint8Array(buf).slice(0, 8))230 .map((b) => b.toString(16).padStart(2, "0"))231 .join("");232}233234function hex(buf: Uint8Array): string {235 return Array.from(buf)236 .map((b) => b.toString(16).padStart(2, "0"))237 .join("");238}239240function unhex(s: string): Uint8Array {241 const out = new Uint8Array(s.length / 2);242 for (let i = 0; i < s.length; i += 2) {243 out[i / 2] = parseInt(s.slice(i, i + 2), 16);244 }245 return out;246}247248249250251252253254255256257258259async function generateAndWrapRootKey(): Promise<{260 rawRootKey: string; 261 recoverySalt: string;262 wrappedRootKey: string;263 wrappedNonce: string;264 wrappedTag: string;265}> {266 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));267268 269 const words = await rootKeyToWords(rootKeyBytes);270 const wordMaterial = await wordsToRootKeyMaterial(words);271 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));272 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);273274 275 276 const nonce = crypto.getRandomValues(new Uint8Array(12));277 const encoded = new TextEncoder().encode(hex(rootKeyBytes));278 const raw = await crypto.subtle.encrypt(279 {280 name: "AES-GCM",281 iv: nonce,282 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,283 tagLength: 128,284 },285 recoveryKey,286 encoded287 );288 289 const rawU8 = new Uint8Array(raw);290 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));291 const tag = hex(rawU8.slice(rawU8.length - 16));292293 return {294 rawRootKey: hex(rootKeyBytes),295 recoverySalt: hex(recoverySalt),296 wrappedRootKey: ciphertext,297 wrappedNonce: hex(nonce),298 wrappedTag: tag,299 };300}301302303304305306307export async function unwrapRootKeyForRecovery(308 wrappedRootKey: string,309 wrappedNonce: string,310 wrappedTag: string,311 recoverySaltHex: string,312 words: string[]313): Promise<void> {314 const wordMaterial = await wordsToRootKeyMaterial(words);315 const recoverySalt = unhex(recoverySaltHex);316 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);317318 319 320 321 322 323 const nonceRaw = unhex(wrappedNonce);324 const ciphertextRaw = unhex(wrappedRootKey);325 const tagRaw = unhex(wrappedTag);326 327 328 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;329 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);330 combined.set(ciphertextRaw, 0);331 combined.set(tagRaw, ciphertextRaw.length);332 const raw = await crypto.subtle.decrypt(333 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },334 recoveryKey,335 combined336 );337 const decryptedHex = new TextDecoder().decode(raw);338 rootKey = unhex(decryptedHex);339}340341342343344345346347348349350export async function rewrapRootKeyForPassword(351 newDerivedKey: Uint8Array,352 _newSalt: Uint8Array353): Promise<void> {354 if (!rootKey) throw new Error("root key not set");355 const slotA = await loadSlot(SLOT_A);356 if (!slotA) throw new Error("missing slot A");357 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);358 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });359}360361362363364365366async function reencryptVaultForNewKey(367 newKey: Uint8Array,368 slotA: StoredVault369): Promise<void> {370 const plaintext = encodePayload(entries, duressConfigured);371 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");372 await writeSlot(SLOT_A, {373 ...slotA,374 nonce,375 ciphertext,376 tag,377 });378}379380381382383384385export async function changePasswordWithNewKey(386 newSalt: Uint8Array,387 newKey: Uint8Array388): Promise<void> {389 const slotA = await loadSlot(SLOT_A);390 if (!slotA) throw new Error("missing slot A");391 392 393 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });394 if (derivedKey) zeroKey(derivedKey);395 derivedKey = newKey;396 397 398 399 400 if (rootKey) {401 const slotAfter = await loadSlot(SLOT_A);402 if (slotAfter) {403 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);404 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });405 }406 }407 if (!duressConfigured) {408 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));409 }410 await stampLocalVaultMutation();411}412413414415416417418export { deriveKey, generateSalt };419420export async function loadSlotA(): Promise<StoredVault | null> {421 return loadSlot(SLOT_A);422}423424425426427428429430431432433434435436async function deriveRootKeyFromDerivedKey(437 derivedKeyBytes: Uint8Array,438 slotA: StoredVault,439): Promise<Uint8Array | null> {440 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {441 return null;442 }443 try {444 const rootHex = await decrypt(445 derivedKeyBytes,446 slotA.pw_root_nonce,447 slotA.pw_root_ciphertext,448 slotA.pw_root_tag,449 "pw_root_v1",450 );451 return unhex(rootHex);452 } catch {453 return null;454 }455}456457458459async function wrapRootKeyForDerivedKey(460 derivedKeyBytes: Uint8Array,461 rootKeyBytes: Uint8Array,462): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {463 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");464 return {465 pw_root_ciphertext: ciphertext,466 pw_root_nonce: nonce,467 pw_root_tag: tag,468 };469}470471472473474475476477478export async function migrateToRecovery(): Promise<void> {479 if (!derivedKey || vaultMode !== "real") {480 throw new Error("vault must be unlocked in real mode");481 }482 const slotA = await loadSlot(SLOT_A);483 if (!slotA) throw new Error("missing slot A");484 const hasRecoveryEscrow =485 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;486 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;487 if (hasRecoveryEscrow && hasPwRoot) return; 488489 490 491 492 493 494 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =495 await generateAndWrapRootKey();496 rootKey = unhex(rawRootKey);497498 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);499 const nextSlotA: StoredVault = {500 ...slotA,501 recovery_salt: recoverySalt,502 recovery_wrapped_root_key: wrappedRootKey,503 recovery_nonce: wrappedNonce,504 recovery_tag: wrappedTag,505 ...pwRoot,506 };507 await writeSlot(SLOT_A, nextSlotA);508 await stampLocalVaultMutation();509}510511512513514515516export async function getRecoveryWords(): Promise<string[]> {517 if (!rootKey) {518 throw new Error("recovery not set up — vault may need migration");519 }520 return rootKeyToWords(rootKey);521}522523async function loadSlot(key: string): Promise<StoredVault | null> {524 const result = await chrome.storage.local.get(key);525 return (result as Record<string, StoredVault | undefined>)[key] ?? null;526}527528async function writeSlot(key: string, vault: StoredVault): Promise<void> {529 try {530 await chrome.storage.local.set({ [key]: vault });531 } catch (err) {532 throw mapStorageError(err);533 }534}535536async function stampLocalVaultMutation(): Promise<void> {537 const raw = await chrome.storage.local.get("peach_last_modified_at");538 const previous = Number(539 (raw as Record<string, unknown>).peach_last_modified_at,540 );541 const next = Math.max(542 Date.now(),543 Number.isFinite(previous) ? previous + 1 : 0,544 );545 await chrome.storage.local.set({546 peach_last_modified_at: String(next),547 });548}549550551552553554555async function ensureMigrated(): Promise<void> {556 if (migrationRan) return;557 migrationRan = true;558559 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];560 const raw = await chrome.storage.local.get(keys);561 const slotAExists = raw[SLOT_A] !== undefined;562 const slotBExists = raw[SLOT_B] !== undefined;563 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;564 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;565566 567 if (!slotAExists && !legacyMain) return;568569 570 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;571572 let didMigrate = false;573 if (legacyMain && !slotAExists) {574 await writeSlot(SLOT_A, legacyMain);575 didMigrate = true;576 }577578 579 if (!slotBExists) {580 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;581 if (legacyDecoy) {582 await writeSlot(SLOT_B, legacyDecoy);583 didMigrate = true;584 } else if (target) {585 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));586 didMigrate = true;587 }588 }589590 const toRemove: string[] = [];591 if (legacyMain) toRemove.push(LEGACY_MAIN);592 if (legacyDecoy) toRemove.push(LEGACY_DECOY);593 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);594 if (toRemove.length > 0) {595 await chrome.storage.local.remove(toRemove);596 didMigrate = true;597 }598 if (didMigrate) {599 await stampLocalVaultMutation();600 }601}602603604605606607608609610611612613function padPlaintext(json: string): string {614 const encoded = new TextEncoder().encode(json);615 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);616 const padLen = target - encoded.length;617 return json + " ".repeat(padLen);618}619620621622623624625626627628629630const CSPRNG_CHUNK = 65_536;631632function fillRandom(buf: Uint8Array): Uint8Array {633 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {634 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);635 const chunk = new Uint8Array(end - offset);636 crypto.getRandomValues(chunk);637 buf.set(chunk, offset);638 }639 return buf;640}641642function generateNoiseBlob(ciphertextBytes: number): StoredVault {643 const salt = crypto.getRandomValues(new Uint8Array(16));644 const nonce = crypto.getRandomValues(new Uint8Array(12));645 const cipher = fillRandom(new Uint8Array(ciphertextBytes));646 const tag = crypto.getRandomValues(new Uint8Array(16));647 return {648 version: 1,649 salt: hex(salt),650 nonce: hex(nonce),651 ciphertext: hex(cipher),652 tag: hex(tag),653 };654}655656657658659660type VaultPayload = {661 entries: VaultEntry[];662 duressConfigured?: boolean;663};664665type DecryptedPayload = {666 entries: VaultEntry[];667 duressConfigured: boolean;668 669670671672673674675 migratedFromLegacy: boolean;676};677678function encodePayload(entries: VaultEntry[], duress: boolean): string {679 const envelope: VaultPayload = { entries, duressConfigured: duress };680 return padPlaintext(JSON.stringify(envelope));681}682683function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {684 if ("created" in raw || "updated" in raw) return true;685 const flat = raw as Partial<VaultEntry>;686 if (flat.type === "passkey") return true;687 688 if (flat.rpId !== undefined && flat.passkey === undefined) return true;689 return false;690}691692function decodePayload(plain: string): DecryptedPayload {693 const parsed = JSON.parse(plain) as694 | Array<VaultEntry | LegacyVaultEntry>695 | VaultPayload;696697 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];698 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);699700 const normalized = rawEntries.map(normalizeEntry);701 const { merged } = mergePasskeyOnlyIntoLogins(normalized);702703 if (Array.isArray(parsed)) {704 return {705 entries: normalized,706 duressConfigured: false,707 migratedFromLegacy: true, 708 };709 }710711 return {712 entries: normalized,713 duressConfigured: parsed.duressConfigured === true,714 migratedFromLegacy: flatMigrationNeeded || merged,715 };716}717718719720721722723724725726727728729730731732733734735736737738739740741function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {742 let merged = false;743 const now = Date.now();744745 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);746 const byRpId = new Map<string, VaultEntry[]>();747 for (const e of candidates) {748 const rpId = e.passkey!.rpId;749 const list = byRpId.get(rpId);750 if (list) list.push(e);751 else byRpId.set(rpId, [e]);752 }753754 for (const [rpId, group] of byRpId) {755 if (group.length === 0) continue;756 group.sort((a, b) => {757 const aT =758 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;759 const bT =760 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;761 return bT - aT;762 });763 const winner = group[0]!;764765 766 767 768 const loginTarget = entries.find((e) => {769 if (e.deletedAt || e.passkey || e.type !== "login") return false;770 if (e.url && extractDomain(e.url) === rpId) return true;771 for (const extra of e.additionalUrls ?? []) {772 if (extractDomain(extra) === rpId) return true;773 }774 return false;775 });776777 if (loginTarget) {778 loginTarget.passkey = winner.passkey;779 loginTarget.modifiedAt = now;780 for (const dup of group) {781 if (!dup.deletedAt) {782 dup.deletedAt = now;783 dup.modifiedAt = now;784 }785 }786 merged = true;787 } else if (group.length > 1) {788 for (let i = 1; i < group.length; i += 1) {789 const dup = group[i]!;790 if (!dup.deletedAt) {791 dup.deletedAt = now;792 dup.modifiedAt = now;793 }794 }795 merged = true;796 }797 }798799 return { merged };800}801802803804805806807808function hasValidVaultSchema(v: unknown): v is StoredVault {809 if (typeof v !== "object" || v === null) return false;810 const r = v as Record<string, unknown>;811 return (812 r.version === 1 &&813 typeof r.salt === "string" &&814 typeof r.nonce === "string" &&815 typeof r.ciphertext === "string" &&816 typeof r.tag === "string"817 );818}819820export type TryDecryptResult =821 | { status: "ok"; payload: DecryptedPayload }822 | { status: "wrong_key" }823 | { status: "corrupted"; reason: string };824825826827828829830export async function tryDecryptSlot(831 vault: StoredVault,832 key: Uint8Array,833 aadTag?: string,834): Promise<DecryptedPayload | null> {835 const res = await tryDecryptSlotResult(vault, key, aadTag);836 return res.status === "ok" ? res.payload : null;837}838839export async function tryDecryptSlotResult(840 vault: StoredVault,841 key: Uint8Array,842 aadTag?: string,843): Promise<TryDecryptResult> {844 if (!hasValidVaultSchema(vault)) {845 return { status: "corrupted", reason: "invalid_slot_schema" };846 }847 let plain: string;848 try {849 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);850 } catch {851 return { status: "wrong_key" };852 }853 try {854 const parsed = JSON.parse(plain) as unknown;855 if (typeof parsed !== "object" || parsed === null) {856 return { status: "corrupted", reason: "invalid_payload_shape" };857 }858 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {859 return { status: "corrupted", reason: "invalid_payload_shape" };860 }861 return { status: "ok", payload: decodePayload(plain) };862 } catch {863 return { status: "corrupted", reason: "invalid_payload_json" };864 }865}866867868869870871function buildDomainIndexSync(all: VaultEntry[]): void {872 domainIndex = new Map();873874 for (const entry of all) {875 if (entry.deletedAt) continue;876 if (entry.type === "note" || entry.type === "card") continue;877878 879 880 881 882 const domains = new Set<string>();883 const primary = extractDomain(entry.url ?? "");884 if (primary) domains.add(primary);885 for (const extra of entry.additionalUrls ?? []) {886 const d = extractDomain(extra);887 if (d) domains.add(d);888 }889 if (domains.size === 0) continue;890891 for (const domain of domains) {892 const bucket = domainIndex.get(domain) ?? [];893 bucket.push(entry);894 domainIndex.set(domain, bucket);895 }896 }897}898899export async function createVault(password: string): Promise<void> {900 await ensureMigrated();901902 const salt = generateSalt();903 derivedKey = await deriveKey(password, salt);904 entries = [];905 vaultMode = "real";906 duressConfigured = false;907 buildDomainIndexSync(entries);908909 910 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =911 await generateAndWrapRootKey();912 rootKey = unhex(rawRootKey);913914 const plaintext = encodePayload(entries, duressConfigured);915 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");916917 918 919 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);920921 const slotA: StoredVault = {922 version: 1,923 salt: hex(salt),924 nonce,925 ciphertext,926 tag,927 recovery_salt: recoverySalt,928 recovery_wrapped_root_key: wrappedRootKey,929 recovery_nonce: wrappedNonce,930 recovery_tag: wrappedTag,931 ...pwRoot,932 };933 await writeSlot(SLOT_A, slotA);934935 936 937 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));938 await stampLocalVaultMutation();939940 touchAutoLock();941}942943export async function hasVault(): Promise<boolean> {944 await ensureMigrated();945 return (await loadSlot(SLOT_A)) !== null;946}947948949950951952953export async function isDuressEnabled(): Promise<boolean> {954 return vaultMode === "real" && duressConfigured;955}956957export async function setupDuress(duressPassword: string): Promise<void> {958 if (!derivedKey || vaultMode !== "real") {959 throw new Error("vault must be unlocked in real mode");960 }961 await ensureMigrated();962963 964 965 966 const slotA = await loadSlot(SLOT_A);967 if (!slotA) throw new Error("missing slot A");968969 const decoySalt = generateSalt();970 const decoyKey = await deriveKey(duressPassword, decoySalt);971 try {972 973 974 const targetBytes = slotA.ciphertext.length / 2;975 let plaintext = encodePayload([], false);976 const encoded = new TextEncoder().encode(plaintext);977 if (encoded.length < targetBytes) {978 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);979 }980 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");981 await writeSlot(SLOT_B, {982 version: 1,983 salt: hex(decoySalt),984 nonce,985 ciphertext,986 tag,987 });988 } finally {989 zeroKey(decoyKey);990 }991992 993 994 duressConfigured = true;995 await persistSlotA();996}997998export async function disableDuress(): 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) {1009 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1010 }10111012 duressConfigured = false;1013 await persistSlotA();1014}10151016101710181019102010211022export async function verifyMainPassword(password: string): Promise<boolean> {1023 await ensureMigrated();1024 const slotA = await loadSlot(SLOT_A);1025 if (!slotA) return false;1026 const key = await deriveKey(password, unhex(slotA.salt));1027 try {1028 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1029 } finally {1030 zeroKey(key);1031 }1032}10331034export async function unlock(password: string): Promise<boolean> {1035 await ensureMigrated();10361037 1038 1039 1040 1041 1042 const [slotA, slotB] = await Promise.all([1043 loadSlot(SLOT_A),1044 loadSlot(SLOT_B),1045 ]);10461047 1048 const [keyA, keyB] = await Promise.all([1049 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),1050 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),1051 ]);10521053 1054 1055 1056 const [resultA, resultB] = await Promise.all([1057 keyA && slotA1058 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1059 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1060 keyB && slotB1061 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1062 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1063 ]);1064 const payloadA = resultA.status === "ok" ? resultA.payload : null;1065 const payloadB = resultB.status === "ok" ? resultB.payload : null;10661067 if (payloadA && keyA) {1068 derivedKey = keyA;1069 void saveSessionKey(keyA, "real");1070 vaultMode = "real";1071 entries = payloadA.entries;1072 duressConfigured = payloadA.duressConfigured;1073 buildDomainIndexSync(entries);1074 await loadSettings();1075 touchAutoLock();1076 if (keyB) zeroKey(keyB);10771078 1079 if (slotA) {1080 const needsRecoverySetup =1081 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1082 if (!needsRecoverySetup) {1083 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1084 }1085 1086 1087 1088 1089 if (needsRecoverySetup || !rootKey) {1090 try {1091 await migrateToRecovery();1092 if (needsRecoverySetup) {1093 1094 1095 1096 await chrome.storage.local.set({ peach_recovery_pending: '1' });1097 }1098 } catch (migrationErr) {1099 console.warn('[Peach] recovery migration failed:', migrationErr);1100 }1101 }1102 }11031104 1105 1106 1107 if (payloadA.migratedFromLegacy) {1108 await persistSlotA();1109 }1110 return true;1111 }11121113 if (payloadB && keyB) {1114 derivedKey = keyB;1115 void saveSessionKey(keyB, "decoy");1116 vaultMode = "decoy";1117 entries = payloadB.entries;1118 duressConfigured = false;1119 buildDomainIndexSync(entries);1120 await loadSettings();1121 touchAutoLock();1122 if (keyA) zeroKey(keyA);1123 return true;1124 }11251126 if (keyA) zeroKey(keyA);1127 if (keyB) zeroKey(keyB);1128 1129 1130 if (resultA.status === "corrupted") {1131 lastUnlockFailureReason = resultA.reason;1132 } else if (resultB.status === "corrupted") {1133 lastUnlockFailureReason = resultB.reason;1134 }1135 return false;1136}11371138113911401141114211431144const SESSION_KEY_STORAGE = "peach_session_dk";11451146async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1147 if (!chrome.storage.session) return;1148 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1149}11501151async function clearSessionKey(): Promise<void> {1152 if (!chrome.storage.session) return;1153 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1154}11551156115711581159116011611162export async function restoreFromSession(): Promise<boolean> {1163 if (derivedKey !== null) return true;1164 if (!chrome.storage.session) return false;1165 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1166 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1167 if (!entry?.keyHex) return false;11681169 const key = unhex(entry.keyHex);1170 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1171 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1172 const slot = await loadSlot(slotKey);1173 if (!slot) { await clearSessionKey(); return false; }11741175 const result = await tryDecryptSlotResult(slot, key, aadTag);1176 if (result.status !== "ok") { await clearSessionKey(); return false; }11771178 derivedKey = key;1179 vaultMode = entry.mode;1180 entries = result.payload.entries;1181 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1182 buildDomainIndexSync(entries);1183 await loadSettings();1184 touchAutoLock();11851186 if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1187 try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { }1188 }11891190 return true;1191}11921193export function lock(): void {1194 void clearSessionKey();1195 if (derivedKey) {1196 zeroKey(derivedKey);1197 derivedKey = null;1198 }1199 if (rootKey) {1200 rootKey.fill(0);1201 rootKey = null;1202 }12031204 entries = [];1205 domainIndex = new Map();1206 vaultMode = "real";1207 duressConfigured = false;1208 clearAutoLock();1209}12101211export function isUnlocked(): boolean {1212 return derivedKey !== null;1213}1214121512161217export async function unlockWithKey(1218 masterKeyHex: string,1219): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1220 try {1221 const masterKeyBytes = unhex(masterKeyHex);1222 const stored = await loadSlot(SLOT_A);1223 if (!stored) return { ok: false, error: "invalid_key" };1224 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1225 if (!payload) return { ok: false, error: "invalid_key" };12261227 derivedKey = masterKeyBytes;1228 vaultMode = "real";1229 entries = payload.entries;1230 duressConfigured = payload.duressConfigured;1231 buildDomainIndexSync(entries);1232 await loadSettings();1233 touchAutoLock();1234 return { ok: true, entries };1235 } catch {1236 return { ok: false, error: "invalid_key" };1237 }1238}12391240124112421243124412451246124712481249125012511252125312541255125612571258export async function unlockFromCodex(params: {1259 slotA: string; 1260 slotB: string | null; 1261 timestamp: number;1262 masterPassphrase: string;1263}): Promise<1264 | { ok: true; entryCount: number }1265 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1266> {1267 let parsedSlotA: StoredVault;1268 let parsedSlotB: StoredVault | null = null;1269 try {1270 const rawA = JSON.parse(params.slotA) as unknown;1271 if (!hasValidVaultSchema(rawA)) {1272 return { ok: false, reason: "invalid_slot_format" };1273 }1274 parsedSlotA = rawA;1275 if (params.slotB !== null) {1276 const rawB = JSON.parse(params.slotB) as unknown;1277 if (!hasValidVaultSchema(rawB)) {1278 return { ok: false, reason: "invalid_slot_format" };1279 }1280 parsedSlotB = rawB;1281 }1282 } catch {1283 return { ok: false, reason: "invalid_slot_format" };1284 }12851286 1287 1288 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1289 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1290 if (!trialPayload) {1291 zeroKey(trialKey);1292 return { ok: false, reason: "wrong_passphrase" };1293 }1294 1295 1296 zeroKey(trialKey);12971298 1299 1300 await takeVaultSnapshot("restore");13011302 await writeSlot(SLOT_A, parsedSlotA);1303 if (parsedSlotB) {1304 await writeSlot(SLOT_B, parsedSlotB);1305 } else {1306 1307 1308 1309 await writeSlot(1310 SLOT_B,1311 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1312 );1313 }1314 await chrome.storage.local.set({1315 peach_last_modified_at: String(params.timestamp),1316 });13171318 1319 1320 const ok = await unlock(params.masterPassphrase);1321 if (!ok) {1322 1323 1324 1325 return { ok: false, reason: "wrong_passphrase" };1326 }1327 return { ok: true, entryCount: getEntries().length };1328}1329133013311332export function getDerivedKeyHex(): string {1333 if (!derivedKey) throw new Error("vault_not_unlocked");1334 return hex(derivedKey);1335}13361337export function getEntries(): VaultEntry[] {1338 if (!derivedKey) throw new Error("vault_locked");1339 return entries.filter((entry) => !entry.deletedAt);1340}13411342const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;13431344export function getIdentities(): SavedIdentity[] {1345 if (!derivedKey) throw new Error("vault_locked");1346 const active = entries.filter((e) => !e.deletedAt && e.type === "login");13471348 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1349 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();13501351 for (const entry of active) {1352 const domain = extractDomain(entry.url ?? "") ?? "";1353 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;13541355 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1356 const existing = map.get(v);1357 if (!existing || modifiedAt > existing.lastUsed) {1358 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1359 } else {1360 map.set(v, { ...existing, count: existing.count + 1 });1361 }1362 }13631364 const uVal = entry.username?.trim();1365 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);13661367 1368 1369 const eVal = entry.email?.trim();1370 if (eVal) tally(emailMap, eVal);1371 }13721373 const toIdentity = (type: "email" | "username") =>1374 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1375 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1376 });13771378 return [1379 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1380 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1381 ];1382}13831384export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1385 if (!derivedKey) throw new Error("vault_locked");13861387 const now = Date.now();1388 const added: VaultEntry[] = [];1389 for (const draft of drafts) {1390 try {1391 const sanitized = sanitizeEntryDraft(draft);1392 const nextEntry: VaultEntry = {1393 ...sanitized,1394 id: crypto.randomUUID(),1395 createdAt: now,1396 modifiedAt: now,1397 };1398 entries.push(nextEntry);1399 added.push(nextEntry);1400 } catch {1401 1402 }1403 }14041405 if (added.length > 0) {1406 await persistEntries();1407 }14081409 return added;1410}141114121413141414151416141714181419export async function restoreEntries(1420 incoming: VaultEntry[],1421 mode: "replace" | "merge"1422): Promise<{ added: number; updated: number; skipped: number }> {1423 if (!derivedKey) throw new Error("vault_locked");14241425 1426 1427 1428 if (mode === "replace" && incoming.length === 0) {1429 throw new Error("refuse to replace with empty entry set");1430 }14311432 let added = 0;1433 let updated = 0;1434 let skipped = 0;14351436 if (mode === "replace") {1437 entries = [];1438 for (const entry of incoming) {1439 const normalized = sanitizeRestoredEntry(entry);1440 if (!normalized) {1441 skipped += 1;1442 continue;1443 }1444 entries.push(normalized);1445 added += 1;1446 }1447 } else {1448 const byId = new Map<string, VaultEntry>();1449 for (const e of entries) byId.set(e.id, e);14501451 for (const entry of incoming) {1452 const normalized = sanitizeRestoredEntry(entry);1453 if (!normalized) {1454 skipped += 1;1455 continue;1456 }1457 const existing = byId.get(normalized.id);1458 if (!existing) {1459 entries.push(normalized);1460 added += 1;1461 } else if (normalized.modifiedAt > existing.modifiedAt) {1462 const idx = entries.findIndex((e) => e.id === existing.id);1463 if (idx !== -1) {1464 entries[idx] = normalized;1465 updated += 1;1466 }1467 } else {1468 skipped += 1;1469 }1470 }1471 }14721473 await persistEntries("import");1474 return { added, updated, skipped };1475}1476147714781479148014811482function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1483 if (!entry || typeof entry !== "object") return null;1484 const title = typeof entry.title === "string" ? entry.title.trim() : "";1485 if (!title) return null;1486 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1487 const now = Date.now();1488 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1489 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;14901491 1492 1493 1494 const migrated: VaultEntry = migrateLegacyPasskey(1495 {1496 ...entry,1497 id,1498 title,1499 type: entry.type ?? "login",1500 createdAt,1501 modifiedAt,1502 },1503 entry1504 );15051506 1507 1508 if (1509 migrated.type === "login" &&1510 !migrated.passkey &&1511 typeof migrated.password !== "string"1512 ) {1513 return null;1514 }1515 if (migrated.passkey && !migrated.passkey.credentialId) {1516 return null;1517 }15181519 return migrated;1520}152115221523152415251526export async function clearVaultEntries(1527 confirmation: "REPLACE_ALL"1528): Promise<void> {1529 if (confirmation !== "REPLACE_ALL") {1530 throw new Error("clearVaultEntries requires explicit confirmation");1531 }1532 if (!derivedKey) throw new Error("vault_locked");1533 for (const entry of entries) {1534 if (!entry.deletedAt) {1535 entry.deletedAt = Date.now();1536 entry.modifiedAt = Date.now();1537 }1538 }1539 await persistEntries();1540}15411542export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1543 if (!derivedKey) throw new Error("vault_locked");15441545 const sanitized = sanitizeEntryDraft(entry);1546 const now = Date.now();1547 const nextEntry: VaultEntry = {1548 ...sanitized,1549 id: crypto.randomUUID(),1550 createdAt: now,1551 modifiedAt: now,1552 1553 1554 passwordChangedAt: sanitized.password ? now : undefined,1555 };15561557 1558 1559 1560 const rollback = entries;1561 entries = [...entries, nextEntry];1562 try {1563 await persistEntries();1564 } catch (err) {1565 entries = rollback;1566 throw err;1567 }15681569 1570 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1571 checkBreach(nextEntry.password).then(async (count) => {1572 if (count > 0) {1573 const idx = entries.findIndex((e) => e.id === nextEntry.id);1574 if (idx !== -1) {1575 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1576 await persistEntries();1577 }1578 }1579 });1580 }15811582 fireSyncTrigger("vault_change");15831584 return nextEntry;1585}15861587export async function updateEntry(1588 id: string,1589 patch: Partial<VaultEntry>1590): Promise<void> {1591 if (!derivedKey) throw new Error("vault_locked");15921593 const idx = entries.findIndex((entry) => entry.id === id);1594 if (idx === -1 || entries[idx].deletedAt) {1595 throw new Error("entry_not_found");1596 }15971598 const current = entries[idx];1599 const sanitizedPatch = sanitizeEntryPatch(patch);1600 1601 1602 1603 const passwordChanged =1604 "password" in patch &&1605 typeof sanitizedPatch.password === "string" &&1606 sanitizedPatch.password !== current.password;1607 entries[idx] = {1608 ...current,1609 ...sanitizedPatch,1610 modifiedAt: Date.now(),1611 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1612 };16131614 1615 1616 1617 1618 if (1619 entries[idx].type === "login" &&1620 !entries[idx].password &&1621 !entries[idx].passkey1622 ) {1623 entries[idx] = current;1624 throw new Error("password or passkey required");1625 }16261627 1628 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1629 entries[idx] = current;1630 throw new Error("card number is required for card entries");1631 }16321633 await persistEntries();16341635 1636 1637 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1638 entries[idx] = { ...entries[idx], breach: undefined };1639 await persistEntries();1640 const password = entries[idx].password;1641 if (password) {1642 checkBreach(password).then(async (count) => {1643 const i = entries.findIndex((e) => e.id === id);1644 if (i !== -1 && count > 0) {1645 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1646 await persistEntries();1647 }1648 });1649 }1650 }16511652 fireSyncTrigger("vault_change");1653}16541655export async function deleteEntry(id: string): Promise<void> {1656 if (!derivedKey) throw new Error("vault_locked");16571658 const entry = entries.find((item) => item.id === id);1659 if (!entry || entry.deletedAt) {1660 throw new Error("entry_not_found");1661 }16621663 entry.deletedAt = Date.now();1664 entry.modifiedAt = Date.now();1665 await persistEntries();1666 fireSyncTrigger("vault_change");1667}16681669export function getDeletedEntries(): VaultEntry[] {1670 if (!derivedKey) return [];1671 return entries.filter((e) => !!e.deletedAt);1672}1673167416751676167716781679export async function getVaultVersion(): Promise<number> {1680 try {1681 const raw = await chrome.storage.local.get("peach_last_modified_at");1682 const v = (raw as Record<string, unknown>).peach_last_modified_at;1683 if (typeof v === "number") return v;1684 if (typeof v === "string" && v !== "") {1685 const n = Number.parseInt(v, 10);1686 return Number.isFinite(n) ? n : 0;1687 }1688 return 0;1689 } catch {1690 return 0;1691 }1692}169316941695169616971698169917001701170217031704export async function addEntryDirect(entry: VaultEntry): Promise<void> {1705 if (!derivedKey) throw new Error("vault_locked");1706 entries = [...entries, entry];1707 await persistEntries("sync_received");1708}17091710export async function updateEntryDirect(patch: VaultEntry): Promise<void> {1711 if (!derivedKey) throw new Error("vault_locked");1712 const idx = entries.findIndex((e) => e.id === patch.id);1713 if (idx === -1) throw new Error("entry_not_found");1714 entries[idx] = { ...entries[idx], ...patch };1715 await persistEntries("sync_received");1716}17171718export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {1719 if (!derivedKey) throw new Error("vault_locked");1720 const idx = entries.findIndex((e) => e.id === id);1721 if (idx === -1) throw new Error("entry_not_found");1722 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };1723 await persistEntries("sync_received");1724}1725172617271728172917301731173217331734173517361737export async function applyMergedRemoteEntries(1738 remoteEntries: VaultEntry[],1739 remoteTombstones: Record<string, number>,1740): Promise<{ received: number; applied: number; conflicts: number }> {1741 if (!derivedKey) throw new Error("vault_locked");17421743 const received = remoteEntries.length + Object.keys(remoteTombstones).length;1744 let applied = 0;1745 let conflicts = 0;17461747 const byId = new Map<string, VaultEntry>();1748 for (const e of entries) byId.set(e.id, e);17491750 for (const remote of remoteEntries) {1751 const sanitized = sanitizeRestoredEntry(remote);1752 if (!sanitized) continue;1753 const tombstoneTs = remoteTombstones[sanitized.id];1754 if (tombstoneTs !== undefined && tombstoneTs >= sanitized.modifiedAt) {1755 continue;1756 }1757 const local = byId.get(sanitized.id);1758 if (!local) {1759 entries.push(sanitized);1760 byId.set(sanitized.id, sanitized);1761 applied += 1;1762 } else if (sanitized.modifiedAt > local.modifiedAt) {1763 const idx = entries.findIndex((e) => e.id === local.id);1764 if (idx !== -1) {1765 entries[idx] = sanitized;1766 byId.set(sanitized.id, sanitized);1767 applied += 1;1768 if (!local.deletedAt) conflicts += 1;1769 }1770 }1771 }17721773 for (const [id, deletedAt] of Object.entries(remoteTombstones)) {1774 if (typeof deletedAt !== "number" || !Number.isFinite(deletedAt)) continue;1775 const local = byId.get(id);1776 if (!local) continue;1777 if (local.deletedAt && local.deletedAt >= deletedAt) continue;1778 if (deletedAt < local.modifiedAt) continue;1779 const idx = entries.findIndex((e) => e.id === id);1780 if (idx !== -1) {1781 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };1782 applied += 1;1783 }1784 }17851786 if (applied > 0) {1787 await persistEntries("sync_received");1788 buildDomainIndexSync(entries);1789 }1790 return { received, applied, conflicts };1791}17921793export async function restoreEntry(id: string): Promise<void> {1794 if (!derivedKey) throw new Error("vault_locked");1795 const entry = entries.find((e) => e.id === id && e.deletedAt);1796 if (!entry) throw new Error("deleted entry not found");1797 delete entry.deletedAt;1798 entry.modifiedAt = Date.now();1799 await persistEntries();1800 buildDomainIndexSync(entries);1801}18021803export async function purgeEntry(id: string): Promise<void> {1804 if (!derivedKey) throw new Error("vault_locked");1805 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1806 if (idx === -1) throw new Error("deleted entry not found");1807 entries.splice(idx, 1);1808 await persistEntries();1809}181018111812export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1813 if (!derivedKey || retentionMs === 0) return 0;1814 const cutoff = Date.now() - retentionMs;1815 const before = entries.length;1816 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1817 const purged = before - entries.length;1818 if (purged > 0) await persistEntries();1819 return purged;1820}18211822export function queryDomain(domain: string): VaultEntry[] {1823 return domainIndex.get(domain) ?? [];1824}18251826export function getEntryById(id: string): VaultEntry | undefined {1827 return entries.find((entry) => entry.id === id && !entry.deletedAt);1828}18291830async function persistSlotA(1831 reason: VaultSnapshot["reason"] = "local_save",1832): Promise<StoredVault> {1833 if (!derivedKey) throw new Error("vault_locked");18341835 const slotA = await loadSlot(SLOT_A);1836 if (!slotA) throw new Error("missing slot A");18371838 1839 1840 await takeVaultSnapshot(reason);18411842 const plaintext = encodePayload(entries, duressConfigured);1843 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");18441845 1846 const nextSlotA: StoredVault = {1847 ...slotA,1848 nonce,1849 ciphertext,1850 tag,1851 1852 };1853 await writeSlot(SLOT_A, nextSlotA);18541855 1856 1857 1858 if (!duressConfigured) {1859 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1860 }1861 await stampLocalVaultMutation();18621863 return nextSlotA;1864}18651866async function persistSlotB(): Promise<void> {1867 if (!derivedKey) throw new Error("vault_locked");18681869 const slotB = await loadSlot(SLOT_B);1870 if (!slotB) throw new Error("missing slot B");18711872 1873 1874 const plaintext = encodePayload(entries, false);1875 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");18761877 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1878 await stampLocalVaultMutation();1879}18801881async function persistEntries(1882 reason: VaultSnapshot["reason"] = "local_save",1883): Promise<void> {1884 if (!derivedKey) throw new Error("vault_locked");18851886 if (vaultMode === "decoy") {1887 await persistSlotB();1888 } else {1889 await persistSlotA(reason);1890 }1891 buildDomainIndexSync(entries);1892}18931894function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1895 if (!Array.isArray(values)) return undefined;1896 const seen = new Set<string>();1897 const cleaned: string[] = [];1898 for (const value of values) {1899 const trimmed = typeof value === "string" ? value.trim() : "";1900 if (!trimmed) continue;1901 const key = trimmed.toLowerCase();1902 if (seen.has(key)) continue;1903 seen.add(key);1904 cleaned.push(trimmed);1905 }1906 return cleaned.length > 0 ? cleaned : undefined;1907}19081909function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1910 const title = entry.title.trim();1911 1912 1913 1914 let type: VaultEntry["type"] = entry.type ?? "login";1915 if (type === "passkey") type = "login";19161917 if (!title) throw new Error("title is required");1918 1919 1920 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1921 throw new Error("password or passkey required");1922 }1923 if (entry.passkey) {1924 const p = entry.passkey;1925 if (1926 !p.rpId ||1927 !p.credentialId ||1928 !p.publicKeyCose ||1929 !p.userHandle ||1930 !p.userName ||1931 (p.algorithm !== -7 && p.algorithm !== -8)1932 ) {1933 throw new Error("invalid passkey credential");1934 }1935 }1936 if (type === "card") {1937 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1938 throw new Error("cardholder name and card number are required");1939 }1940 }19411942 return {1943 type,1944 title,1945 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1946 email: type === "login" ? entry.email?.trim() || undefined : undefined,1947 password: type === "login" ? entry.password : undefined,1948 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1949 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1950 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1951 notes: entry.notes?.trim() || undefined,1952 breach: type === "login" ? entry.breach : undefined,1953 canary: type === "login" ? entry.canary : undefined,1954 tags: entry.tags,1955 passkey: entry.passkey,1956 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1957 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1958 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1959 expYear: type === "card" ? entry.expYear?.trim() : undefined,1960 cvc: type === "card" ? entry.cvc?.trim() : undefined,1961 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1962 network: type === "card" ? entry.network : undefined,1963 last4: type === "card" ? entry.last4?.trim() : undefined,1964 };1965}19661967function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1968 const nextPatch: Partial<VaultEntry> = {};19691970 if ("type" in patch) {1971 nextPatch.type = patch.type;1972 }19731974 if ("title" in patch) {1975 const title = patch.title?.trim() ?? "";1976 if (!title) throw new Error("title is required");1977 nextPatch.title = title;1978 }19791980 if ("url" in patch) {1981 nextPatch.url = patch.url?.trim() ?? "";1982 }19831984 if ("additionalUrls" in patch) {1985 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1986 const seen = new Set<string>();1987 const cleaned: string[] = [];1988 for (const value of incoming) {1989 const trimmed = typeof value === "string" ? value.trim() : "";1990 if (!trimmed) continue;1991 const key = trimmed.toLowerCase();1992 if (seen.has(key)) continue;1993 seen.add(key);1994 cleaned.push(trimmed);1995 }1996 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1997 }19981999 if ("username" in patch) {2000 nextPatch.username = patch.username?.trim() ?? "";2001 }20022003 if ("email" in patch) {2004 nextPatch.email = patch.email?.trim() || undefined;2005 }20062007 if ("password" in patch) {2008 const password = patch.password ?? "";2009 2010 nextPatch.password = password || undefined;2011 }20122013 if ("totp" in patch) {2014 nextPatch.totp = patch.totp?.trim() || undefined;2015 }20162017 if ("notes" in patch) {2018 nextPatch.notes = patch.notes?.trim() || undefined;2019 }20202021 if ("breach" in patch) {2022 nextPatch.breach = patch.breach;2023 }20242025 if ("canary" in patch) {2026 nextPatch.canary = patch.canary;2027 }20282029 if ("passkey" in patch) {2030 2031 2032 2033 nextPatch.passkey = patch.passkey;2034 }20352036 if ("tags" in patch) {2037 nextPatch.tags = patch.tags;2038 }20392040 if ("cardholderName" in patch) {2041 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2042 }2043 if ("cardNumber" in patch) {2044 const n = patch.cardNumber ?? "";2045 nextPatch.cardNumber = n || undefined;2046 }2047 if ("expMonth" in patch) {2048 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2049 }2050 if ("expYear" in patch) {2051 nextPatch.expYear = patch.expYear?.trim() || undefined;2052 }2053 if ("cvc" in patch) {2054 nextPatch.cvc = patch.cvc?.trim() || undefined;2055 }2056 if ("cardNickname" in patch) {2057 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2058 }2059 if ("network" in patch) {2060 nextPatch.network = patch.network;2061 }2062 if ("last4" in patch) {2063 nextPatch.last4 = patch.last4?.trim() || undefined;2064 }20652066 return nextPatch;2067}20682069function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2070 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2071 const modifiedAt =2072 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;20732074 const base: VaultEntry = {2075 id: entry.id,2076 type: "type" in entry ? entry.type : "login",2077 title: entry.title,2078 username: entry.username,2079 email: "email" in entry ? entry.email : undefined,2080 password: entry.password,2081 url: entry.url,2082 additionalUrls:2083 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2084 ? entry.additionalUrls2085 : undefined,2086 totp: entry.totp,2087 notes: entry.notes,2088 breach: entry.breach,2089 canary: "canary" in entry ? entry.canary : undefined,2090 passkey: "passkey" in entry ? entry.passkey : undefined,2091 createdAt,2092 modifiedAt,2093 deletedAt: entry.deletedAt,2094 tags: "tags" in entry ? entry.tags : undefined,2095 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2096 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2097 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2098 expYear: "expYear" in entry ? entry.expYear : undefined,2099 cvc: "cvc" in entry ? entry.cvc : undefined,2100 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2101 network: "network" in entry ? entry.network : undefined,2102 last4: "last4" in entry ? entry.last4 : undefined,2103 };21042105 return migrateLegacyPasskey(base, entry);2106}210721082109211021112112211321142115function migrateLegacyPasskey(2116 base: VaultEntry,2117 raw: VaultEntry | LegacyVaultEntry2118): VaultEntry {2119 if (base.passkey) return base;2120 const flat = raw as Partial<VaultEntry>;2121 const isPasskeyType = base.type === "passkey";2122 const hasFlatPasskeyFields =2123 typeof flat.rpId === "string" &&2124 typeof flat.credentialId === "string" &&2125 typeof flat.publicKeyCose === "string" &&2126 typeof flat.userHandle === "string" &&2127 typeof flat.userName === "string";2128 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2129 if (!hasFlatPasskeyFields) return base;21302131 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2132 base.passkey = {2133 rpId: flat.rpId!,2134 rpName: flat.rpName ?? flat.rpId!,2135 credentialId: flat.credentialId!,2136 publicKeyCose: flat.publicKeyCose!,2137 privateKeyJwk: flat.privateKeyJwk,2138 userHandle: flat.userHandle!,2139 userName: flat.userName!,2140 userDisplayName: flat.userDisplayName ?? flat.userName!,2141 algorithm,2142 signCount: flat.signCount ?? 0,2143 aaguid: flat.aaguid ?? "",2144 backupEligible: flat.backupEligible === true,2145 backupState: flat.backupState === true,2146 lastUsedAt: flat.lastUsedAt,2147 browser: flat.browser,2148 transports: flat.transports,2149 createdAt: base.createdAt,2150 };2151 2152 2153 2154 if (isPasskeyType) base.type = "login";2155 if (!base.username) base.username = flat.userName;2156 if (!base.url) base.url = `https://${flat.rpId}`;2157 return base;2158}2159216021612162216321642165function entryDupeKey(e: VaultEntry): string {2166 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2167 2168 2169 2170 2171 if (e.passkey) {2172 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2173 }2174 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2175}217621772178export function countDuplicates(): number {2179 const best = new Map<string, VaultEntry>();2180 let dupeCount = 0;2181 for (const e of entries.filter((e) => !e.deletedAt)) {2182 const key = entryDupeKey(e);2183 const existing = best.get(key);2184 if (!existing) {2185 best.set(key, e);2186 } else {2187 dupeCount++;2188 2189 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2190 best.set(key, e);2191 }2192 }2193 }2194 return dupeCount;2195}219621972198219922002201export async function deduplicateEntries(): Promise<number> {2202 if (!derivedKey) throw new Error("vault_locked");2203 2204 2205 if (vaultMode === "decoy") {2206 throw new Error("deduplicate disabled in decoy mode");2207 }22082209 const best = new Map<string, VaultEntry>();2210 for (const e of entries.filter((en) => !en.deletedAt)) {2211 const key = entryDupeKey(e);2212 const existing = best.get(key);2213 if (!existing) {2214 best.set(key, e);2215 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2216 best.set(key, e);2217 }2218 }22192220 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2221 const now = Date.now();2222 let removed = 0;2223 entries = entries.map((e) => {2224 if (!e.deletedAt && !keepIds.has(e.id)) {2225 removed++;2226 return { ...e, deletedAt: now };2227 }2228 return e;2229 });22302231 if (removed > 0) await persistEntries();2232 return removed;2233}223422352236223722382239224022412242const HISTORY_KEY = "peach_vault_history";2243const HISTORY_MAX = 10;22442245224622472248224922502251export async function takeVaultSnapshot(2252 reason: VaultSnapshot["reason"],2253): Promise<void> {2254 try {2255 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2256 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2257 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];22582259 2260 2261 2262 if (!slotA) return;22632264 const vault_a = JSON.stringify(slotA);2265 const vault_b = JSON.stringify(slotB ?? slotA);22662267 2268 2269 2270 let entryCount: number | null = null;2271 if (derivedKey && vaultMode === "real") {2272 try {2273 entryCount = entries.filter((e) => !e.deletedAt).length;2274 } catch {2275 entryCount = null;2276 }2277 }22782279 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2280 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2281 ? existingRaw2282 : { snapshots: [] };22832284 2285 2286 2287 const latest = history.snapshots[0];2288 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2289 return;2290 }22912292 const snapshot: VaultSnapshot = {2293 id: crypto.randomUUID(),2294 savedAt: Date.now(),2295 reason,2296 vault_a,2297 vault_b,2298 entryCount,2299 };23002301 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2302 await chrome.storage.local.set({ [HISTORY_KEY]: history });2303 } catch (err) {2304 2305 2306 2307 2308 console.warn('[Peach] takeVaultSnapshot failed:', err);2309 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2310 try {2311 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2312 } catch {2313 2314 }2315 }2316 }2317}231823192320export async function clearVaultHistory(): Promise<void> {2321 try {2322 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2323 } catch {2324 2325 }2326}232723282329export async function getVaultHistory(): Promise<VaultSnapshot[]> {2330 const raw = await chrome.storage.local.get(HISTORY_KEY);2331 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2332 if (!history || !Array.isArray(history.snapshots)) return [];2333 return history.snapshots;2334}23352336233723382339234023412342export async function restoreVaultSnapshot(2343 snapshotId: string,2344): Promise<{ ok: true } | { ok: false; error: string }> {2345 const history = await getVaultHistory();2346 const snapshot = history.find((s) => s.id === snapshotId);2347 if (!snapshot) return { ok: false, error: "snapshot_not_found" };23482349 2350 2351 2352 await takeVaultSnapshot("restore");23532354 let restoredA: StoredVault;2355 let restoredB: StoredVault;2356 try {2357 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2358 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2359 } catch {2360 return { ok: false, error: "snapshot_corrupt" };2361 }23622363 await writeSlot(SLOT_A, restoredA);2364 await writeSlot(SLOT_B, restoredB);2365 await stampLocalVaultMutation();23662367 2368 2369 2370 lock();23712372 return { ok: true };2373}23742375237623772378237923802381export async function changePassword(2382 currentPassword: string,2383 newPassword: string2384): Promise<boolean> {2385 if (!derivedKey || vaultMode !== "real") return false;23862387 2388 const valid = await verifyMainPassword(currentPassword);2389 if (!valid) return false;23902391 const slotA = await loadSlot(SLOT_A);2392 if (!slotA) return false;2393 if (!rootKey) return false;23942395 2396 const newSalt = generateSalt();2397 const newKey = await deriveKey(newPassword, newSalt);23982399 2400 const plaintext = encodePayload(entries, duressConfigured);2401 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");24022403 2404 2405 2406 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);24072408 const finalSlotA: StoredVault = {2409 ...slotA,2410 salt: hex(newSalt),2411 nonce,2412 ciphertext,2413 tag,2414 ...pwRoot,2415 };24162417 2418 2419 2420 await takeVaultSnapshot("local_save");24212422 2423 2424 2425 2426 await writeSlot(SLOT_A, finalSlotA);24272428 2429 2430 2431 zeroKey(derivedKey);2432 derivedKey = newKey;24332434 2435 2436 2437 if (!duressConfigured) {2438 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2439 }2440 await stampLocalVaultMutation();24412442 return true;2443}