123456789101112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import type {15 AccentColorName,16 LegacyVaultEntry,17 ManualSummonRecord,18 StoredVault,19 VaultEntry,20} from "../shared/types";21import { decrypt, encrypt, zeroKey } from "./aes";22import { clearAutoLock, touchAutoLock } from "./alarms";2324type EntryDraft = Omit<25 VaultEntry,26 "id" | "createdAt" | "modifiedAt" | "deletedAt"27>;282930313233const SLOT_A = "peach_vault_a";34const SLOT_B = "peach_vault_b";353637const LEGACY_MAIN = "peach_vault";38const LEGACY_DECOY = "peach_decoy_vault";39const LEGACY_DURESS_FLAG = "peach_duress";40414243const PAD_BLOCK = 1024;4445let derivedKey: Uint8Array | null = null;46let entries: VaultEntry[] = [];47let domainIndex: Map<string, VaultEntry[]> = new Map();48let vaultMode: "real" | "decoy" = "real";49505152let duressConfigured = false;5354let migrationRan = false;555657585960export interface VaultSettings {61 accentColor?: AccentColorName;62 theme?: "dark" | "light";63 autoLockMinutes?: number;64 clipboardClearSeconds?: number;65 lockOnClose?: boolean;66 breachMonitoring?: boolean;67 passkeyProviderEnabled?: boolean;68 trashRetentionDays?: number;69 manualSummonDomains?: Record<string, ManualSummonRecord>;70 declinedCardFingerprints?: string[];71 72737475767778 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;79}8081let settings: VaultSettings = {};8283export function getSettings(): VaultSettings {84 return settings;85}8687export async function ensureSettingsLoaded(): Promise<VaultSettings> {88 await loadSettings();89 return settings;90}9192export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {93 await loadSettings();94 settings = { ...settings, ...patch };95 await persistSettings();96}9798async function persistSettings(): Promise<void> {99 try {100 await chrome.storage.local.set({ peach_settings: settings });101 } catch (err) {102 throw mapStorageError(err);103 }104}105106function mapStorageError(err: unknown): Error {107 const message = err instanceof Error ? err.message : String(err);108 if (/QUOTA_BYTES|quota/i.test(message)) {109 return new Error("STORAGE_QUOTA_EXCEEDED");110 }111 return err instanceof Error ? err : new Error(message);112}113114async function loadSettings(): Promise<void> {115 const result = await chrome.storage.local.get("peach_settings");116 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};117}118119120121122123export async function checkBreach(password: string): Promise<number> {124 const encoder = new TextEncoder();125 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));126 const hashHex = Array.from(new Uint8Array(hashBuffer))127 .map((b) => b.toString(16).padStart(2, "0"))128 .join("")129 .toUpperCase();130131 const prefix = hashHex.slice(0, 5);132 const suffix = hashHex.slice(5);133134 try {135 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);136 if (!res.ok) return 0;137 const text = await res.text();138 const match = text.split("\r\n").find((l) => l.startsWith(suffix));139 return match ? parseInt(match.split(":")[1], 10) : 0;140 } catch {141 return 0; 142 }143}144145export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {146 const fingerprint = await cardFingerprint(last4, expMonth, expYear);147 await loadSettings();148 settings = {149 ...settings,150 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],151 };152 await persistSettings();153}154155export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {156 await loadSettings();157 const fingerprint = await cardFingerprint(last4, expMonth, expYear);158 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);159}160161async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {162 const encoder = new TextEncoder();163 const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));164 return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");165}166167function hex(buf: Uint8Array): string {168 return Array.from(buf)169 .map((b) => b.toString(16).padStart(2, "0"))170 .join("");171}172173function unhex(s: string): Uint8Array {174 const out = new Uint8Array(s.length / 2);175 for (let i = 0; i < s.length; i += 2) {176 out[i / 2] = parseInt(s.slice(i, i + 2), 16);177 }178 return out;179}180181182183184185async function loadSlot(key: string): Promise<StoredVault | null> {186 const result = await chrome.storage.local.get(key);187 return (result as Record<string, StoredVault | undefined>)[key] ?? null;188}189190async function writeSlot(key: string, vault: StoredVault): Promise<void> {191 try {192 await chrome.storage.local.set({ [key]: vault });193 } catch (err) {194 throw mapStorageError(err);195 }196}197198199200201202203async function ensureMigrated(): Promise<void> {204 if (migrationRan) return;205 migrationRan = true;206207 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];208 const raw = await chrome.storage.local.get(keys);209 const slotAExists = raw[SLOT_A] !== undefined;210 const slotBExists = raw[SLOT_B] !== undefined;211 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;212 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;213214 215 if (!slotAExists && !legacyMain) return;216217 218 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;219220 if (legacyMain && !slotAExists) {221 await writeSlot(SLOT_A, legacyMain);222 }223224 225 if (!slotBExists) {226 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;227 if (legacyDecoy) {228 await writeSlot(SLOT_B, legacyDecoy);229 } else if (target) {230 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));231 }232 }233234 const toRemove: string[] = [];235 if (legacyMain) toRemove.push(LEGACY_MAIN);236 if (legacyDecoy) toRemove.push(LEGACY_DECOY);237 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);238 if (toRemove.length > 0) {239 await chrome.storage.local.remove(toRemove);240 }241}242243244245246247248249250251252253function padPlaintext(json: string): string {254 const encoded = new TextEncoder().encode(json);255 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);256 const padLen = target - encoded.length;257 return json + " ".repeat(padLen);258}259260261262263264265266function generateNoiseBlob(ciphertextBytes: number): StoredVault {267 const salt = crypto.getRandomValues(new Uint8Array(16));268 const nonce = crypto.getRandomValues(new Uint8Array(12));269 const cipher = crypto.getRandomValues(new Uint8Array(ciphertextBytes));270 const tag = crypto.getRandomValues(new Uint8Array(16));271 return {272 version: 1,273 salt: hex(salt),274 nonce: hex(nonce),275 ciphertext: hex(cipher),276 tag: hex(tag),277 };278}279280281282283284type VaultPayload = {285 entries: VaultEntry[];286 duressConfigured?: boolean;287};288289type DecryptedPayload = {290 entries: VaultEntry[];291 duressConfigured: boolean;292};293294function encodePayload(entries: VaultEntry[], duress: boolean): string {295 const envelope: VaultPayload = { entries, duressConfigured: duress };296 return padPlaintext(JSON.stringify(envelope));297}298299function decodePayload(plain: string): DecryptedPayload {300 const parsed = JSON.parse(plain) as301 | Array<VaultEntry | LegacyVaultEntry>302 | VaultPayload;303304 if (Array.isArray(parsed)) {305 306 return {307 entries: parsed.map(normalizeEntry),308 duressConfigured: false,309 };310 }311312 return {313 entries: (parsed.entries ?? []).map(normalizeEntry),314 duressConfigured: parsed.duressConfigured === true,315 };316}317318async function tryDecryptSlot(319 vault: StoredVault,320 key: Uint8Array321): Promise<DecryptedPayload | null> {322 try {323 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);324 return decodePayload(plain);325 } catch {326 return null;327 }328}329330331332333334function buildDomainIndexSync(all: VaultEntry[]): void {335 domainIndex = new Map();336337 for (const entry of all) {338 if (entry.deletedAt) continue;339 if (entry.type === "note" || entry.type === "passkey" || entry.type === "card") continue;340341 342 343 344 345 const domains = new Set<string>();346 const primary = extractDomain(entry.url ?? "");347 if (primary) domains.add(primary);348 for (const extra of entry.additionalUrls ?? []) {349 const d = extractDomain(extra);350 if (d) domains.add(d);351 }352 if (domains.size === 0) continue;353354 for (const domain of domains) {355 const bucket = domainIndex.get(domain) ?? [];356 bucket.push(entry);357 domainIndex.set(domain, bucket);358 }359 }360}361362export async function createVault(password: string): Promise<void> {363 await ensureMigrated();364365 const salt = generateSalt();366 derivedKey = await deriveKey(password, salt);367 entries = [];368 vaultMode = "real";369 duressConfigured = false;370 buildDomainIndexSync(entries);371372 const plaintext = encodePayload(entries, duressConfigured);373 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);374375 const slotA: StoredVault = {376 version: 1,377 salt: hex(salt),378 nonce,379 ciphertext,380 tag,381 };382 await writeSlot(SLOT_A, slotA);383384 385 386 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));387388 touchAutoLock();389}390391export async function hasVault(): Promise<boolean> {392 await ensureMigrated();393 return (await loadSlot(SLOT_A)) !== null;394}395396397398399400401export async function isDuressEnabled(): Promise<boolean> {402 return vaultMode === "real" && duressConfigured;403}404405export async function hasDecoyVault(): Promise<boolean> {406 return isDuressEnabled();407}408409export async function setupDuress(duressPassword: string): Promise<void> {410 if (!derivedKey || vaultMode !== "real") {411 throw new Error("vault must be unlocked in real mode");412 }413 await ensureMigrated();414415 416 417 418 const slotA = await loadSlot(SLOT_A);419 if (!slotA) throw new Error("missing slot A");420421 const decoySalt = generateSalt();422 const decoyKey = await deriveKey(duressPassword, decoySalt);423 try {424 425 426 const targetBytes = slotA.ciphertext.length / 2;427 let plaintext = encodePayload([], false);428 const encoded = new TextEncoder().encode(plaintext);429 if (encoded.length < targetBytes) {430 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);431 }432 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);433 await writeSlot(SLOT_B, {434 version: 1,435 salt: hex(decoySalt),436 nonce,437 ciphertext,438 tag,439 });440 } finally {441 zeroKey(decoyKey);442 }443444 445 446 duressConfigured = true;447 await persistSlotA();448}449450export async function disableDuress(): Promise<void> {451 if (!derivedKey || vaultMode !== "real") {452 throw new Error("vault must be unlocked in real mode");453 }454 await ensureMigrated();455456 457 458 459 const slotA = await loadSlot(SLOT_A);460 if (slotA) {461 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));462 }463464 duressConfigured = false;465 await persistSlotA();466}467468export async function verifyMainPassword(password: string): Promise<boolean> {469 await ensureMigrated();470 const slotA = await loadSlot(SLOT_A);471 if (!slotA) return false;472 const key = await deriveKey(password, unhex(slotA.salt));473 try {474 return (await tryDecryptSlot(slotA, key)) !== null;475 } finally {476 zeroKey(key);477 }478}479480export async function unlock(password: string): Promise<boolean> {481 await ensureMigrated();482483 484 485 486 487 488 const [slotA, slotB] = await Promise.all([489 loadSlot(SLOT_A),490 loadSlot(SLOT_B),491 ]);492493 494 const [keyA, keyB] = await Promise.all([495 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),496 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),497 ]);498499 500 const [payloadA, payloadB] = await Promise.all([501 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),502 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),503 ]);504505 if (payloadA && keyA) {506 derivedKey = keyA;507 vaultMode = "real";508 entries = payloadA.entries;509 duressConfigured = payloadA.duressConfigured;510 buildDomainIndexSync(entries);511 await loadSettings();512 touchAutoLock();513 if (keyB) zeroKey(keyB);514 515 516 if (hasLegacyShape(payloadA.entries)) {517 await persistSlotA();518 }519 return true;520 }521522 if (payloadB && keyB) {523 derivedKey = keyB;524 vaultMode = "decoy";525 entries = payloadB.entries;526 duressConfigured = false;527 buildDomainIndexSync(entries);528 await loadSettings();529 touchAutoLock();530 if (keyA) zeroKey(keyA);531 return true;532 }533534 if (keyA) zeroKey(keyA);535 if (keyB) zeroKey(keyB);536 return false;537}538539export function lock(): void {540 if (derivedKey) {541 zeroKey(derivedKey);542 derivedKey = null;543 }544545 entries = [];546 domainIndex = new Map();547 vaultMode = "real";548 duressConfigured = false;549 clearAutoLock();550}551552export function isUnlocked(): boolean {553 return derivedKey !== null;554}555556export function getEntries(): VaultEntry[] {557 if (!derivedKey) throw new Error("vault locked");558 return entries.filter((entry) => !entry.deletedAt);559}560561export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {562 if (!derivedKey) throw new Error("vault locked");563564 const now = Date.now();565 const added: VaultEntry[] = [];566 for (const draft of drafts) {567 try {568 const sanitized = sanitizeEntryDraft(draft);569 const nextEntry: VaultEntry = {570 ...sanitized,571 id: crypto.randomUUID(),572 createdAt: now,573 modifiedAt: now,574 };575 entries.push(nextEntry);576 added.push(nextEntry);577 } catch {578 579 }580 }581582 if (added.length > 0) {583 await persistEntries();584 }585586 return added;587}588589590591592593594595596export async function restoreEntries(597 incoming: VaultEntry[],598 mode: "replace" | "merge"599): Promise<{ added: number; updated: number; skipped: number }> {600 if (!derivedKey) throw new Error("vault locked");601602 603 604 605 if (mode === "replace" && incoming.length === 0) {606 throw new Error("refuse to replace with empty entry set");607 }608609 let added = 0;610 let updated = 0;611 let skipped = 0;612613 if (mode === "replace") {614 entries = [];615 for (const entry of incoming) {616 const normalized = sanitizeRestoredEntry(entry);617 if (!normalized) {618 skipped += 1;619 continue;620 }621 entries.push(normalized);622 added += 1;623 }624 } else {625 const byId = new Map<string, VaultEntry>();626 for (const e of entries) byId.set(e.id, e);627628 for (const entry of incoming) {629 const normalized = sanitizeRestoredEntry(entry);630 if (!normalized) {631 skipped += 1;632 continue;633 }634 const existing = byId.get(normalized.id);635 if (!existing) {636 entries.push(normalized);637 added += 1;638 } else if (normalized.modifiedAt > existing.modifiedAt) {639 const idx = entries.findIndex((e) => e.id === existing.id);640 if (idx !== -1) {641 entries[idx] = normalized;642 updated += 1;643 }644 } else {645 skipped += 1;646 }647 }648 }649650 await persistEntries();651 return { added, updated, skipped };652}653654655656657658659function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {660 if (!entry || typeof entry !== "object") return null;661 const title = typeof entry.title === "string" ? entry.title.trim() : "";662 if (!title) return null;663 const type = entry.type ?? "login";664 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();665 const now = Date.now();666 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;667 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;668669 if (type === "login" && typeof entry.password !== "string") {670 return null;671 }672 if (673 type === "passkey" &&674 (typeof entry.rpId !== "string" ||675 typeof entry.credentialId !== "string" ||676 !entry.privateKeyJwk)677 ) {678 return null;679 }680681 return {682 ...entry,683 id,684 title,685 type,686 createdAt,687 modifiedAt,688 };689}690691692693694695export async function clearVaultEntries(696 confirmation: "REPLACE_ALL"697): Promise<void> {698 if (confirmation !== "REPLACE_ALL") {699 throw new Error("clearVaultEntries requires explicit confirmation");700 }701 if (!derivedKey) throw new Error("vault locked");702 for (const entry of entries) {703 if (!entry.deletedAt) {704 entry.deletedAt = Date.now();705 entry.modifiedAt = Date.now();706 }707 }708 await persistEntries();709}710711export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {712 if (!derivedKey) throw new Error("vault locked");713714 const sanitized = sanitizeEntryDraft(entry);715 const now = Date.now();716 const nextEntry: VaultEntry = {717 ...sanitized,718 id: crypto.randomUUID(),719 createdAt: now,720 modifiedAt: now,721 722 723 passwordChangedAt: sanitized.password ? now : undefined,724 };725726 entries.push(nextEntry);727 await persistEntries();728729 730 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {731 checkBreach(nextEntry.password).then(async (count) => {732 if (count > 0) {733 const idx = entries.findIndex((e) => e.id === nextEntry.id);734 if (idx !== -1) {735 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };736 await persistEntries();737 }738 }739 });740 }741742 return nextEntry;743}744745export async function updateEntry(746 id: string,747 patch: Partial<VaultEntry>748): Promise<void> {749 if (!derivedKey) throw new Error("vault locked");750751 const idx = entries.findIndex((entry) => entry.id === id);752 if (idx === -1 || entries[idx].deletedAt) {753 throw new Error("entry not found");754 }755756 const current = entries[idx];757 const sanitizedPatch = sanitizeEntryPatch(patch);758 759 760 761 const passwordChanged =762 "password" in patch &&763 typeof sanitizedPatch.password === "string" &&764 sanitizedPatch.password !== current.password;765 entries[idx] = {766 ...current,767 ...sanitizedPatch,768 modifiedAt: Date.now(),769 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),770 };771772 773 774 775 if (entries[idx].type === "login" && !entries[idx].password) {776 entries[idx] = current;777 throw new Error("password is required for login entries");778 }779780 781 if (entries[idx].type === "card" && !entries[idx].cardNumber) {782 entries[idx] = current;783 throw new Error("card number is required for card entries");784 }785786 await persistEntries();787788 789 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {790 const password = entries[idx].password!;791 checkBreach(password).then(async (count) => {792 const i = entries.findIndex((e) => e.id === id);793 if (i !== -1) {794 entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };795 await persistEntries();796 }797 });798 }799}800801export async function deleteEntry(id: string): Promise<void> {802 if (!derivedKey) throw new Error("vault locked");803804 const entry = entries.find((item) => item.id === id);805 if (!entry || entry.deletedAt) {806 throw new Error("entry not found");807 }808809 entry.deletedAt = Date.now();810 entry.modifiedAt = Date.now();811 await persistEntries();812}813814export function getDeletedEntries(): VaultEntry[] {815 if (!derivedKey) return [];816 return entries.filter((e) => !!e.deletedAt);817}818819export async function restoreEntry(id: string): Promise<void> {820 if (!derivedKey) throw new Error("vault locked");821 const entry = entries.find((e) => e.id === id && e.deletedAt);822 if (!entry) throw new Error("deleted entry not found");823 delete entry.deletedAt;824 entry.modifiedAt = Date.now();825 await persistEntries();826 buildDomainIndexSync(entries);827}828829export async function purgeEntry(id: string): Promise<void> {830 if (!derivedKey) throw new Error("vault locked");831 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);832 if (idx === -1) throw new Error("deleted entry not found");833 entries.splice(idx, 1);834 await persistEntries();835}836837838export async function purgeExpiredTrash(retentionMs: number): Promise<number> {839 if (!derivedKey || retentionMs === 0) return 0;840 const cutoff = Date.now() - retentionMs;841 const before = entries.length;842 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));843 const purged = before - entries.length;844 if (purged > 0) await persistEntries();845 return purged;846}847848export function queryDomain(domain: string): VaultEntry[] {849 return domainIndex.get(domain) ?? [];850}851852export function getEntryById(id: string): VaultEntry | undefined {853 return entries.find((entry) => entry.id === id && !entry.deletedAt);854}855856async function persistSlotA(): Promise<StoredVault> {857 if (!derivedKey) throw new Error("vault locked");858859 const slotA = await loadSlot(SLOT_A);860 if (!slotA) throw new Error("missing slot A");861862 const plaintext = encodePayload(entries, duressConfigured);863 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);864865 const nextSlotA: StoredVault = {866 ...slotA,867 nonce,868 ciphertext,869 tag,870 };871 await writeSlot(SLOT_A, nextSlotA);872873 874 875 876 if (!duressConfigured) {877 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));878 }879880 return nextSlotA;881}882883async function persistSlotB(): Promise<void> {884 if (!derivedKey) throw new Error("vault locked");885886 const slotB = await loadSlot(SLOT_B);887 if (!slotB) throw new Error("missing slot B");888889 890 891 const plaintext = encodePayload(entries, false);892 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);893894 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });895}896897async function persistEntries(): Promise<void> {898 if (!derivedKey) throw new Error("vault locked");899900 if (vaultMode === "decoy") {901 await persistSlotB();902 } else {903 const freshSlotA = await persistSlotA();904 905 906 const activeCount = entries.filter((e) => !e.deletedAt).length;907 void takeSnapshot(freshSlotA, activeCount);908 }909 buildDomainIndexSync(entries);910}911912function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {913 if (!Array.isArray(values)) return undefined;914 const seen = new Set<string>();915 const cleaned: string[] = [];916 for (const value of values) {917 const trimmed = typeof value === "string" ? value.trim() : "";918 if (!trimmed) continue;919 const key = trimmed.toLowerCase();920 if (seen.has(key)) continue;921 seen.add(key);922 cleaned.push(trimmed);923 }924 return cleaned.length > 0 ? cleaned : undefined;925}926927function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {928 const title = entry.title.trim();929 const type = entry.type ?? "login";930931 if (!title) throw new Error("title is required");932 if (type === "login" && !entry.password?.trim()) {933 throw new Error("password is required");934 }935 if (type === "passkey") {936 if (937 !entry.rpId?.trim() ||938 !entry.rpName?.trim() ||939 !entry.credentialId?.trim() ||940 !entry.publicKeyCose?.trim() ||941 !entry.privateKeyJwk ||942 !entry.userHandle?.trim() ||943 !entry.userName?.trim() ||944 !entry.userDisplayName?.trim() ||945 (entry.algorithm !== -7 && entry.algorithm !== -8)946 ) {947 throw new Error("invalid passkey entry");948 }949 }950 if (type === "card") {951 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {952 throw new Error("cardholder name and card number are required");953 }954 }955956 return {957 type,958 title,959 username: type === "login" ? entry.username?.trim() ?? "" : undefined,960 password: type === "login" ? entry.password : undefined,961 url: type === "login" ? entry.url?.trim() ?? "" : undefined,962 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,963 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,964 notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,965 breach: type === "login" ? entry.breach : undefined,966 canary: type === "login" ? entry.canary : undefined,967 tags: entry.tags,968 rpId: type === "passkey" ? entry.rpId?.trim() : undefined,969 rpName: type === "passkey" ? entry.rpName?.trim() : undefined,970 credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,971 publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,972 privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,973 userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,974 userName: type === "passkey" ? entry.userName?.trim() : undefined,975 userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,976 algorithm: type === "passkey" ? entry.algorithm : undefined,977 signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,978 aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,979 backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,980 backupState: type === "passkey" ? entry.backupState === true : undefined,981 lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,982 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,983 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,984 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,985 expYear: type === "card" ? entry.expYear?.trim() : undefined,986 cvc: type === "card" ? entry.cvc?.trim() : undefined,987 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,988 network: type === "card" ? entry.network : undefined,989 last4: type === "card" ? entry.last4?.trim() : undefined,990 };991}992993function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {994 const nextPatch: Partial<VaultEntry> = {};995996 if ("type" in patch) {997 nextPatch.type = patch.type;998 }9991000 if ("title" in patch) {1001 const title = patch.title?.trim() ?? "";1002 if (!title) throw new Error("title is required");1003 nextPatch.title = title;1004 }10051006 if ("url" in patch) {1007 nextPatch.url = patch.url?.trim() ?? "";1008 }10091010 if ("additionalUrls" in patch) {1011 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1012 const seen = new Set<string>();1013 const cleaned: string[] = [];1014 for (const value of incoming) {1015 const trimmed = typeof value === "string" ? value.trim() : "";1016 if (!trimmed) continue;1017 const key = trimmed.toLowerCase();1018 if (seen.has(key)) continue;1019 seen.add(key);1020 cleaned.push(trimmed);1021 }1022 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1023 }10241025 if ("username" in patch) {1026 nextPatch.username = patch.username?.trim() ?? "";1027 }10281029 if ("password" in patch) {1030 const password = patch.password ?? "";1031 1032 nextPatch.password = password || undefined;1033 }10341035 if ("totp" in patch) {1036 nextPatch.totp = patch.totp?.trim() || undefined;1037 }10381039 if ("notes" in patch) {1040 nextPatch.notes = patch.notes?.trim() || undefined;1041 }10421043 if ("breach" in patch) {1044 nextPatch.breach = patch.breach;1045 }10461047 if ("canary" in patch) {1048 nextPatch.canary = patch.canary;1049 }10501051 if ("rpId" in patch) {1052 nextPatch.rpId = patch.rpId?.trim();1053 }10541055 if ("rpName" in patch) {1056 nextPatch.rpName = patch.rpName?.trim();1057 }10581059 if ("credentialId" in patch) {1060 nextPatch.credentialId = patch.credentialId?.trim();1061 }10621063 if ("publicKeyCose" in patch) {1064 nextPatch.publicKeyCose = patch.publicKeyCose?.trim();1065 }10661067 if ("privateKeyJwk" in patch) {1068 nextPatch.privateKeyJwk = patch.privateKeyJwk;1069 }10701071 if ("userHandle" in patch) {1072 nextPatch.userHandle = patch.userHandle?.trim();1073 }10741075 if ("userName" in patch) {1076 nextPatch.userName = patch.userName?.trim();1077 }10781079 if ("userDisplayName" in patch) {1080 nextPatch.userDisplayName = patch.userDisplayName?.trim();1081 }10821083 if ("algorithm" in patch) {1084 nextPatch.algorithm = patch.algorithm;1085 }10861087 if ("signCount" in patch) {1088 nextPatch.signCount = patch.signCount;1089 }10901091 if ("aaguid" in patch) {1092 nextPatch.aaguid = patch.aaguid?.trim();1093 }10941095 if ("backupEligible" in patch) {1096 nextPatch.backupEligible = patch.backupEligible;1097 }10981099 if ("backupState" in patch) {1100 nextPatch.backupState = patch.backupState;1101 }11021103 if ("lastUsedAt" in patch) {1104 nextPatch.lastUsedAt = patch.lastUsedAt;1105 }11061107 if ("tags" in patch) {1108 nextPatch.tags = patch.tags;1109 }11101111 if ("cardholderName" in patch) {1112 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1113 }1114 if ("cardNumber" in patch) {1115 const n = patch.cardNumber ?? "";1116 nextPatch.cardNumber = n || undefined;1117 }1118 if ("expMonth" in patch) {1119 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1120 }1121 if ("expYear" in patch) {1122 nextPatch.expYear = patch.expYear?.trim() || undefined;1123 }1124 if ("cvc" in patch) {1125 nextPatch.cvc = patch.cvc?.trim() || undefined;1126 }1127 if ("cardNickname" in patch) {1128 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1129 }1130 if ("network" in patch) {1131 nextPatch.network = patch.network;1132 }1133 if ("last4" in patch) {1134 nextPatch.last4 = patch.last4?.trim() || undefined;1135 }11361137 return nextPatch;1138}11391140function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1141 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1142 const modifiedAt =1143 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;11441145 return {1146 id: entry.id,1147 type: "type" in entry ? entry.type : "login",1148 title: entry.title,1149 username: entry.username,1150 password: entry.password,1151 url: entry.url,1152 totp: entry.totp,1153 notes: entry.notes,1154 breach: entry.breach,1155 canary: "canary" in entry ? entry.canary : undefined,1156 rpId: "rpId" in entry ? entry.rpId : undefined,1157 rpName: "rpName" in entry ? entry.rpName : undefined,1158 credentialId: "credentialId" in entry ? entry.credentialId : undefined,1159 publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1160 privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1161 userHandle: "userHandle" in entry ? entry.userHandle : undefined,1162 userName: "userName" in entry ? entry.userName : undefined,1163 userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1164 algorithm: "algorithm" in entry ? entry.algorithm : undefined,1165 signCount: "signCount" in entry ? entry.signCount : undefined,1166 aaguid: "aaguid" in entry ? entry.aaguid : undefined,1167 backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1168 backupState: "backupState" in entry ? entry.backupState : undefined,1169 lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1170 createdAt,1171 modifiedAt,1172 deletedAt: entry.deletedAt,1173 tags: "tags" in entry ? entry.tags : undefined,1174 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1175 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1176 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1177 expYear: "expYear" in entry ? entry.expYear : undefined,1178 cvc: "cvc" in entry ? entry.cvc : undefined,1179 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1180 network: "network" in entry ? entry.network : undefined,1181 last4: "last4" in entry ? entry.last4 : undefined,1182 };1183}11841185function hasLegacyShape(parsed: VaultEntry[]): boolean {1186 return parsed.some(1187 (entry) => "created" in entry || "updated" in entry1188 );1189}119011911192119311941195function entryDupeKey(e: VaultEntry): string {1196 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1197 if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1198 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1199}120012011202export function countDuplicates(): number {1203 const best = new Map<string, VaultEntry>();1204 let dupeCount = 0;1205 for (const e of entries.filter((e) => !e.deletedAt)) {1206 const key = entryDupeKey(e);1207 const existing = best.get(key);1208 if (!existing) {1209 best.set(key, e);1210 } else {1211 dupeCount++;1212 1213 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1214 best.set(key, e);1215 }1216 }1217 }1218 return dupeCount;1219}122012211222122312241225export async function deduplicateEntries(): Promise<number> {1226 if (!derivedKey) throw new Error("vault locked");1227 1228 1229 if (vaultMode === "decoy") {1230 throw new Error("deduplicate disabled in decoy mode");1231 }12321233 const best = new Map<string, VaultEntry>();1234 for (const e of entries.filter((en) => !en.deletedAt)) {1235 const key = entryDupeKey(e);1236 const existing = best.get(key);1237 if (!existing) {1238 best.set(key, e);1239 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1240 best.set(key, e);1241 }1242 }12431244 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1245 const now = Date.now();1246 let removed = 0;1247 entries = entries.map((e) => {1248 if (!e.deletedAt && !keepIds.has(e.id)) {1249 removed++;1250 return { ...e, deletedAt: now };1251 }1252 return e;1253 });12541255 if (removed > 0) await persistEntries();1256 return removed;1257}125812591260126112621263const SNAPSHOT_KEY = "peach_vault_snapshots";1264const MAX_SNAPSHOTS = 6;12651266interface VaultSnapshot {1267 timestamp: number;1268 entryCount: number;1269 slot: StoredVault;1270}127112721273export interface SnapshotSummary {1274 index: number;1275 timestamp: number;1276 entryCount: number;1277}12781279async function takeSnapshot(1280 slot: StoredVault,1281 entryCount: number1282): Promise<void> {1283 try {1284 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1285 const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1286 const next: VaultSnapshot[] = [1287 { timestamp: Date.now(), entryCount, slot },1288 ...existing,1289 ].slice(0, MAX_SNAPSHOTS);1290 await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1291 } catch {1292 1293 }1294}12951296export async function listSnapshots(): Promise<SnapshotSummary[]> {1297 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1298 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1299 return snapshots.map((s, i) => ({1300 index: i,1301 timestamp: s.timestamp,1302 entryCount: s.entryCount,1303 }));1304}13051306130713081309131013111312export async function restoreFromSnapshot(1313 index: number,1314 password: string1315): Promise<boolean> {1316 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1317 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1318 const snapshot = snapshots[index];1319 if (!snapshot) return false;13201321 1322 const key = await deriveKey(password, unhex(snapshot.slot.salt));1323 const payload = await tryDecryptSlot(snapshot.slot, key);1324 if (!payload) {1325 zeroKey(key);1326 return false;1327 }13281329 1330 await writeSlot(SLOT_A, snapshot.slot);1331 await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));13321333 1334 if (derivedKey) zeroKey(derivedKey);1335 derivedKey = key;1336 entries = payload.entries;1337 duressConfigured = payload.duressConfigured;1338 vaultMode = "real";1339 buildDomainIndexSync(entries);1340 await loadSettings();1341 touchAutoLock();13421343 return true;1344}13451346134713481349135013511352export async function changePassword(1353 currentPassword: string,1354 newPassword: string1355): Promise<boolean> {1356 if (!derivedKey || vaultMode !== "real") return false;13571358 1359 const valid = await verifyMainPassword(currentPassword);1360 if (!valid) return false;13611362 const slotA = await loadSlot(SLOT_A);1363 if (!slotA) return false;13641365 1366 const newSalt = generateSalt();1367 const newKey = await deriveKey(newPassword, newSalt);13681369 1370 const plaintext = encodePayload(entries, duressConfigured);1371 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);13721373 const freshSlotA: StoredVault = {1374 ...slotA,1375 salt: hex(newSalt),1376 nonce,1377 ciphertext,1378 tag,1379 };1380 await writeSlot(SLOT_A, freshSlotA);13811382 1383 1384 1385 zeroKey(derivedKey);1386 derivedKey = newKey;13871388 1389 1390 1391 const activeCount = entries.filter((e) => !e.deletedAt).length;1392 void takeSnapshot(freshSlotA, activeCount);13931394 1395 1396 1397 if (!duressConfigured) {1398 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1399 }14001401 return true;1402}