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}16731674export async function restoreEntry(id: string): Promise<void> {1675 if (!derivedKey) throw new Error("vault_locked");1676 const entry = entries.find((e) => e.id === id && e.deletedAt);1677 if (!entry) throw new Error("deleted entry not found");1678 delete entry.deletedAt;1679 entry.modifiedAt = Date.now();1680 await persistEntries();1681 buildDomainIndexSync(entries);1682}16831684export async function purgeEntry(id: string): Promise<void> {1685 if (!derivedKey) throw new Error("vault_locked");1686 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1687 if (idx === -1) throw new Error("deleted entry not found");1688 entries.splice(idx, 1);1689 await persistEntries();1690}169116921693export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1694 if (!derivedKey || retentionMs === 0) return 0;1695 const cutoff = Date.now() - retentionMs;1696 const before = entries.length;1697 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1698 const purged = before - entries.length;1699 if (purged > 0) await persistEntries();1700 return purged;1701}17021703export function queryDomain(domain: string): VaultEntry[] {1704 return domainIndex.get(domain) ?? [];1705}17061707export function getEntryById(id: string): VaultEntry | undefined {1708 return entries.find((entry) => entry.id === id && !entry.deletedAt);1709}17101711async function persistSlotA(1712 reason: VaultSnapshot["reason"] = "local_save",1713): Promise<StoredVault> {1714 if (!derivedKey) throw new Error("vault_locked");17151716 const slotA = await loadSlot(SLOT_A);1717 if (!slotA) throw new Error("missing slot A");17181719 1720 1721 await takeVaultSnapshot(reason);17221723 const plaintext = encodePayload(entries, duressConfigured);1724 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");17251726 1727 const nextSlotA: StoredVault = {1728 ...slotA,1729 nonce,1730 ciphertext,1731 tag,1732 1733 };1734 await writeSlot(SLOT_A, nextSlotA);17351736 1737 1738 1739 if (!duressConfigured) {1740 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1741 }1742 await stampLocalVaultMutation();17431744 return nextSlotA;1745}17461747async function persistSlotB(): Promise<void> {1748 if (!derivedKey) throw new Error("vault_locked");17491750 const slotB = await loadSlot(SLOT_B);1751 if (!slotB) throw new Error("missing slot B");17521753 1754 1755 const plaintext = encodePayload(entries, false);1756 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");17571758 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1759 await stampLocalVaultMutation();1760}17611762async function persistEntries(1763 reason: VaultSnapshot["reason"] = "local_save",1764): Promise<void> {1765 if (!derivedKey) throw new Error("vault_locked");17661767 if (vaultMode === "decoy") {1768 await persistSlotB();1769 } else {1770 await persistSlotA(reason);1771 }1772 buildDomainIndexSync(entries);1773}17741775function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1776 if (!Array.isArray(values)) return undefined;1777 const seen = new Set<string>();1778 const cleaned: string[] = [];1779 for (const value of values) {1780 const trimmed = typeof value === "string" ? value.trim() : "";1781 if (!trimmed) continue;1782 const key = trimmed.toLowerCase();1783 if (seen.has(key)) continue;1784 seen.add(key);1785 cleaned.push(trimmed);1786 }1787 return cleaned.length > 0 ? cleaned : undefined;1788}17891790function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1791 const title = entry.title.trim();1792 1793 1794 1795 let type: VaultEntry["type"] = entry.type ?? "login";1796 if (type === "passkey") type = "login";17971798 if (!title) throw new Error("title is required");1799 1800 1801 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1802 throw new Error("password or passkey required");1803 }1804 if (entry.passkey) {1805 const p = entry.passkey;1806 if (1807 !p.rpId ||1808 !p.credentialId ||1809 !p.publicKeyCose ||1810 !p.userHandle ||1811 !p.userName ||1812 (p.algorithm !== -7 && p.algorithm !== -8)1813 ) {1814 throw new Error("invalid passkey credential");1815 }1816 }1817 if (type === "card") {1818 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1819 throw new Error("cardholder name and card number are required");1820 }1821 }18221823 return {1824 type,1825 title,1826 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1827 email: type === "login" ? entry.email?.trim() || undefined : undefined,1828 password: type === "login" ? entry.password : undefined,1829 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1830 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1831 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1832 notes: entry.notes?.trim() || undefined,1833 breach: type === "login" ? entry.breach : undefined,1834 canary: type === "login" ? entry.canary : undefined,1835 tags: entry.tags,1836 passkey: entry.passkey,1837 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1838 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1839 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1840 expYear: type === "card" ? entry.expYear?.trim() : undefined,1841 cvc: type === "card" ? entry.cvc?.trim() : undefined,1842 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1843 network: type === "card" ? entry.network : undefined,1844 last4: type === "card" ? entry.last4?.trim() : undefined,1845 };1846}18471848function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1849 const nextPatch: Partial<VaultEntry> = {};18501851 if ("type" in patch) {1852 nextPatch.type = patch.type;1853 }18541855 if ("title" in patch) {1856 const title = patch.title?.trim() ?? "";1857 if (!title) throw new Error("title is required");1858 nextPatch.title = title;1859 }18601861 if ("url" in patch) {1862 nextPatch.url = patch.url?.trim() ?? "";1863 }18641865 if ("additionalUrls" in patch) {1866 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1867 const seen = new Set<string>();1868 const cleaned: string[] = [];1869 for (const value of incoming) {1870 const trimmed = typeof value === "string" ? value.trim() : "";1871 if (!trimmed) continue;1872 const key = trimmed.toLowerCase();1873 if (seen.has(key)) continue;1874 seen.add(key);1875 cleaned.push(trimmed);1876 }1877 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1878 }18791880 if ("username" in patch) {1881 nextPatch.username = patch.username?.trim() ?? "";1882 }18831884 if ("email" in patch) {1885 nextPatch.email = patch.email?.trim() || undefined;1886 }18871888 if ("password" in patch) {1889 const password = patch.password ?? "";1890 1891 nextPatch.password = password || undefined;1892 }18931894 if ("totp" in patch) {1895 nextPatch.totp = patch.totp?.trim() || undefined;1896 }18971898 if ("notes" in patch) {1899 nextPatch.notes = patch.notes?.trim() || undefined;1900 }19011902 if ("breach" in patch) {1903 nextPatch.breach = patch.breach;1904 }19051906 if ("canary" in patch) {1907 nextPatch.canary = patch.canary;1908 }19091910 if ("passkey" in patch) {1911 1912 1913 1914 nextPatch.passkey = patch.passkey;1915 }19161917 if ("tags" in patch) {1918 nextPatch.tags = patch.tags;1919 }19201921 if ("cardholderName" in patch) {1922 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1923 }1924 if ("cardNumber" in patch) {1925 const n = patch.cardNumber ?? "";1926 nextPatch.cardNumber = n || undefined;1927 }1928 if ("expMonth" in patch) {1929 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1930 }1931 if ("expYear" in patch) {1932 nextPatch.expYear = patch.expYear?.trim() || undefined;1933 }1934 if ("cvc" in patch) {1935 nextPatch.cvc = patch.cvc?.trim() || undefined;1936 }1937 if ("cardNickname" in patch) {1938 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1939 }1940 if ("network" in patch) {1941 nextPatch.network = patch.network;1942 }1943 if ("last4" in patch) {1944 nextPatch.last4 = patch.last4?.trim() || undefined;1945 }19461947 return nextPatch;1948}19491950function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1951 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1952 const modifiedAt =1953 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;19541955 const base: VaultEntry = {1956 id: entry.id,1957 type: "type" in entry ? entry.type : "login",1958 title: entry.title,1959 username: entry.username,1960 email: "email" in entry ? entry.email : undefined,1961 password: entry.password,1962 url: entry.url,1963 additionalUrls:1964 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)1965 ? entry.additionalUrls1966 : undefined,1967 totp: entry.totp,1968 notes: entry.notes,1969 breach: entry.breach,1970 canary: "canary" in entry ? entry.canary : undefined,1971 passkey: "passkey" in entry ? entry.passkey : undefined,1972 createdAt,1973 modifiedAt,1974 deletedAt: entry.deletedAt,1975 tags: "tags" in entry ? entry.tags : undefined,1976 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1977 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1978 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1979 expYear: "expYear" in entry ? entry.expYear : undefined,1980 cvc: "cvc" in entry ? entry.cvc : undefined,1981 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1982 network: "network" in entry ? entry.network : undefined,1983 last4: "last4" in entry ? entry.last4 : undefined,1984 };19851986 return migrateLegacyPasskey(base, entry);1987}198819891990199119921993199419951996function migrateLegacyPasskey(1997 base: VaultEntry,1998 raw: VaultEntry | LegacyVaultEntry1999): VaultEntry {2000 if (base.passkey) return base;2001 const flat = raw as Partial<VaultEntry>;2002 const isPasskeyType = base.type === "passkey";2003 const hasFlatPasskeyFields =2004 typeof flat.rpId === "string" &&2005 typeof flat.credentialId === "string" &&2006 typeof flat.publicKeyCose === "string" &&2007 typeof flat.userHandle === "string" &&2008 typeof flat.userName === "string";2009 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2010 if (!hasFlatPasskeyFields) return base;20112012 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2013 base.passkey = {2014 rpId: flat.rpId!,2015 rpName: flat.rpName ?? flat.rpId!,2016 credentialId: flat.credentialId!,2017 publicKeyCose: flat.publicKeyCose!,2018 privateKeyJwk: flat.privateKeyJwk,2019 userHandle: flat.userHandle!,2020 userName: flat.userName!,2021 userDisplayName: flat.userDisplayName ?? flat.userName!,2022 algorithm,2023 signCount: flat.signCount ?? 0,2024 aaguid: flat.aaguid ?? "",2025 backupEligible: flat.backupEligible === true,2026 backupState: flat.backupState === true,2027 lastUsedAt: flat.lastUsedAt,2028 browser: flat.browser,2029 transports: flat.transports,2030 createdAt: base.createdAt,2031 };2032 2033 2034 2035 if (isPasskeyType) base.type = "login";2036 if (!base.username) base.username = flat.userName;2037 if (!base.url) base.url = `https://${flat.rpId}`;2038 return base;2039}2040204120422043204420452046function entryDupeKey(e: VaultEntry): string {2047 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2048 2049 2050 2051 2052 if (e.passkey) {2053 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2054 }2055 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2056}205720582059export function countDuplicates(): number {2060 const best = new Map<string, VaultEntry>();2061 let dupeCount = 0;2062 for (const e of entries.filter((e) => !e.deletedAt)) {2063 const key = entryDupeKey(e);2064 const existing = best.get(key);2065 if (!existing) {2066 best.set(key, e);2067 } else {2068 dupeCount++;2069 2070 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2071 best.set(key, e);2072 }2073 }2074 }2075 return dupeCount;2076}207720782079208020812082export async function deduplicateEntries(): Promise<number> {2083 if (!derivedKey) throw new Error("vault_locked");2084 2085 2086 if (vaultMode === "decoy") {2087 throw new Error("deduplicate disabled in decoy mode");2088 }20892090 const best = new Map<string, VaultEntry>();2091 for (const e of entries.filter((en) => !en.deletedAt)) {2092 const key = entryDupeKey(e);2093 const existing = best.get(key);2094 if (!existing) {2095 best.set(key, e);2096 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2097 best.set(key, e);2098 }2099 }21002101 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2102 const now = Date.now();2103 let removed = 0;2104 entries = entries.map((e) => {2105 if (!e.deletedAt && !keepIds.has(e.id)) {2106 removed++;2107 return { ...e, deletedAt: now };2108 }2109 return e;2110 });21112112 if (removed > 0) await persistEntries();2113 return removed;2114}211521162117211821192120212121222123const HISTORY_KEY = "peach_vault_history";2124const HISTORY_MAX = 10;21252126212721282129213021312132export async function takeVaultSnapshot(2133 reason: VaultSnapshot["reason"],2134): Promise<void> {2135 try {2136 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2137 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2138 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];21392140 2141 2142 2143 if (!slotA) return;21442145 const vault_a = JSON.stringify(slotA);2146 const vault_b = JSON.stringify(slotB ?? slotA);21472148 2149 2150 2151 let entryCount: number | null = null;2152 if (derivedKey && vaultMode === "real") {2153 try {2154 entryCount = entries.filter((e) => !e.deletedAt).length;2155 } catch {2156 entryCount = null;2157 }2158 }21592160 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2161 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2162 ? existingRaw2163 : { snapshots: [] };21642165 2166 2167 2168 const latest = history.snapshots[0];2169 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2170 return;2171 }21722173 const snapshot: VaultSnapshot = {2174 id: crypto.randomUUID(),2175 savedAt: Date.now(),2176 reason,2177 vault_a,2178 vault_b,2179 entryCount,2180 };21812182 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2183 await chrome.storage.local.set({ [HISTORY_KEY]: history });2184 } catch (err) {2185 2186 2187 2188 2189 console.warn('[Peach] takeVaultSnapshot failed:', err);2190 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2191 try {2192 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2193 } catch {2194 2195 }2196 }2197 }2198}219922002201export async function clearVaultHistory(): Promise<void> {2202 try {2203 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2204 } catch {2205 2206 }2207}220822092210export async function getVaultHistory(): Promise<VaultSnapshot[]> {2211 const raw = await chrome.storage.local.get(HISTORY_KEY);2212 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2213 if (!history || !Array.isArray(history.snapshots)) return [];2214 return history.snapshots;2215}22162217221822192220222122222223export async function restoreVaultSnapshot(2224 snapshotId: string,2225): Promise<{ ok: true } | { ok: false; error: string }> {2226 const history = await getVaultHistory();2227 const snapshot = history.find((s) => s.id === snapshotId);2228 if (!snapshot) return { ok: false, error: "snapshot_not_found" };22292230 2231 2232 2233 await takeVaultSnapshot("restore");22342235 let restoredA: StoredVault;2236 let restoredB: StoredVault;2237 try {2238 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2239 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2240 } catch {2241 return { ok: false, error: "snapshot_corrupt" };2242 }22432244 await writeSlot(SLOT_A, restoredA);2245 await writeSlot(SLOT_B, restoredB);2246 await stampLocalVaultMutation();22472248 2249 2250 2251 lock();22522253 return { ok: true };2254}22552256225722582259226022612262export async function changePassword(2263 currentPassword: string,2264 newPassword: string2265): Promise<boolean> {2266 if (!derivedKey || vaultMode !== "real") return false;22672268 2269 const valid = await verifyMainPassword(currentPassword);2270 if (!valid) return false;22712272 const slotA = await loadSlot(SLOT_A);2273 if (!slotA) return false;2274 if (!rootKey) return false;22752276 2277 const newSalt = generateSalt();2278 const newKey = await deriveKey(newPassword, newSalt);22792280 2281 const plaintext = encodePayload(entries, duressConfigured);2282 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");22832284 2285 2286 2287 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);22882289 const finalSlotA: StoredVault = {2290 ...slotA,2291 salt: hex(newSalt),2292 nonce,2293 ciphertext,2294 tag,2295 ...pwRoot,2296 };22972298 2299 2300 2301 await takeVaultSnapshot("local_save");23022303 2304 2305 2306 2307 await writeSlot(SLOT_A, finalSlotA);23082309 2310 2311 2312 zeroKey(derivedKey);2313 derivedKey = newKey;23142315 2316 2317 2318 if (!duressConfigured) {2319 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2320 }2321 await stampLocalVaultMutation();23222323 return true;2324}