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 };722723 entries.push(nextEntry);724 await persistEntries();725726 727 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {728 checkBreach(nextEntry.password).then(async (count) => {729 if (count > 0) {730 const idx = entries.findIndex((e) => e.id === nextEntry.id);731 if (idx !== -1) {732 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };733 await persistEntries();734 }735 }736 });737 }738739 return nextEntry;740}741742export async function updateEntry(743 id: string,744 patch: Partial<VaultEntry>745): Promise<void> {746 if (!derivedKey) throw new Error("vault locked");747748 const idx = entries.findIndex((entry) => entry.id === id);749 if (idx === -1 || entries[idx].deletedAt) {750 throw new Error("entry not found");751 }752753 const current = entries[idx];754 entries[idx] = {755 ...current,756 ...sanitizeEntryPatch(patch),757 modifiedAt: Date.now(),758 };759760 761 762 763 if (entries[idx].type === "login" && !entries[idx].password) {764 entries[idx] = current;765 throw new Error("password is required for login entries");766 }767768 769 if (entries[idx].type === "card" && !entries[idx].cardNumber) {770 entries[idx] = current;771 throw new Error("card number is required for card entries");772 }773774 await persistEntries();775776 777 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {778 const password = entries[idx].password!;779 checkBreach(password).then(async (count) => {780 const i = entries.findIndex((e) => e.id === id);781 if (i !== -1) {782 entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };783 await persistEntries();784 }785 });786 }787}788789export async function deleteEntry(id: string): Promise<void> {790 if (!derivedKey) throw new Error("vault locked");791792 const entry = entries.find((item) => item.id === id);793 if (!entry || entry.deletedAt) {794 throw new Error("entry not found");795 }796797 entry.deletedAt = Date.now();798 entry.modifiedAt = Date.now();799 await persistEntries();800}801802export function getDeletedEntries(): VaultEntry[] {803 if (!derivedKey) return [];804 return entries.filter((e) => !!e.deletedAt);805}806807export async function restoreEntry(id: string): Promise<void> {808 if (!derivedKey) throw new Error("vault locked");809 const entry = entries.find((e) => e.id === id && e.deletedAt);810 if (!entry) throw new Error("deleted entry not found");811 delete entry.deletedAt;812 entry.modifiedAt = Date.now();813 await persistEntries();814 buildDomainIndexSync(entries);815}816817export async function purgeEntry(id: string): Promise<void> {818 if (!derivedKey) throw new Error("vault locked");819 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);820 if (idx === -1) throw new Error("deleted entry not found");821 entries.splice(idx, 1);822 await persistEntries();823}824825826export async function purgeExpiredTrash(retentionMs: number): Promise<number> {827 if (!derivedKey || retentionMs === 0) return 0;828 const cutoff = Date.now() - retentionMs;829 const before = entries.length;830 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));831 const purged = before - entries.length;832 if (purged > 0) await persistEntries();833 return purged;834}835836export function queryDomain(domain: string): VaultEntry[] {837 return domainIndex.get(domain) ?? [];838}839840export function getEntryById(id: string): VaultEntry | undefined {841 return entries.find((entry) => entry.id === id && !entry.deletedAt);842}843844async function persistSlotA(): Promise<StoredVault> {845 if (!derivedKey) throw new Error("vault locked");846847 const slotA = await loadSlot(SLOT_A);848 if (!slotA) throw new Error("missing slot A");849850 const plaintext = encodePayload(entries, duressConfigured);851 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);852853 const nextSlotA: StoredVault = {854 ...slotA,855 nonce,856 ciphertext,857 tag,858 };859 await writeSlot(SLOT_A, nextSlotA);860861 862 863 864 if (!duressConfigured) {865 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));866 }867868 return nextSlotA;869}870871async function persistSlotB(): Promise<void> {872 if (!derivedKey) throw new Error("vault locked");873874 const slotB = await loadSlot(SLOT_B);875 if (!slotB) throw new Error("missing slot B");876877 878 879 const plaintext = encodePayload(entries, false);880 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);881882 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });883}884885async function persistEntries(): Promise<void> {886 if (!derivedKey) throw new Error("vault locked");887888 if (vaultMode === "decoy") {889 await persistSlotB();890 } else {891 const freshSlotA = await persistSlotA();892 893 894 const activeCount = entries.filter((e) => !e.deletedAt).length;895 void takeSnapshot(freshSlotA, activeCount);896 }897 buildDomainIndexSync(entries);898}899900function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {901 if (!Array.isArray(values)) return undefined;902 const seen = new Set<string>();903 const cleaned: string[] = [];904 for (const value of values) {905 const trimmed = typeof value === "string" ? value.trim() : "";906 if (!trimmed) continue;907 const key = trimmed.toLowerCase();908 if (seen.has(key)) continue;909 seen.add(key);910 cleaned.push(trimmed);911 }912 return cleaned.length > 0 ? cleaned : undefined;913}914915function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {916 const title = entry.title.trim();917 const type = entry.type ?? "login";918919 if (!title) throw new Error("title is required");920 if (type === "login" && !entry.password?.trim()) {921 throw new Error("password is required");922 }923 if (type === "passkey") {924 if (925 !entry.rpId?.trim() ||926 !entry.rpName?.trim() ||927 !entry.credentialId?.trim() ||928 !entry.publicKeyCose?.trim() ||929 !entry.privateKeyJwk ||930 !entry.userHandle?.trim() ||931 !entry.userName?.trim() ||932 !entry.userDisplayName?.trim() ||933 (entry.algorithm !== -7 && entry.algorithm !== -8)934 ) {935 throw new Error("invalid passkey entry");936 }937 }938 if (type === "card") {939 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {940 throw new Error("cardholder name and card number are required");941 }942 }943944 return {945 type,946 title,947 username: type === "login" ? entry.username?.trim() ?? "" : undefined,948 password: type === "login" ? entry.password : undefined,949 url: type === "login" ? entry.url?.trim() ?? "" : undefined,950 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,951 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,952 notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,953 breach: type === "login" ? entry.breach : undefined,954 canary: type === "login" ? entry.canary : undefined,955 tags: entry.tags,956 rpId: type === "passkey" ? entry.rpId?.trim() : undefined,957 rpName: type === "passkey" ? entry.rpName?.trim() : undefined,958 credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,959 publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,960 privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,961 userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,962 userName: type === "passkey" ? entry.userName?.trim() : undefined,963 userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,964 algorithm: type === "passkey" ? entry.algorithm : undefined,965 signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,966 aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,967 backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,968 backupState: type === "passkey" ? entry.backupState === true : undefined,969 lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,970 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,971 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,972 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,973 expYear: type === "card" ? entry.expYear?.trim() : undefined,974 cvc: type === "card" ? entry.cvc?.trim() : undefined,975 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,976 network: type === "card" ? entry.network : undefined,977 last4: type === "card" ? entry.last4?.trim() : undefined,978 };979}980981function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {982 const nextPatch: Partial<VaultEntry> = {};983984 if ("type" in patch) {985 nextPatch.type = patch.type;986 }987988 if ("title" in patch) {989 const title = patch.title?.trim() ?? "";990 if (!title) throw new Error("title is required");991 nextPatch.title = title;992 }993994 if ("url" in patch) {995 nextPatch.url = patch.url?.trim() ?? "";996 }997998 if ("additionalUrls" in patch) {999 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1000 const seen = new Set<string>();1001 const cleaned: string[] = [];1002 for (const value of incoming) {1003 const trimmed = typeof value === "string" ? value.trim() : "";1004 if (!trimmed) continue;1005 const key = trimmed.toLowerCase();1006 if (seen.has(key)) continue;1007 seen.add(key);1008 cleaned.push(trimmed);1009 }1010 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1011 }10121013 if ("username" in patch) {1014 nextPatch.username = patch.username?.trim() ?? "";1015 }10161017 if ("password" in patch) {1018 const password = patch.password ?? "";1019 1020 nextPatch.password = password || undefined;1021 }10221023 if ("totp" in patch) {1024 nextPatch.totp = patch.totp?.trim() || undefined;1025 }10261027 if ("notes" in patch) {1028 nextPatch.notes = patch.notes?.trim() || undefined;1029 }10301031 if ("breach" in patch) {1032 nextPatch.breach = patch.breach;1033 }10341035 if ("canary" in patch) {1036 nextPatch.canary = patch.canary;1037 }10381039 if ("rpId" in patch) {1040 nextPatch.rpId = patch.rpId?.trim();1041 }10421043 if ("rpName" in patch) {1044 nextPatch.rpName = patch.rpName?.trim();1045 }10461047 if ("credentialId" in patch) {1048 nextPatch.credentialId = patch.credentialId?.trim();1049 }10501051 if ("publicKeyCose" in patch) {1052 nextPatch.publicKeyCose = patch.publicKeyCose?.trim();1053 }10541055 if ("privateKeyJwk" in patch) {1056 nextPatch.privateKeyJwk = patch.privateKeyJwk;1057 }10581059 if ("userHandle" in patch) {1060 nextPatch.userHandle = patch.userHandle?.trim();1061 }10621063 if ("userName" in patch) {1064 nextPatch.userName = patch.userName?.trim();1065 }10661067 if ("userDisplayName" in patch) {1068 nextPatch.userDisplayName = patch.userDisplayName?.trim();1069 }10701071 if ("algorithm" in patch) {1072 nextPatch.algorithm = patch.algorithm;1073 }10741075 if ("signCount" in patch) {1076 nextPatch.signCount = patch.signCount;1077 }10781079 if ("aaguid" in patch) {1080 nextPatch.aaguid = patch.aaguid?.trim();1081 }10821083 if ("backupEligible" in patch) {1084 nextPatch.backupEligible = patch.backupEligible;1085 }10861087 if ("backupState" in patch) {1088 nextPatch.backupState = patch.backupState;1089 }10901091 if ("lastUsedAt" in patch) {1092 nextPatch.lastUsedAt = patch.lastUsedAt;1093 }10941095 if ("tags" in patch) {1096 nextPatch.tags = patch.tags;1097 }10981099 if ("cardholderName" in patch) {1100 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1101 }1102 if ("cardNumber" in patch) {1103 const n = patch.cardNumber ?? "";1104 nextPatch.cardNumber = n || undefined;1105 }1106 if ("expMonth" in patch) {1107 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1108 }1109 if ("expYear" in patch) {1110 nextPatch.expYear = patch.expYear?.trim() || undefined;1111 }1112 if ("cvc" in patch) {1113 nextPatch.cvc = patch.cvc?.trim() || undefined;1114 }1115 if ("cardNickname" in patch) {1116 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1117 }1118 if ("network" in patch) {1119 nextPatch.network = patch.network;1120 }1121 if ("last4" in patch) {1122 nextPatch.last4 = patch.last4?.trim() || undefined;1123 }11241125 return nextPatch;1126}11271128function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1129 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1130 const modifiedAt =1131 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;11321133 return {1134 id: entry.id,1135 type: "type" in entry ? entry.type : "login",1136 title: entry.title,1137 username: entry.username,1138 password: entry.password,1139 url: entry.url,1140 totp: entry.totp,1141 notes: entry.notes,1142 breach: entry.breach,1143 canary: "canary" in entry ? entry.canary : undefined,1144 rpId: "rpId" in entry ? entry.rpId : undefined,1145 rpName: "rpName" in entry ? entry.rpName : undefined,1146 credentialId: "credentialId" in entry ? entry.credentialId : undefined,1147 publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1148 privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1149 userHandle: "userHandle" in entry ? entry.userHandle : undefined,1150 userName: "userName" in entry ? entry.userName : undefined,1151 userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1152 algorithm: "algorithm" in entry ? entry.algorithm : undefined,1153 signCount: "signCount" in entry ? entry.signCount : undefined,1154 aaguid: "aaguid" in entry ? entry.aaguid : undefined,1155 backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1156 backupState: "backupState" in entry ? entry.backupState : undefined,1157 lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1158 createdAt,1159 modifiedAt,1160 deletedAt: entry.deletedAt,1161 tags: "tags" in entry ? entry.tags : undefined,1162 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1163 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1164 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1165 expYear: "expYear" in entry ? entry.expYear : undefined,1166 cvc: "cvc" in entry ? entry.cvc : undefined,1167 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1168 network: "network" in entry ? entry.network : undefined,1169 last4: "last4" in entry ? entry.last4 : undefined,1170 };1171}11721173function hasLegacyShape(parsed: VaultEntry[]): boolean {1174 return parsed.some(1175 (entry) => "created" in entry || "updated" in entry1176 );1177}117811791180118111821183function entryDupeKey(e: VaultEntry): string {1184 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1185 if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1186 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1187}118811891190export function countDuplicates(): number {1191 const best = new Map<string, VaultEntry>();1192 let dupeCount = 0;1193 for (const e of entries.filter((e) => !e.deletedAt)) {1194 const key = entryDupeKey(e);1195 const existing = best.get(key);1196 if (!existing) {1197 best.set(key, e);1198 } else {1199 dupeCount++;1200 1201 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1202 best.set(key, e);1203 }1204 }1205 }1206 return dupeCount;1207}120812091210121112121213export async function deduplicateEntries(): Promise<number> {1214 if (!derivedKey) throw new Error("vault locked");1215 1216 1217 if (vaultMode === "decoy") {1218 throw new Error("deduplicate disabled in decoy mode");1219 }12201221 const best = new Map<string, VaultEntry>();1222 for (const e of entries.filter((en) => !en.deletedAt)) {1223 const key = entryDupeKey(e);1224 const existing = best.get(key);1225 if (!existing) {1226 best.set(key, e);1227 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1228 best.set(key, e);1229 }1230 }12311232 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1233 const now = Date.now();1234 let removed = 0;1235 entries = entries.map((e) => {1236 if (!e.deletedAt && !keepIds.has(e.id)) {1237 removed++;1238 return { ...e, deletedAt: now };1239 }1240 return e;1241 });12421243 if (removed > 0) await persistEntries();1244 return removed;1245}124612471248124912501251const SNAPSHOT_KEY = "peach_vault_snapshots";1252const MAX_SNAPSHOTS = 6;12531254interface VaultSnapshot {1255 timestamp: number;1256 entryCount: number;1257 slot: StoredVault;1258}125912601261export interface SnapshotSummary {1262 index: number;1263 timestamp: number;1264 entryCount: number;1265}12661267async function takeSnapshot(1268 slot: StoredVault,1269 entryCount: number1270): Promise<void> {1271 try {1272 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1273 const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1274 const next: VaultSnapshot[] = [1275 { timestamp: Date.now(), entryCount, slot },1276 ...existing,1277 ].slice(0, MAX_SNAPSHOTS);1278 await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1279 } catch {1280 1281 }1282}12831284export async function listSnapshots(): Promise<SnapshotSummary[]> {1285 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1286 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1287 return snapshots.map((s, i) => ({1288 index: i,1289 timestamp: s.timestamp,1290 entryCount: s.entryCount,1291 }));1292}12931294129512961297129812991300export async function restoreFromSnapshot(1301 index: number,1302 password: string1303): Promise<boolean> {1304 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1305 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1306 const snapshot = snapshots[index];1307 if (!snapshot) return false;13081309 1310 const key = await deriveKey(password, unhex(snapshot.slot.salt));1311 const payload = await tryDecryptSlot(snapshot.slot, key);1312 if (!payload) {1313 zeroKey(key);1314 return false;1315 }13161317 1318 await writeSlot(SLOT_A, snapshot.slot);1319 await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));13201321 1322 if (derivedKey) zeroKey(derivedKey);1323 derivedKey = key;1324 entries = payload.entries;1325 duressConfigured = payload.duressConfigured;1326 vaultMode = "real";1327 buildDomainIndexSync(entries);1328 await loadSettings();1329 touchAutoLock();13301331 return true;1332}13331334133513361337133813391340export async function changePassword(1341 currentPassword: string,1342 newPassword: string1343): Promise<boolean> {1344 if (!derivedKey || vaultMode !== "real") return false;13451346 1347 const valid = await verifyMainPassword(currentPassword);1348 if (!valid) return false;13491350 const slotA = await loadSlot(SLOT_A);1351 if (!slotA) return false;13521353 1354 const newSalt = generateSalt();1355 const newKey = await deriveKey(newPassword, newSalt);13561357 1358 const plaintext = encodePayload(entries, duressConfigured);1359 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);13601361 const freshSlotA: StoredVault = {1362 ...slotA,1363 salt: hex(newSalt),1364 nonce,1365 ciphertext,1366 tag,1367 };1368 await writeSlot(SLOT_A, freshSlotA);13691370 1371 1372 1373 zeroKey(derivedKey);1374 derivedKey = newKey;13751376 1377 1378 1379 const activeCount = entries.filter((e) => !e.deletedAt).length;1380 void takeSnapshot(freshSlotA, activeCount);13811382 1383 1384 1385 if (!duressConfigured) {1386 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1387 }13881389 return true;1390}