1import { deriveKey, generateSalt } from "../crypto/argon2";2import { extractDomain } from "../shared/domain";3import type {4 AccentColorName,5 LegacyVaultEntry,6 ManualSummonRecord,7 StoredVault,8 VaultEntry,9} from "../shared/types";10import { decrypt, encrypt, zeroKey } from "./aes";11import { clearAutoLock, touchAutoLock } from "./alarms";1213type EntryDraft = Omit<14 VaultEntry,15 "id" | "createdAt" | "modifiedAt" | "deletedAt"16>;171819202122const SLOT_A = "peach_vault_a";23const SLOT_B = "peach_vault_b";242526const LEGACY_MAIN = "peach_vault";27const LEGACY_DECOY = "peach_decoy_vault";28const LEGACY_DURESS_FLAG = "peach_duress";29303132const PAD_BLOCK = 1024;3334let derivedKey: Uint8Array | null = null;35let entries: VaultEntry[] = [];36let domainIndex: Map<string, VaultEntry[]> = new Map();37let vaultMode: "real" | "decoy" = "real";38394041let duressConfigured = false;4243let migrationRan = false;444546474849export interface VaultSettings {50 accentColor?: AccentColorName;51 theme?: "dark" | "light";52 autoLockMinutes?: number;53 clipboardClearSeconds?: number;54 lockOnClose?: boolean;55 breachMonitoring?: boolean;56 passkeyProviderEnabled?: boolean;57 trashRetentionDays?: number;58 manualSummonDomains?: Record<string, ManualSummonRecord>;59 declinedCardFingerprints?: string[];60}6162let settings: VaultSettings = {};6364export function getSettings(): VaultSettings {65 return settings;66}6768export async function ensureSettingsLoaded(): Promise<VaultSettings> {69 await loadSettings();70 return settings;71}7273export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {74 await loadSettings();75 settings = { ...settings, ...patch };76 await persistSettings();77}7879async function persistSettings(): Promise<void> {80 try {81 await chrome.storage.local.set({ peach_settings: settings });82 } catch (err) {83 throw mapStorageError(err);84 }85}8687function mapStorageError(err: unknown): Error {88 const message = err instanceof Error ? err.message : String(err);89 if (/QUOTA_BYTES|quota/i.test(message)) {90 return new Error("STORAGE_QUOTA_EXCEEDED");91 }92 return err instanceof Error ? err : new Error(message);93}9495async function loadSettings(): Promise<void> {96 const result = await chrome.storage.local.get("peach_settings");97 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};98}99100101102103104export async function checkBreach(password: string): Promise<number> {105 const encoder = new TextEncoder();106 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));107 const hashHex = Array.from(new Uint8Array(hashBuffer))108 .map((b) => b.toString(16).padStart(2, "0"))109 .join("")110 .toUpperCase();111112 const prefix = hashHex.slice(0, 5);113 const suffix = hashHex.slice(5);114115 try {116 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);117 if (!res.ok) return 0;118 const text = await res.text();119 const match = text.split("\r\n").find((l) => l.startsWith(suffix));120 return match ? parseInt(match.split(":")[1], 10) : 0;121 } catch {122 return 0; 123 }124}125126export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {127 const fingerprint = await cardFingerprint(last4, expMonth, expYear);128 await loadSettings();129 settings = {130 ...settings,131 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],132 };133 await persistSettings();134}135136export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {137 await loadSettings();138 const fingerprint = await cardFingerprint(last4, expMonth, expYear);139 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);140}141142async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {143 const encoder = new TextEncoder();144 const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));145 return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");146}147148function hex(buf: Uint8Array): string {149 return Array.from(buf)150 .map((b) => b.toString(16).padStart(2, "0"))151 .join("");152}153154function unhex(s: string): Uint8Array {155 const out = new Uint8Array(s.length / 2);156 for (let i = 0; i < s.length; i += 2) {157 out[i / 2] = parseInt(s.slice(i, i + 2), 16);158 }159 return out;160}161162163164165166async function loadSlot(key: string): Promise<StoredVault | null> {167 const result = await chrome.storage.local.get(key);168 return (result as Record<string, StoredVault | undefined>)[key] ?? null;169}170171async function writeSlot(key: string, vault: StoredVault): Promise<void> {172 try {173 await chrome.storage.local.set({ [key]: vault });174 } catch (err) {175 throw mapStorageError(err);176 }177}178179180181182183184async function ensureMigrated(): Promise<void> {185 if (migrationRan) return;186 migrationRan = true;187188 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];189 const raw = await chrome.storage.local.get(keys);190 const slotAExists = raw[SLOT_A] !== undefined;191 const slotBExists = raw[SLOT_B] !== undefined;192 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;193 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;194195 196 if (!slotAExists && !legacyMain) return;197198 199 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;200201 if (legacyMain && !slotAExists) {202 await writeSlot(SLOT_A, legacyMain);203 }204205 206 if (!slotBExists) {207 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;208 if (legacyDecoy) {209 await writeSlot(SLOT_B, legacyDecoy);210 } else if (target) {211 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));212 }213 }214215 const toRemove: string[] = [];216 if (legacyMain) toRemove.push(LEGACY_MAIN);217 if (legacyDecoy) toRemove.push(LEGACY_DECOY);218 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);219 if (toRemove.length > 0) {220 await chrome.storage.local.remove(toRemove);221 }222}223224225226227228229230231232233234function padPlaintext(json: string): string {235 const encoded = new TextEncoder().encode(json);236 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);237 const padLen = target - encoded.length;238 return json + " ".repeat(padLen);239}240241242243244245246247function generateNoiseBlob(ciphertextBytes: number): StoredVault {248 const salt = crypto.getRandomValues(new Uint8Array(16));249 const nonce = crypto.getRandomValues(new Uint8Array(12));250 const cipher = crypto.getRandomValues(new Uint8Array(ciphertextBytes));251 const tag = crypto.getRandomValues(new Uint8Array(16));252 return {253 version: 1,254 salt: hex(salt),255 nonce: hex(nonce),256 ciphertext: hex(cipher),257 tag: hex(tag),258 };259}260261262263264265type VaultPayload = {266 entries: VaultEntry[];267 duressConfigured?: boolean;268};269270type DecryptedPayload = {271 entries: VaultEntry[];272 duressConfigured: boolean;273};274275function encodePayload(entries: VaultEntry[], duress: boolean): string {276 const envelope: VaultPayload = { entries, duressConfigured: duress };277 return padPlaintext(JSON.stringify(envelope));278}279280function decodePayload(plain: string): DecryptedPayload {281 const parsed = JSON.parse(plain) as282 | Array<VaultEntry | LegacyVaultEntry>283 | VaultPayload;284285 if (Array.isArray(parsed)) {286 287 return {288 entries: parsed.map(normalizeEntry),289 duressConfigured: false,290 };291 }292293 return {294 entries: (parsed.entries ?? []).map(normalizeEntry),295 duressConfigured: parsed.duressConfigured === true,296 };297}298299async function tryDecryptSlot(300 vault: StoredVault,301 key: Uint8Array302): Promise<DecryptedPayload | null> {303 try {304 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);305 return decodePayload(plain);306 } catch {307 return null;308 }309}310311312313314315function buildDomainIndexSync(all: VaultEntry[]): void {316 domainIndex = new Map();317318 for (const entry of all) {319 if (entry.deletedAt) continue;320 if (entry.type === "note" || entry.type === "passkey" || entry.type === "card") continue;321322 const domain = extractDomain(entry.url ?? "");323 if (!domain) continue;324325 const bucket = domainIndex.get(domain) ?? [];326 bucket.push(entry);327 domainIndex.set(domain, bucket);328 }329}330331export async function createVault(password: string): Promise<void> {332 await ensureMigrated();333334 const salt = generateSalt();335 derivedKey = await deriveKey(password, salt);336 entries = [];337 vaultMode = "real";338 duressConfigured = false;339 buildDomainIndexSync(entries);340341 const plaintext = encodePayload(entries, duressConfigured);342 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);343344 const slotA: StoredVault = {345 version: 1,346 salt: hex(salt),347 nonce,348 ciphertext,349 tag,350 };351 await writeSlot(SLOT_A, slotA);352353 354 355 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));356357 touchAutoLock();358}359360export async function hasVault(): Promise<boolean> {361 await ensureMigrated();362 return (await loadSlot(SLOT_A)) !== null;363}364365366367368369370export async function isDuressEnabled(): Promise<boolean> {371 return vaultMode === "real" && duressConfigured;372}373374export async function hasDecoyVault(): Promise<boolean> {375 return isDuressEnabled();376}377378export async function setupDuress(duressPassword: string): Promise<void> {379 if (!derivedKey || vaultMode !== "real") {380 throw new Error("vault must be unlocked in real mode");381 }382 await ensureMigrated();383384 385 386 387 const slotA = await loadSlot(SLOT_A);388 if (!slotA) throw new Error("missing slot A");389390 const decoySalt = generateSalt();391 const decoyKey = await deriveKey(duressPassword, decoySalt);392 try {393 394 395 const targetBytes = slotA.ciphertext.length / 2;396 let plaintext = encodePayload([], false);397 const encoded = new TextEncoder().encode(plaintext);398 if (encoded.length < targetBytes) {399 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);400 }401 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);402 await writeSlot(SLOT_B, {403 version: 1,404 salt: hex(decoySalt),405 nonce,406 ciphertext,407 tag,408 });409 } finally {410 zeroKey(decoyKey);411 }412413 414 415 duressConfigured = true;416 await persistSlotA();417}418419export async function disableDuress(): Promise<void> {420 if (!derivedKey || vaultMode !== "real") {421 throw new Error("vault must be unlocked in real mode");422 }423 await ensureMigrated();424425 426 427 428 const slotA = await loadSlot(SLOT_A);429 if (slotA) {430 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));431 }432433 duressConfigured = false;434 await persistSlotA();435}436437export async function verifyMainPassword(password: string): Promise<boolean> {438 await ensureMigrated();439 const slotA = await loadSlot(SLOT_A);440 if (!slotA) return false;441 const key = await deriveKey(password, unhex(slotA.salt));442 try {443 return (await tryDecryptSlot(slotA, key)) !== null;444 } finally {445 zeroKey(key);446 }447}448449export async function unlock(password: string): Promise<boolean> {450 await ensureMigrated();451452 453 454 455 456 457 const [slotA, slotB] = await Promise.all([458 loadSlot(SLOT_A),459 loadSlot(SLOT_B),460 ]);461462 463 const [keyA, keyB] = await Promise.all([464 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),465 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),466 ]);467468 469 const [payloadA, payloadB] = await Promise.all([470 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),471 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),472 ]);473474 if (payloadA && keyA) {475 derivedKey = keyA;476 vaultMode = "real";477 entries = payloadA.entries;478 duressConfigured = payloadA.duressConfigured;479 buildDomainIndexSync(entries);480 await loadSettings();481 touchAutoLock();482 if (keyB) zeroKey(keyB);483 484 485 if (hasLegacyShape(payloadA.entries)) {486 await persistSlotA();487 }488 return true;489 }490491 if (payloadB && keyB) {492 derivedKey = keyB;493 vaultMode = "decoy";494 entries = payloadB.entries;495 duressConfigured = false;496 buildDomainIndexSync(entries);497 await loadSettings();498 touchAutoLock();499 if (keyA) zeroKey(keyA);500 return true;501 }502503 if (keyA) zeroKey(keyA);504 if (keyB) zeroKey(keyB);505 return false;506}507508export function lock(): void {509 if (derivedKey) {510 zeroKey(derivedKey);511 derivedKey = null;512 }513514 entries = [];515 domainIndex = new Map();516 vaultMode = "real";517 duressConfigured = false;518 clearAutoLock();519}520521export function isUnlocked(): boolean {522 return derivedKey !== null;523}524525export function getEntries(): VaultEntry[] {526 if (!derivedKey) throw new Error("vault locked");527 return entries.filter((entry) => !entry.deletedAt);528}529530export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {531 if (!derivedKey) throw new Error("vault locked");532533 const now = Date.now();534 const added: VaultEntry[] = [];535 for (const draft of drafts) {536 try {537 const sanitized = sanitizeEntryDraft(draft);538 const nextEntry: VaultEntry = {539 ...sanitized,540 id: crypto.randomUUID(),541 createdAt: now,542 modifiedAt: now,543 };544 entries.push(nextEntry);545 added.push(nextEntry);546 } catch {547 548 }549 }550551 if (added.length > 0) {552 await persistEntries();553 }554555 return added;556}557558559560561562563564565export async function restoreEntries(566 incoming: VaultEntry[],567 mode: "replace" | "merge"568): Promise<{ added: number; updated: number; skipped: number }> {569 if (!derivedKey) throw new Error("vault locked");570571 572 573 574 if (mode === "replace" && incoming.length === 0) {575 throw new Error("refuse to replace with empty entry set");576 }577578 let added = 0;579 let updated = 0;580 let skipped = 0;581582 if (mode === "replace") {583 entries = [];584 for (const entry of incoming) {585 const normalized = sanitizeRestoredEntry(entry);586 if (!normalized) {587 skipped += 1;588 continue;589 }590 entries.push(normalized);591 added += 1;592 }593 } else {594 const byId = new Map<string, VaultEntry>();595 for (const e of entries) byId.set(e.id, e);596597 for (const entry of incoming) {598 const normalized = sanitizeRestoredEntry(entry);599 if (!normalized) {600 skipped += 1;601 continue;602 }603 const existing = byId.get(normalized.id);604 if (!existing) {605 entries.push(normalized);606 added += 1;607 } else if (normalized.modifiedAt > existing.modifiedAt) {608 const idx = entries.findIndex((e) => e.id === existing.id);609 if (idx !== -1) {610 entries[idx] = normalized;611 updated += 1;612 }613 } else {614 skipped += 1;615 }616 }617 }618619 await persistEntries();620 return { added, updated, skipped };621}622623624625626627628function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {629 if (!entry || typeof entry !== "object") return null;630 const title = typeof entry.title === "string" ? entry.title.trim() : "";631 if (!title) return null;632 const type = entry.type ?? "login";633 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();634 const now = Date.now();635 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;636 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;637638 if (type === "login" && typeof entry.password !== "string") {639 return null;640 }641 if (642 type === "passkey" &&643 (typeof entry.rpId !== "string" ||644 typeof entry.credentialId !== "string" ||645 !entry.privateKeyJwk)646 ) {647 return null;648 }649650 return {651 ...entry,652 id,653 title,654 type,655 createdAt,656 modifiedAt,657 };658}659660export async function clearVaultEntries(661 confirmation: "REPLACE_ALL"662): Promise<void> {663 if (confirmation !== "REPLACE_ALL") {664 throw new Error("clearVaultEntries requires explicit confirmation");665 }666 if (!derivedKey) throw new Error("vault locked");667 for (const entry of entries) {668 if (!entry.deletedAt) {669 entry.deletedAt = Date.now();670 entry.modifiedAt = Date.now();671 }672 }673 await persistEntries();674}675676export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {677 if (!derivedKey) throw new Error("vault locked");678679 const sanitized = sanitizeEntryDraft(entry);680 const now = Date.now();681 const nextEntry: VaultEntry = {682 ...sanitized,683 id: crypto.randomUUID(),684 createdAt: now,685 modifiedAt: now,686 };687688 entries.push(nextEntry);689 await persistEntries();690691 692 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {693 checkBreach(nextEntry.password).then(async (count) => {694 if (count > 0) {695 const idx = entries.findIndex((e) => e.id === nextEntry.id);696 if (idx !== -1) {697 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };698 await persistEntries();699 }700 }701 });702 }703704 return nextEntry;705}706707export async function updateEntry(708 id: string,709 patch: Partial<VaultEntry>710): Promise<void> {711 if (!derivedKey) throw new Error("vault locked");712713 const idx = entries.findIndex((entry) => entry.id === id);714 if (idx === -1 || entries[idx].deletedAt) {715 throw new Error("entry not found");716 }717718 const current = entries[idx];719 entries[idx] = {720 ...current,721 ...sanitizeEntryPatch(patch),722 modifiedAt: Date.now(),723 };724725 726 727 728 if (entries[idx].type === "login" && !entries[idx].password) {729 entries[idx] = current;730 throw new Error("password is required for login entries");731 }732733 734 if (entries[idx].type === "card" && !entries[idx].cardNumber) {735 entries[idx] = current;736 throw new Error("card number is required for card entries");737 }738739 await persistEntries();740741 742 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {743 const password = entries[idx].password!;744 checkBreach(password).then(async (count) => {745 const i = entries.findIndex((e) => e.id === id);746 if (i !== -1) {747 entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };748 await persistEntries();749 }750 });751 }752}753754export async function deleteEntry(id: string): Promise<void> {755 if (!derivedKey) throw new Error("vault locked");756757 const entry = entries.find((item) => item.id === id);758 if (!entry || entry.deletedAt) {759 throw new Error("entry not found");760 }761762 entry.deletedAt = Date.now();763 entry.modifiedAt = Date.now();764 await persistEntries();765}766767export function getDeletedEntries(): VaultEntry[] {768 if (!derivedKey) return [];769 return entries.filter((e) => !!e.deletedAt);770}771772export async function restoreEntry(id: string): Promise<void> {773 if (!derivedKey) throw new Error("vault locked");774 const entry = entries.find((e) => e.id === id && e.deletedAt);775 if (!entry) throw new Error("deleted entry not found");776 delete entry.deletedAt;777 entry.modifiedAt = Date.now();778 await persistEntries();779 buildDomainIndexSync(entries);780}781782export async function purgeEntry(id: string): Promise<void> {783 if (!derivedKey) throw new Error("vault locked");784 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);785 if (idx === -1) throw new Error("deleted entry not found");786 entries.splice(idx, 1);787 await persistEntries();788}789790791export async function purgeExpiredTrash(retentionMs: number): Promise<number> {792 if (!derivedKey || retentionMs === 0) return 0;793 const cutoff = Date.now() - retentionMs;794 const before = entries.length;795 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));796 const purged = before - entries.length;797 if (purged > 0) await persistEntries();798 return purged;799}800801export function queryDomain(domain: string): VaultEntry[] {802 return domainIndex.get(domain) ?? [];803}804805export function getEntryById(id: string): VaultEntry | undefined {806 return entries.find((entry) => entry.id === id && !entry.deletedAt);807}808809async function persistSlotA(): Promise<StoredVault> {810 if (!derivedKey) throw new Error("vault locked");811812 const slotA = await loadSlot(SLOT_A);813 if (!slotA) throw new Error("missing slot A");814815 const plaintext = encodePayload(entries, duressConfigured);816 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);817818 const nextSlotA: StoredVault = {819 ...slotA,820 nonce,821 ciphertext,822 tag,823 };824 await writeSlot(SLOT_A, nextSlotA);825826 827 828 829 if (!duressConfigured) {830 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));831 }832833 return nextSlotA;834}835836async function persistSlotB(): Promise<void> {837 if (!derivedKey) throw new Error("vault locked");838839 const slotB = await loadSlot(SLOT_B);840 if (!slotB) throw new Error("missing slot B");841842 843 844 const plaintext = encodePayload(entries, false);845 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);846847 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });848}849850async function persistEntries(): Promise<void> {851 if (!derivedKey) throw new Error("vault locked");852853 if (vaultMode === "decoy") {854 await persistSlotB();855 } else {856 const freshSlotA = await persistSlotA();857 858 859 const activeCount = entries.filter((e) => !e.deletedAt).length;860 void takeSnapshot(freshSlotA, activeCount);861 }862 buildDomainIndexSync(entries);863}864865function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {866 const title = entry.title.trim();867 const type = entry.type ?? "login";868869 if (!title) throw new Error("title is required");870 if (type === "login" && !entry.password?.trim()) {871 throw new Error("password is required");872 }873 if (type === "passkey") {874 if (875 !entry.rpId?.trim() ||876 !entry.rpName?.trim() ||877 !entry.credentialId?.trim() ||878 !entry.publicKeyCose?.trim() ||879 !entry.privateKeyJwk ||880 !entry.userHandle?.trim() ||881 !entry.userName?.trim() ||882 !entry.userDisplayName?.trim() ||883 (entry.algorithm !== -7 && entry.algorithm !== -8)884 ) {885 throw new Error("invalid passkey entry");886 }887 }888 if (type === "card") {889 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {890 throw new Error("cardholder name and card number are required");891 }892 }893894 return {895 type,896 title,897 username: type === "login" ? entry.username?.trim() ?? "" : undefined,898 password: type === "login" ? entry.password : undefined,899 url: type === "login" ? entry.url?.trim() ?? "" : undefined,900 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,901 notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,902 breach: type === "login" ? entry.breach : undefined,903 canary: type === "login" ? entry.canary : undefined,904 tags: entry.tags,905 rpId: type === "passkey" ? entry.rpId?.trim() : undefined,906 rpName: type === "passkey" ? entry.rpName?.trim() : undefined,907 credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,908 publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,909 privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,910 userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,911 userName: type === "passkey" ? entry.userName?.trim() : undefined,912 userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,913 algorithm: type === "passkey" ? entry.algorithm : undefined,914 signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,915 aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,916 backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,917 backupState: type === "passkey" ? entry.backupState === true : undefined,918 lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,919 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,920 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,921 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,922 expYear: type === "card" ? entry.expYear?.trim() : undefined,923 cvc: type === "card" ? entry.cvc?.trim() : undefined,924 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,925 network: type === "card" ? entry.network : undefined,926 last4: type === "card" ? entry.last4?.trim() : undefined,927 };928}929930function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {931 const nextPatch: Partial<VaultEntry> = {};932933 if ("type" in patch) {934 nextPatch.type = patch.type;935 }936937 if ("title" in patch) {938 const title = patch.title?.trim() ?? "";939 if (!title) throw new Error("title is required");940 nextPatch.title = title;941 }942943 if ("url" in patch) {944 nextPatch.url = patch.url?.trim() ?? "";945 }946947 if ("username" in patch) {948 nextPatch.username = patch.username?.trim() ?? "";949 }950951 if ("password" in patch) {952 const password = patch.password ?? "";953 954 nextPatch.password = password || undefined;955 }956957 if ("totp" in patch) {958 nextPatch.totp = patch.totp?.trim() || undefined;959 }960961 if ("notes" in patch) {962 nextPatch.notes = patch.notes?.trim() || undefined;963 }964965 if ("breach" in patch) {966 nextPatch.breach = patch.breach;967 }968969 if ("canary" in patch) {970 nextPatch.canary = patch.canary;971 }972973 if ("rpId" in patch) {974 nextPatch.rpId = patch.rpId?.trim();975 }976977 if ("rpName" in patch) {978 nextPatch.rpName = patch.rpName?.trim();979 }980981 if ("credentialId" in patch) {982 nextPatch.credentialId = patch.credentialId?.trim();983 }984985 if ("publicKeyCose" in patch) {986 nextPatch.publicKeyCose = patch.publicKeyCose?.trim();987 }988989 if ("privateKeyJwk" in patch) {990 nextPatch.privateKeyJwk = patch.privateKeyJwk;991 }992993 if ("userHandle" in patch) {994 nextPatch.userHandle = patch.userHandle?.trim();995 }996997 if ("userName" in patch) {998 nextPatch.userName = patch.userName?.trim();999 }10001001 if ("userDisplayName" in patch) {1002 nextPatch.userDisplayName = patch.userDisplayName?.trim();1003 }10041005 if ("algorithm" in patch) {1006 nextPatch.algorithm = patch.algorithm;1007 }10081009 if ("signCount" in patch) {1010 nextPatch.signCount = patch.signCount;1011 }10121013 if ("aaguid" in patch) {1014 nextPatch.aaguid = patch.aaguid?.trim();1015 }10161017 if ("backupEligible" in patch) {1018 nextPatch.backupEligible = patch.backupEligible;1019 }10201021 if ("backupState" in patch) {1022 nextPatch.backupState = patch.backupState;1023 }10241025 if ("lastUsedAt" in patch) {1026 nextPatch.lastUsedAt = patch.lastUsedAt;1027 }10281029 if ("tags" in patch) {1030 nextPatch.tags = patch.tags;1031 }10321033 if ("cardholderName" in patch) {1034 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1035 }1036 if ("cardNumber" in patch) {1037 const n = patch.cardNumber ?? "";1038 nextPatch.cardNumber = n || undefined;1039 }1040 if ("expMonth" in patch) {1041 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1042 }1043 if ("expYear" in patch) {1044 nextPatch.expYear = patch.expYear?.trim() || undefined;1045 }1046 if ("cvc" in patch) {1047 nextPatch.cvc = patch.cvc?.trim() || undefined;1048 }1049 if ("cardNickname" in patch) {1050 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1051 }1052 if ("network" in patch) {1053 nextPatch.network = patch.network;1054 }1055 if ("last4" in patch) {1056 nextPatch.last4 = patch.last4?.trim() || undefined;1057 }10581059 return nextPatch;1060}10611062function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1063 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1064 const modifiedAt =1065 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;10661067 return {1068 id: entry.id,1069 type: "type" in entry ? entry.type : "login",1070 title: entry.title,1071 username: entry.username,1072 password: entry.password,1073 url: entry.url,1074 totp: entry.totp,1075 notes: entry.notes,1076 breach: entry.breach,1077 canary: "canary" in entry ? entry.canary : undefined,1078 rpId: "rpId" in entry ? entry.rpId : undefined,1079 rpName: "rpName" in entry ? entry.rpName : undefined,1080 credentialId: "credentialId" in entry ? entry.credentialId : undefined,1081 publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1082 privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1083 userHandle: "userHandle" in entry ? entry.userHandle : undefined,1084 userName: "userName" in entry ? entry.userName : undefined,1085 userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1086 algorithm: "algorithm" in entry ? entry.algorithm : undefined,1087 signCount: "signCount" in entry ? entry.signCount : undefined,1088 aaguid: "aaguid" in entry ? entry.aaguid : undefined,1089 backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1090 backupState: "backupState" in entry ? entry.backupState : undefined,1091 lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1092 createdAt,1093 modifiedAt,1094 deletedAt: entry.deletedAt,1095 tags: "tags" in entry ? entry.tags : undefined,1096 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1097 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1098 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1099 expYear: "expYear" in entry ? entry.expYear : undefined,1100 cvc: "cvc" in entry ? entry.cvc : undefined,1101 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1102 network: "network" in entry ? entry.network : undefined,1103 last4: "last4" in entry ? entry.last4 : undefined,1104 };1105}11061107function hasLegacyShape(parsed: VaultEntry[]): boolean {1108 return parsed.some(1109 (entry) => "created" in entry || "updated" in entry1110 );1111}111211131114111511161117function entryDupeKey(e: VaultEntry): string {1118 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1119 if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1120 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1121}112211231124export function countDuplicates(): number {1125 const best = new Map<string, VaultEntry>();1126 let dupeCount = 0;1127 for (const e of entries.filter((e) => !e.deletedAt)) {1128 const key = entryDupeKey(e);1129 const existing = best.get(key);1130 if (!existing) {1131 best.set(key, e);1132 } else {1133 dupeCount++;1134 1135 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1136 best.set(key, e);1137 }1138 }1139 }1140 return dupeCount;1141}114211431144114511461147export async function deduplicateEntries(): Promise<number> {1148 if (!derivedKey) throw new Error("vault locked");1149 1150 1151 if (vaultMode === "decoy") {1152 throw new Error("deduplicate disabled in decoy mode");1153 }11541155 const best = new Map<string, VaultEntry>();1156 for (const e of entries.filter((en) => !en.deletedAt)) {1157 const key = entryDupeKey(e);1158 const existing = best.get(key);1159 if (!existing) {1160 best.set(key, e);1161 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1162 best.set(key, e);1163 }1164 }11651166 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1167 const now = Date.now();1168 let removed = 0;1169 entries = entries.map((e) => {1170 if (!e.deletedAt && !keepIds.has(e.id)) {1171 removed++;1172 return { ...e, deletedAt: now };1173 }1174 return e;1175 });11761177 if (removed > 0) await persistEntries();1178 return removed;1179}118011811182118311841185const SNAPSHOT_KEY = "peach_vault_snapshots";1186const MAX_SNAPSHOTS = 6;11871188interface VaultSnapshot {1189 timestamp: number;1190 entryCount: number;1191 slot: StoredVault;1192}119311941195export interface SnapshotSummary {1196 index: number;1197 timestamp: number;1198 entryCount: number;1199}12001201async function takeSnapshot(1202 slot: StoredVault,1203 entryCount: number1204): Promise<void> {1205 try {1206 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1207 const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1208 const next: VaultSnapshot[] = [1209 { timestamp: Date.now(), entryCount, slot },1210 ...existing,1211 ].slice(0, MAX_SNAPSHOTS);1212 await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1213 } catch {1214 1215 }1216}12171218export async function listSnapshots(): Promise<SnapshotSummary[]> {1219 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1220 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1221 return snapshots.map((s, i) => ({1222 index: i,1223 timestamp: s.timestamp,1224 entryCount: s.entryCount,1225 }));1226}12271228122912301231123212331234export async function restoreFromSnapshot(1235 index: number,1236 password: string1237): Promise<boolean> {1238 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1239 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1240 const snapshot = snapshots[index];1241 if (!snapshot) return false;12421243 1244 const key = await deriveKey(password, unhex(snapshot.slot.salt));1245 const payload = await tryDecryptSlot(snapshot.slot, key);1246 if (!payload) {1247 zeroKey(key);1248 return false;1249 }12501251 1252 await writeSlot(SLOT_A, snapshot.slot);1253 await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));12541255 1256 if (derivedKey) zeroKey(derivedKey);1257 derivedKey = key;1258 entries = payload.entries;1259 duressConfigured = payload.duressConfigured;1260 vaultMode = "real";1261 buildDomainIndexSync(entries);1262 await loadSettings();1263 touchAutoLock();12641265 return true;1266}12671268126912701271127212731274export async function changePassword(1275 currentPassword: string,1276 newPassword: string1277): Promise<boolean> {1278 if (!derivedKey || vaultMode !== "real") return false;12791280 1281 const valid = await verifyMainPassword(currentPassword);1282 if (!valid) return false;12831284 const slotA = await loadSlot(SLOT_A);1285 if (!slotA) return false;12861287 1288 const newSalt = generateSalt();1289 const newKey = await deriveKey(newPassword, newSalt);12901291 1292 const plaintext = encodePayload(entries, duressConfigured);1293 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);12941295 const freshSlotA: StoredVault = {1296 ...slotA,1297 salt: hex(newSalt),1298 nonce,1299 ciphertext,1300 tag,1301 };1302 await writeSlot(SLOT_A, freshSlotA);13031304 1305 1306 1307 zeroKey(derivedKey);1308 derivedKey = newKey;13091310 1311 1312 1313 const activeCount = entries.filter((e) => !e.deletedAt).length;1314 void takeSnapshot(freshSlotA, activeCount);13151316 1317 1318 1319 if (!duressConfigured) {1320 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1321 }13221323 return true;1324}