123456789101112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import {15 rootKeyToWords,16 wordsToRootKeyMaterial,17 deriveRecoveryKey,18} from "../shared/recovery-words";19import type {20 AccentColorName,21 LegacyVaultEntry,22 ManualSummonRecord,23 SavedIdentity,24 StoredVault,25 VaultEntry,26 VaultHistory,27 VaultSnapshot,28} from "../shared/types";29import { decrypt, encrypt, zeroKey } from "./aes";30import { clearAutoLock, touchAutoLock } from "./alarms";3132type EntryDraft = Omit<33 VaultEntry,34 "id" | "createdAt" | "modifiedAt" | "deletedAt"35>;363738394041const SLOT_A = "peach_vault_a";42const SLOT_B = "peach_vault_b";434445const LEGACY_MAIN = "peach_vault";46const LEGACY_DECOY = "peach_decoy_vault";47const LEGACY_DURESS_FLAG = "peach_duress";48495051const PAD_BLOCK = 1024;5253let derivedKey: Uint8Array | null = null;5455565758let rootKey: Uint8Array | null = null;59let entries: VaultEntry[] = [];60let domainIndex: Map<string, VaultEntry[]> = new Map();61let vaultMode: "real" | "decoy" = "real";62636465let duressConfigured = false;6667let migrationRan = false;686970717273let lastUnlockFailureReason: string | null = null;7475export function consumeLastUnlockFailureReason(): string | null {76 const r = lastUnlockFailureReason;77 lastUnlockFailureReason = null;78 return r;79}808182838485export type DisplayFont = "cormorant-italic" | "cormorant" | "inter" | "system";86export type TextSize = "small" | "medium" | "large";8788export interface VaultSettings {89 accentColor?: AccentColorName;90 theme?: "dark" | "light";91 autoLockMinutes?: number;92 clipboardClearSeconds?: number;93 lockOnClose?: boolean;94 breachMonitoring?: boolean;95 passkeyProviderEnabled?: boolean;96 trashRetentionDays?: number;97 manualSummonDomains?: Record<string, ManualSummonRecord>;98 declinedCardFingerprints?: string[];99 100101102103104 displayFont?: DisplayFont;105 106107108109110 textSize?: TextSize;111 112113114115116 reduceMotion?: boolean;117 118119120121122 biometricsEnabled?: boolean;123 124125126127128129130 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;131}132133let settings: VaultSettings = {};134135export function getSettings(): VaultSettings {136 return settings;137}138139export async function ensureSettingsLoaded(): Promise<VaultSettings> {140 await loadSettings();141 return settings;142}143144export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {145 await loadSettings();146 settings = { ...settings, ...patch };147 await persistSettings();148}149150async function persistSettings(): Promise<void> {151 try {152 await chrome.storage.local.set({ peach_settings: settings });153 } catch (err) {154 throw mapStorageError(err);155 }156}157158function mapStorageError(err: unknown): Error {159 const message = err instanceof Error ? err.message : String(err);160 if (/QUOTA_BYTES|quota/i.test(message)) {161 return new Error("STORAGE_QUOTA_EXCEEDED");162 }163 return err instanceof Error ? err : new Error(message);164}165166async function loadSettings(): Promise<void> {167 const result = await chrome.storage.local.get("peach_settings");168 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};169}170171172173174175export async function checkBreach(password: string): Promise<number> {176 const encoder = new TextEncoder();177 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));178 const hashHex = Array.from(new Uint8Array(hashBuffer))179 .map((b) => b.toString(16).padStart(2, "0"))180 .join("")181 .toUpperCase();182183 const prefix = hashHex.slice(0, 5);184 const suffix = hashHex.slice(5);185186 try {187 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);188 if (!res.ok) return 0;189 const text = await res.text();190 const match = text.split("\r\n").find((l) => l.startsWith(suffix));191 return match ? parseInt(match.split(":")[1], 10) : 0;192 } catch {193 return 0; 194 }195}196197export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {198 const fingerprint = await cardFingerprint(last4, expMonth, expYear);199 await loadSettings();200 settings = {201 ...settings,202 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],203 };204 await persistSettings();205}206207export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {208 await loadSettings();209 const fingerprint = await cardFingerprint(last4, expMonth, expYear);210 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);211}212213async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {214 const encoder = new TextEncoder();215 const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));216 return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");217}218219function hex(buf: Uint8Array): string {220 return Array.from(buf)221 .map((b) => b.toString(16).padStart(2, "0"))222 .join("");223}224225function unhex(s: string): Uint8Array {226 const out = new Uint8Array(s.length / 2);227 for (let i = 0; i < s.length; i += 2) {228 out[i / 2] = parseInt(s.slice(i, i + 2), 16);229 }230 return out;231}232233234235236237238239240241242243244async function generateAndWrapRootKey(): Promise<{245 rawRootKey: string; 246 recoverySalt: string;247 wrappedRootKey: string;248 wrappedNonce: string;249 wrappedTag: string;250}> {251 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));252253 254 const words = await rootKeyToWords(rootKeyBytes);255 const wordMaterial = await wordsToRootKeyMaterial(words);256 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));257 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);258259 260 261 const nonce = crypto.getRandomValues(new Uint8Array(12));262 const encoded = new TextEncoder().encode(hex(rootKeyBytes));263 const raw = await crypto.subtle.encrypt(264 {265 name: "AES-GCM",266 iv: nonce,267 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,268 tagLength: 128,269 },270 recoveryKey,271 encoded272 );273 274 const rawU8 = new Uint8Array(raw);275 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));276 const tag = hex(rawU8.slice(rawU8.length - 16));277278 return {279 rawRootKey: hex(rootKeyBytes),280 recoverySalt: hex(recoverySalt),281 wrappedRootKey: ciphertext,282 wrappedNonce: hex(nonce),283 wrappedTag: tag,284 };285}286287288289290291292export async function unwrapRootKeyForRecovery(293 wrappedRootKey: string,294 wrappedNonce: string,295 wrappedTag: string,296 recoverySaltHex: string,297 words: string[]298): Promise<void> {299 const wordMaterial = await wordsToRootKeyMaterial(words);300 const recoverySalt = unhex(recoverySaltHex);301 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);302303 304 305 306 307 308 const nonceRaw = unhex(wrappedNonce);309 const ciphertextRaw = unhex(wrappedRootKey);310 const tagRaw = unhex(wrappedTag);311 312 313 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;314 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);315 combined.set(ciphertextRaw, 0);316 combined.set(tagRaw, ciphertextRaw.length);317 const raw = await crypto.subtle.decrypt(318 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },319 recoveryKey,320 combined321 );322 const decryptedHex = new TextDecoder().decode(raw);323 rootKey = unhex(decryptedHex);324}325326327328329330331332333334335export async function rewrapRootKeyForPassword(336 newDerivedKey: Uint8Array,337 _newSalt: Uint8Array338): Promise<void> {339 if (!rootKey) throw new Error("root key not set");340 const slotA = await loadSlot(SLOT_A);341 if (!slotA) throw new Error("missing slot A");342 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);343 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });344}345346347348349350351async function reencryptVaultForNewKey(352 newKey: Uint8Array,353 slotA: StoredVault354): Promise<void> {355 const plaintext = encodePayload(entries, duressConfigured);356 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);357 await writeSlot(SLOT_A, {358 ...slotA,359 nonce,360 ciphertext,361 tag,362 });363}364365366367368369370export async function changePasswordWithNewKey(371 newSalt: Uint8Array,372 newKey: Uint8Array373): Promise<void> {374 const slotA = await loadSlot(SLOT_A);375 if (!slotA) throw new Error("missing slot A");376 377 378 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });379 if (derivedKey) zeroKey(derivedKey);380 derivedKey = newKey;381 382 383 384 385 if (rootKey) {386 const slotAfter = await loadSlot(SLOT_A);387 if (slotAfter) {388 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);389 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });390 }391 }392 if (!duressConfigured) {393 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));394 }395 await stampLocalVaultMutation();396}397398399400401402403export { deriveKey, generateSalt };404405export async function loadSlotA(): Promise<StoredVault | null> {406 return loadSlot(SLOT_A);407}408409410411412413414415416417418419420421async function deriveRootKeyFromDerivedKey(422 derivedKeyBytes: Uint8Array,423 slotA: StoredVault,424): Promise<Uint8Array | null> {425 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {426 return null;427 }428 try {429 const rootHex = await decrypt(430 derivedKeyBytes,431 slotA.pw_root_nonce,432 slotA.pw_root_ciphertext,433 slotA.pw_root_tag,434 );435 return unhex(rootHex);436 } catch {437 return null;438 }439}440441442443async function wrapRootKeyForDerivedKey(444 derivedKeyBytes: Uint8Array,445 rootKeyBytes: Uint8Array,446): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {447 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes));448 return {449 pw_root_ciphertext: ciphertext,450 pw_root_nonce: nonce,451 pw_root_tag: tag,452 };453}454455456457458459460461462export async function migrateToRecovery(): Promise<void> {463 if (!derivedKey || vaultMode !== "real") {464 throw new Error("vault must be unlocked in real mode");465 }466 const slotA = await loadSlot(SLOT_A);467 if (!slotA) throw new Error("missing slot A");468 const hasRecoveryEscrow =469 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;470 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;471 if (hasRecoveryEscrow && hasPwRoot) return; 472473 474 475 476 477 478 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =479 await generateAndWrapRootKey();480 rootKey = unhex(rawRootKey);481482 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);483 const nextSlotA: StoredVault = {484 ...slotA,485 recovery_salt: recoverySalt,486 recovery_wrapped_root_key: wrappedRootKey,487 recovery_nonce: wrappedNonce,488 recovery_tag: wrappedTag,489 ...pwRoot,490 };491 await writeSlot(SLOT_A, nextSlotA);492 await stampLocalVaultMutation();493}494495496497498499500export async function getRecoveryWords(): Promise<string[]> {501 if (!rootKey) {502 throw new Error("recovery not set up — vault may need migration");503 }504 return rootKeyToWords(rootKey);505}506507async function loadSlot(key: string): Promise<StoredVault | null> {508 const result = await chrome.storage.local.get(key);509 return (result as Record<string, StoredVault | undefined>)[key] ?? null;510}511512async function writeSlot(key: string, vault: StoredVault): Promise<void> {513 try {514 await chrome.storage.local.set({ [key]: vault });515 } catch (err) {516 throw mapStorageError(err);517 }518}519520async function stampLocalVaultMutation(): Promise<void> {521 await chrome.storage.local.set({522 peach_last_modified_at: String(Date.now()),523 });524}525526527528529530531async function ensureMigrated(): Promise<void> {532 if (migrationRan) return;533 migrationRan = true;534535 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];536 const raw = await chrome.storage.local.get(keys);537 const slotAExists = raw[SLOT_A] !== undefined;538 const slotBExists = raw[SLOT_B] !== undefined;539 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;540 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;541542 543 if (!slotAExists && !legacyMain) return;544545 546 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;547548 let didMigrate = false;549 if (legacyMain && !slotAExists) {550 await writeSlot(SLOT_A, legacyMain);551 didMigrate = true;552 }553554 555 if (!slotBExists) {556 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;557 if (legacyDecoy) {558 await writeSlot(SLOT_B, legacyDecoy);559 didMigrate = true;560 } else if (target) {561 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));562 didMigrate = true;563 }564 }565566 const toRemove: string[] = [];567 if (legacyMain) toRemove.push(LEGACY_MAIN);568 if (legacyDecoy) toRemove.push(LEGACY_DECOY);569 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);570 if (toRemove.length > 0) {571 await chrome.storage.local.remove(toRemove);572 didMigrate = true;573 }574 if (didMigrate) {575 await stampLocalVaultMutation();576 }577}578579580581582583584585586587588589function padPlaintext(json: string): string {590 const encoded = new TextEncoder().encode(json);591 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);592 const padLen = target - encoded.length;593 return json + " ".repeat(padLen);594}595596597598599600601602603604605606const CSPRNG_CHUNK = 65_536;607608function fillRandom(buf: Uint8Array): Uint8Array {609 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {610 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);611 const chunk = new Uint8Array(end - offset);612 crypto.getRandomValues(chunk);613 buf.set(chunk, offset);614 }615 return buf;616}617618function generateNoiseBlob(ciphertextBytes: number): StoredVault {619 const salt = crypto.getRandomValues(new Uint8Array(16));620 const nonce = crypto.getRandomValues(new Uint8Array(12));621 const cipher = fillRandom(new Uint8Array(ciphertextBytes));622 const tag = crypto.getRandomValues(new Uint8Array(16));623 return {624 version: 1,625 salt: hex(salt),626 nonce: hex(nonce),627 ciphertext: hex(cipher),628 tag: hex(tag),629 };630}631632633634635636type VaultPayload = {637 entries: VaultEntry[];638 duressConfigured?: boolean;639};640641type DecryptedPayload = {642 entries: VaultEntry[];643 duressConfigured: boolean;644 645646647648649650651 migratedFromLegacy: boolean;652};653654function encodePayload(entries: VaultEntry[], duress: boolean): string {655 const envelope: VaultPayload = { entries, duressConfigured: duress };656 return padPlaintext(JSON.stringify(envelope));657}658659function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {660 if ("created" in raw || "updated" in raw) return true;661 const flat = raw as Partial<VaultEntry>;662 if (flat.type === "passkey") return true;663 664 if (flat.rpId !== undefined && flat.passkey === undefined) return true;665 return false;666}667668function decodePayload(plain: string): DecryptedPayload {669 const parsed = JSON.parse(plain) as670 | Array<VaultEntry | LegacyVaultEntry>671 | VaultPayload;672673 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];674 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);675676 const normalized = rawEntries.map(normalizeEntry);677 const { merged } = mergePasskeyOnlyIntoLogins(normalized);678679 if (Array.isArray(parsed)) {680 return {681 entries: normalized,682 duressConfigured: false,683 migratedFromLegacy: true, 684 };685 }686687 return {688 entries: normalized,689 duressConfigured: parsed.duressConfigured === true,690 migratedFromLegacy: flatMigrationNeeded || merged,691 };692}693694695696697698699700701702703704705706707708709710711712713714715716717function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {718 let merged = false;719 const now = Date.now();720721 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);722 const byRpId = new Map<string, VaultEntry[]>();723 for (const e of candidates) {724 const rpId = e.passkey!.rpId;725 const list = byRpId.get(rpId);726 if (list) list.push(e);727 else byRpId.set(rpId, [e]);728 }729730 for (const [rpId, group] of byRpId) {731 if (group.length === 0) continue;732 group.sort((a, b) => {733 const aT =734 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;735 const bT =736 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;737 return bT - aT;738 });739 const winner = group[0]!;740741 742 743 744 const loginTarget = entries.find((e) => {745 if (e.deletedAt || e.passkey || e.type !== "login") return false;746 if (e.url && extractDomain(e.url) === rpId) return true;747 for (const extra of e.additionalUrls ?? []) {748 if (extractDomain(extra) === rpId) return true;749 }750 return false;751 });752753 if (loginTarget) {754 loginTarget.passkey = winner.passkey;755 loginTarget.modifiedAt = now;756 for (const dup of group) {757 if (!dup.deletedAt) {758 dup.deletedAt = now;759 dup.modifiedAt = now;760 }761 }762 merged = true;763 } else if (group.length > 1) {764 for (let i = 1; i < group.length; i += 1) {765 const dup = group[i]!;766 if (!dup.deletedAt) {767 dup.deletedAt = now;768 dup.modifiedAt = now;769 }770 }771 merged = true;772 }773 }774775 return { merged };776}777778779780781782783784function hasValidVaultSchema(v: unknown): v is StoredVault {785 if (typeof v !== "object" || v === null) return false;786 const r = v as Record<string, unknown>;787 return (788 r.version === 1 &&789 typeof r.salt === "string" &&790 typeof r.nonce === "string" &&791 typeof r.ciphertext === "string" &&792 typeof r.tag === "string"793 );794}795796export type TryDecryptResult =797 | { status: "ok"; payload: DecryptedPayload }798 | { status: "wrong_key" }799 | { status: "corrupted"; reason: string };800801802803804805806export async function tryDecryptSlot(807 vault: StoredVault,808 key: Uint8Array809): Promise<DecryptedPayload | null> {810 const res = await tryDecryptSlotResult(vault, key);811 return res.status === "ok" ? res.payload : null;812}813814export async function tryDecryptSlotResult(815 vault: StoredVault,816 key: Uint8Array817): Promise<TryDecryptResult> {818 if (!hasValidVaultSchema(vault)) {819 return { status: "corrupted", reason: "invalid_slot_schema" };820 }821 let plain: string;822 try {823 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);824 } catch {825 return { status: "wrong_key" };826 }827 try {828 const parsed = JSON.parse(plain) as unknown;829 if (typeof parsed !== "object" || parsed === null) {830 return { status: "corrupted", reason: "invalid_payload_shape" };831 }832 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {833 return { status: "corrupted", reason: "invalid_payload_shape" };834 }835 return { status: "ok", payload: decodePayload(plain) };836 } catch {837 return { status: "corrupted", reason: "invalid_payload_json" };838 }839}840841842843844845function buildDomainIndexSync(all: VaultEntry[]): void {846 domainIndex = new Map();847848 for (const entry of all) {849 if (entry.deletedAt) continue;850 if (entry.type === "note" || entry.type === "card") continue;851852 853 854 855 856 const domains = new Set<string>();857 const primary = extractDomain(entry.url ?? "");858 if (primary) domains.add(primary);859 for (const extra of entry.additionalUrls ?? []) {860 const d = extractDomain(extra);861 if (d) domains.add(d);862 }863 if (domains.size === 0) continue;864865 for (const domain of domains) {866 const bucket = domainIndex.get(domain) ?? [];867 bucket.push(entry);868 domainIndex.set(domain, bucket);869 }870 }871}872873export async function createVault(password: string): Promise<void> {874 await ensureMigrated();875876 const salt = generateSalt();877 derivedKey = await deriveKey(password, salt);878 entries = [];879 vaultMode = "real";880 duressConfigured = false;881 buildDomainIndexSync(entries);882883 884 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =885 await generateAndWrapRootKey();886 rootKey = unhex(rawRootKey);887888 const plaintext = encodePayload(entries, duressConfigured);889 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);890891 892 893 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);894895 const slotA: StoredVault = {896 version: 1,897 salt: hex(salt),898 nonce,899 ciphertext,900 tag,901 recovery_salt: recoverySalt,902 recovery_wrapped_root_key: wrappedRootKey,903 recovery_nonce: wrappedNonce,904 recovery_tag: wrappedTag,905 ...pwRoot,906 };907 await writeSlot(SLOT_A, slotA);908909 910 911 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));912 await stampLocalVaultMutation();913914 touchAutoLock();915}916917export async function hasVault(): Promise<boolean> {918 await ensureMigrated();919 return (await loadSlot(SLOT_A)) !== null;920}921922923924925926927export async function isDuressEnabled(): Promise<boolean> {928 return vaultMode === "real" && duressConfigured;929}930931export async function setupDuress(duressPassword: string): Promise<void> {932 if (!derivedKey || vaultMode !== "real") {933 throw new Error("vault must be unlocked in real mode");934 }935 await ensureMigrated();936937 938 939 940 const slotA = await loadSlot(SLOT_A);941 if (!slotA) throw new Error("missing slot A");942943 const decoySalt = generateSalt();944 const decoyKey = await deriveKey(duressPassword, decoySalt);945 try {946 947 948 const targetBytes = slotA.ciphertext.length / 2;949 let plaintext = encodePayload([], false);950 const encoded = new TextEncoder().encode(plaintext);951 if (encoded.length < targetBytes) {952 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);953 }954 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);955 await writeSlot(SLOT_B, {956 version: 1,957 salt: hex(decoySalt),958 nonce,959 ciphertext,960 tag,961 });962 } finally {963 zeroKey(decoyKey);964 }965966 967 968 duressConfigured = true;969 await persistSlotA();970}971972export async function disableDuress(): Promise<void> {973 if (!derivedKey || vaultMode !== "real") {974 throw new Error("vault must be unlocked in real mode");975 }976 await ensureMigrated();977978 979 980 981 const slotA = await loadSlot(SLOT_A);982 if (slotA) {983 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));984 }985986 duressConfigured = false;987 await persistSlotA();988}989990export async function verifyMainPassword(password: string): Promise<boolean> {991 await ensureMigrated();992 const slotA = await loadSlot(SLOT_A);993 if (!slotA) return false;994 const key = await deriveKey(password, unhex(slotA.salt));995 try {996 return (await tryDecryptSlot(slotA, key)) !== null;997 } finally {998 zeroKey(key);999 }1000}10011002export async function unlock(password: string): Promise<boolean> {1003 await ensureMigrated();10041005 1006 1007 1008 1009 1010 const [slotA, slotB] = await Promise.all([1011 loadSlot(SLOT_A),1012 loadSlot(SLOT_B),1013 ]);10141015 1016 const [keyA, keyB] = await Promise.all([1017 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),1018 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),1019 ]);10201021 1022 1023 1024 const [resultA, resultB] = await Promise.all([1025 keyA && slotA1026 ? tryDecryptSlotResult(slotA, keyA)1027 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1028 keyB && slotB1029 ? tryDecryptSlotResult(slotB, keyB)1030 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1031 ]);1032 const payloadA = resultA.status === "ok" ? resultA.payload : null;1033 const payloadB = resultB.status === "ok" ? resultB.payload : null;10341035 if (payloadA && keyA) {1036 derivedKey = keyA;1037 vaultMode = "real";1038 entries = payloadA.entries;1039 duressConfigured = payloadA.duressConfigured;1040 buildDomainIndexSync(entries);1041 await loadSettings();1042 touchAutoLock();1043 if (keyB) zeroKey(keyB);10441045 1046 if (slotA) {1047 const needsRecoverySetup =1048 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1049 if (!needsRecoverySetup) {1050 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1051 }1052 1053 1054 1055 1056 if (needsRecoverySetup || !rootKey) {1057 try {1058 await migrateToRecovery();1059 if (needsRecoverySetup) {1060 1061 1062 1063 await chrome.storage.local.set({ peach_recovery_pending: '1' });1064 }1065 } catch (migrationErr) {1066 console.warn('[Peach] recovery migration failed:', migrationErr);1067 }1068 }1069 }10701071 1072 1073 1074 if (payloadA.migratedFromLegacy) {1075 await persistSlotA();1076 }1077 return true;1078 }10791080 if (payloadB && keyB) {1081 derivedKey = keyB;1082 vaultMode = "decoy";1083 entries = payloadB.entries;1084 duressConfigured = false;1085 buildDomainIndexSync(entries);1086 await loadSettings();1087 touchAutoLock();1088 if (keyA) zeroKey(keyA);1089 return true;1090 }10911092 if (keyA) zeroKey(keyA);1093 if (keyB) zeroKey(keyB);1094 1095 1096 if (resultA.status === "corrupted") {1097 lastUnlockFailureReason = resultA.reason;1098 } else if (resultB.status === "corrupted") {1099 lastUnlockFailureReason = resultB.reason;1100 }1101 return false;1102}11031104export function lock(): void {1105 if (derivedKey) {1106 zeroKey(derivedKey);1107 derivedKey = null;1108 }1109 if (rootKey) {1110 rootKey.fill(0);1111 rootKey = null;1112 }11131114 entries = [];1115 domainIndex = new Map();1116 vaultMode = "real";1117 duressConfigured = false;1118 clearAutoLock();1119}11201121export function isUnlocked(): boolean {1122 return derivedKey !== null;1123}1124112511261127export async function unlockWithKey(1128 masterKeyHex: string,1129): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1130 try {1131 const masterKeyBytes = unhex(masterKeyHex);1132 const stored = await loadSlot(SLOT_A);1133 if (!stored) return { ok: false, error: "invalid_key" };1134 const payload = await tryDecryptSlot(stored, masterKeyBytes);1135 if (!payload) return { ok: false, error: "invalid_key" };11361137 derivedKey = masterKeyBytes;1138 vaultMode = "real";1139 entries = payload.entries;1140 duressConfigured = payload.duressConfigured;1141 buildDomainIndexSync(entries);1142 await loadSettings();1143 touchAutoLock();1144 return { ok: true, entries };1145 } catch {1146 return { ok: false, error: "invalid_key" };1147 }1148}11491150115111521153115411551156115711581159116011611162116311641165116611671168export async function unlockFromCodex(params: {1169 slotA: string; 1170 slotB: string | null; 1171 timestamp: number;1172 masterPassphrase: string;1173}): Promise<1174 | { ok: true; entryCount: number }1175 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1176> {1177 let parsedSlotA: StoredVault;1178 let parsedSlotB: StoredVault | null = null;1179 try {1180 const rawA = JSON.parse(params.slotA) as unknown;1181 if (!hasValidVaultSchema(rawA)) {1182 return { ok: false, reason: "invalid_slot_format" };1183 }1184 parsedSlotA = rawA;1185 if (params.slotB !== null) {1186 const rawB = JSON.parse(params.slotB) as unknown;1187 if (!hasValidVaultSchema(rawB)) {1188 return { ok: false, reason: "invalid_slot_format" };1189 }1190 parsedSlotB = rawB;1191 }1192 } catch {1193 return { ok: false, reason: "invalid_slot_format" };1194 }11951196 1197 1198 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1199 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey);1200 if (!trialPayload) {1201 zeroKey(trialKey);1202 return { ok: false, reason: "wrong_passphrase" };1203 }1204 1205 1206 zeroKey(trialKey);12071208 1209 1210 await takeVaultSnapshot("restore");12111212 await writeSlot(SLOT_A, parsedSlotA);1213 if (parsedSlotB) {1214 await writeSlot(SLOT_B, parsedSlotB);1215 } else {1216 1217 1218 1219 await writeSlot(1220 SLOT_B,1221 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1222 );1223 }1224 await chrome.storage.local.set({1225 peach_last_modified_at: String(params.timestamp),1226 });12271228 1229 1230 const ok = await unlock(params.masterPassphrase);1231 if (!ok) {1232 1233 1234 1235 return { ok: false, reason: "wrong_passphrase" };1236 }1237 return { ok: true, entryCount: getEntries().length };1238}1239124012411242export function getDerivedKeyHex(): string {1243 if (!derivedKey) throw new Error("vault_not_unlocked");1244 return hex(derivedKey);1245}12461247export function getEntries(): VaultEntry[] {1248 if (!derivedKey) throw new Error("vault locked");1249 return entries.filter((entry) => !entry.deletedAt);1250}12511252const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;12531254export function getIdentities(): SavedIdentity[] {1255 if (!derivedKey) throw new Error("vault locked");1256 const active = entries.filter((e) => !e.deletedAt && e.type === "login");12571258 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1259 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();12601261 for (const entry of active) {1262 const value = entry.username?.trim();1263 if (!value) continue;1264 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;1265 const existing = map.get(value);1266 const domain = extractDomain(entry.url ?? "") ?? "";1267 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;1268 if (!existing || modifiedAt > existing.lastUsed) {1269 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1270 } else {1271 map.set(value, { ...existing, count: existing.count + 1 });1272 }1273 }12741275 const toIdentity = (type: "email" | "username") =>1276 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1277 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1278 });12791280 return [1281 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1282 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1283 ];1284}12851286export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1287 if (!derivedKey) throw new Error("vault locked");12881289 const now = Date.now();1290 const added: VaultEntry[] = [];1291 for (const draft of drafts) {1292 try {1293 const sanitized = sanitizeEntryDraft(draft);1294 const nextEntry: VaultEntry = {1295 ...sanitized,1296 id: crypto.randomUUID(),1297 createdAt: now,1298 modifiedAt: now,1299 };1300 entries.push(nextEntry);1301 added.push(nextEntry);1302 } catch {1303 1304 }1305 }13061307 if (added.length > 0) {1308 await persistEntries();1309 }13101311 return added;1312}131313141315131613171318131913201321export async function restoreEntries(1322 incoming: VaultEntry[],1323 mode: "replace" | "merge"1324): Promise<{ added: number; updated: number; skipped: number }> {1325 if (!derivedKey) throw new Error("vault locked");13261327 1328 1329 1330 if (mode === "replace" && incoming.length === 0) {1331 throw new Error("refuse to replace with empty entry set");1332 }13331334 let added = 0;1335 let updated = 0;1336 let skipped = 0;13371338 if (mode === "replace") {1339 entries = [];1340 for (const entry of incoming) {1341 const normalized = sanitizeRestoredEntry(entry);1342 if (!normalized) {1343 skipped += 1;1344 continue;1345 }1346 entries.push(normalized);1347 added += 1;1348 }1349 } else {1350 const byId = new Map<string, VaultEntry>();1351 for (const e of entries) byId.set(e.id, e);13521353 for (const entry of incoming) {1354 const normalized = sanitizeRestoredEntry(entry);1355 if (!normalized) {1356 skipped += 1;1357 continue;1358 }1359 const existing = byId.get(normalized.id);1360 if (!existing) {1361 entries.push(normalized);1362 added += 1;1363 } else if (normalized.modifiedAt > existing.modifiedAt) {1364 const idx = entries.findIndex((e) => e.id === existing.id);1365 if (idx !== -1) {1366 entries[idx] = normalized;1367 updated += 1;1368 }1369 } else {1370 skipped += 1;1371 }1372 }1373 }13741375 await persistEntries("import");1376 return { added, updated, skipped };1377}1378137913801381138213831384function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1385 if (!entry || typeof entry !== "object") return null;1386 const title = typeof entry.title === "string" ? entry.title.trim() : "";1387 if (!title) return null;1388 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1389 const now = Date.now();1390 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1391 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;13921393 1394 1395 1396 const migrated: VaultEntry = migrateLegacyPasskey(1397 {1398 ...entry,1399 id,1400 title,1401 type: entry.type ?? "login",1402 createdAt,1403 modifiedAt,1404 },1405 entry1406 );14071408 1409 1410 if (1411 migrated.type === "login" &&1412 !migrated.passkey &&1413 typeof migrated.password !== "string"1414 ) {1415 return null;1416 }1417 if (migrated.passkey && !migrated.passkey.credentialId) {1418 return null;1419 }14201421 return migrated;1422}142314241425142614271428export async function clearVaultEntries(1429 confirmation: "REPLACE_ALL"1430): Promise<void> {1431 if (confirmation !== "REPLACE_ALL") {1432 throw new Error("clearVaultEntries requires explicit confirmation");1433 }1434 if (!derivedKey) throw new Error("vault locked");1435 for (const entry of entries) {1436 if (!entry.deletedAt) {1437 entry.deletedAt = Date.now();1438 entry.modifiedAt = Date.now();1439 }1440 }1441 await persistEntries();1442}14431444export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1445 if (!derivedKey) throw new Error("vault locked");14461447 const sanitized = sanitizeEntryDraft(entry);1448 const now = Date.now();1449 const nextEntry: VaultEntry = {1450 ...sanitized,1451 id: crypto.randomUUID(),1452 createdAt: now,1453 modifiedAt: now,1454 1455 1456 passwordChangedAt: sanitized.password ? now : undefined,1457 };14581459 entries.push(nextEntry);1460 await persistEntries();14611462 1463 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1464 checkBreach(nextEntry.password).then(async (count) => {1465 if (count > 0) {1466 const idx = entries.findIndex((e) => e.id === nextEntry.id);1467 if (idx !== -1) {1468 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1469 await persistEntries();1470 }1471 }1472 });1473 }14741475 return nextEntry;1476}14771478export async function updateEntry(1479 id: string,1480 patch: Partial<VaultEntry>1481): Promise<void> {1482 if (!derivedKey) throw new Error("vault locked");14831484 const idx = entries.findIndex((entry) => entry.id === id);1485 if (idx === -1 || entries[idx].deletedAt) {1486 throw new Error("entry not found");1487 }14881489 const current = entries[idx];1490 const sanitizedPatch = sanitizeEntryPatch(patch);1491 1492 1493 1494 const passwordChanged =1495 "password" in patch &&1496 typeof sanitizedPatch.password === "string" &&1497 sanitizedPatch.password !== current.password;1498 entries[idx] = {1499 ...current,1500 ...sanitizedPatch,1501 modifiedAt: Date.now(),1502 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1503 };15041505 1506 1507 1508 1509 if (1510 entries[idx].type === "login" &&1511 !entries[idx].password &&1512 !entries[idx].passkey1513 ) {1514 entries[idx] = current;1515 throw new Error("password or passkey required");1516 }15171518 1519 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1520 entries[idx] = current;1521 throw new Error("card number is required for card entries");1522 }15231524 await persistEntries();15251526 1527 1528 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1529 entries[idx] = { ...entries[idx], breach: undefined };1530 await persistEntries();1531 const password = entries[idx].password;1532 if (password) {1533 checkBreach(password).then(async (count) => {1534 const i = entries.findIndex((e) => e.id === id);1535 if (i !== -1 && count > 0) {1536 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1537 await persistEntries();1538 }1539 });1540 }1541 }1542}15431544export async function deleteEntry(id: string): Promise<void> {1545 if (!derivedKey) throw new Error("vault locked");15461547 const entry = entries.find((item) => item.id === id);1548 if (!entry || entry.deletedAt) {1549 throw new Error("entry not found");1550 }15511552 entry.deletedAt = Date.now();1553 entry.modifiedAt = Date.now();1554 await persistEntries();1555}15561557export function getDeletedEntries(): VaultEntry[] {1558 if (!derivedKey) return [];1559 return entries.filter((e) => !!e.deletedAt);1560}15611562export async function restoreEntry(id: string): Promise<void> {1563 if (!derivedKey) throw new Error("vault locked");1564 const entry = entries.find((e) => e.id === id && e.deletedAt);1565 if (!entry) throw new Error("deleted entry not found");1566 delete entry.deletedAt;1567 entry.modifiedAt = Date.now();1568 await persistEntries();1569 buildDomainIndexSync(entries);1570}15711572export async function purgeEntry(id: string): Promise<void> {1573 if (!derivedKey) throw new Error("vault locked");1574 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1575 if (idx === -1) throw new Error("deleted entry not found");1576 entries.splice(idx, 1);1577 await persistEntries();1578}157915801581export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1582 if (!derivedKey || retentionMs === 0) return 0;1583 const cutoff = Date.now() - retentionMs;1584 const before = entries.length;1585 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1586 const purged = before - entries.length;1587 if (purged > 0) await persistEntries();1588 return purged;1589}15901591export function queryDomain(domain: string): VaultEntry[] {1592 return domainIndex.get(domain) ?? [];1593}15941595export function getEntryById(id: string): VaultEntry | undefined {1596 return entries.find((entry) => entry.id === id && !entry.deletedAt);1597}15981599async function persistSlotA(1600 reason: VaultSnapshot["reason"] = "local_save",1601): Promise<StoredVault> {1602 if (!derivedKey) throw new Error("vault locked");16031604 const slotA = await loadSlot(SLOT_A);1605 if (!slotA) throw new Error("missing slot A");16061607 1608 1609 await takeVaultSnapshot(reason);16101611 const plaintext = encodePayload(entries, duressConfigured);1612 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);16131614 1615 const nextSlotA: StoredVault = {1616 ...slotA,1617 nonce,1618 ciphertext,1619 tag,1620 1621 };1622 await writeSlot(SLOT_A, nextSlotA);16231624 1625 1626 1627 if (!duressConfigured) {1628 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1629 }1630 await stampLocalVaultMutation();16311632 return nextSlotA;1633}16341635async function persistSlotB(): Promise<void> {1636 if (!derivedKey) throw new Error("vault locked");16371638 const slotB = await loadSlot(SLOT_B);1639 if (!slotB) throw new Error("missing slot B");16401641 1642 1643 const plaintext = encodePayload(entries, false);1644 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);16451646 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1647 await stampLocalVaultMutation();1648}16491650async function persistEntries(1651 reason: VaultSnapshot["reason"] = "local_save",1652): Promise<void> {1653 if (!derivedKey) throw new Error("vault locked");16541655 if (vaultMode === "decoy") {1656 await persistSlotB();1657 } else {1658 await persistSlotA(reason);1659 }1660 buildDomainIndexSync(entries);1661}16621663function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1664 if (!Array.isArray(values)) return undefined;1665 const seen = new Set<string>();1666 const cleaned: string[] = [];1667 for (const value of values) {1668 const trimmed = typeof value === "string" ? value.trim() : "";1669 if (!trimmed) continue;1670 const key = trimmed.toLowerCase();1671 if (seen.has(key)) continue;1672 seen.add(key);1673 cleaned.push(trimmed);1674 }1675 return cleaned.length > 0 ? cleaned : undefined;1676}16771678function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1679 const title = entry.title.trim();1680 1681 1682 1683 let type: VaultEntry["type"] = entry.type ?? "login";1684 if (type === "passkey") type = "login";16851686 if (!title) throw new Error("title is required");1687 1688 1689 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1690 throw new Error("password or passkey required");1691 }1692 if (entry.passkey) {1693 const p = entry.passkey;1694 if (1695 !p.rpId ||1696 !p.credentialId ||1697 !p.publicKeyCose ||1698 !p.userHandle ||1699 !p.userName ||1700 (p.algorithm !== -7 && p.algorithm !== -8)1701 ) {1702 throw new Error("invalid passkey credential");1703 }1704 }1705 if (type === "card") {1706 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1707 throw new Error("cardholder name and card number are required");1708 }1709 }17101711 return {1712 type,1713 title,1714 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1715 password: type === "login" ? entry.password : undefined,1716 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1717 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1718 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1719 notes: entry.notes?.trim() || undefined,1720 breach: type === "login" ? entry.breach : undefined,1721 canary: type === "login" ? entry.canary : undefined,1722 tags: entry.tags,1723 passkey: entry.passkey,1724 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1725 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1726 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1727 expYear: type === "card" ? entry.expYear?.trim() : undefined,1728 cvc: type === "card" ? entry.cvc?.trim() : undefined,1729 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1730 network: type === "card" ? entry.network : undefined,1731 last4: type === "card" ? entry.last4?.trim() : undefined,1732 };1733}17341735function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1736 const nextPatch: Partial<VaultEntry> = {};17371738 if ("type" in patch) {1739 nextPatch.type = patch.type;1740 }17411742 if ("title" in patch) {1743 const title = patch.title?.trim() ?? "";1744 if (!title) throw new Error("title is required");1745 nextPatch.title = title;1746 }17471748 if ("url" in patch) {1749 nextPatch.url = patch.url?.trim() ?? "";1750 }17511752 if ("additionalUrls" in patch) {1753 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1754 const seen = new Set<string>();1755 const cleaned: string[] = [];1756 for (const value of incoming) {1757 const trimmed = typeof value === "string" ? value.trim() : "";1758 if (!trimmed) continue;1759 const key = trimmed.toLowerCase();1760 if (seen.has(key)) continue;1761 seen.add(key);1762 cleaned.push(trimmed);1763 }1764 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1765 }17661767 if ("username" in patch) {1768 nextPatch.username = patch.username?.trim() ?? "";1769 }17701771 if ("email" in patch) {1772 nextPatch.email = patch.email?.trim() || undefined;1773 }17741775 if ("password" in patch) {1776 const password = patch.password ?? "";1777 1778 nextPatch.password = password || undefined;1779 }17801781 if ("totp" in patch) {1782 nextPatch.totp = patch.totp?.trim() || undefined;1783 }17841785 if ("notes" in patch) {1786 nextPatch.notes = patch.notes?.trim() || undefined;1787 }17881789 if ("breach" in patch) {1790 nextPatch.breach = patch.breach;1791 }17921793 if ("canary" in patch) {1794 nextPatch.canary = patch.canary;1795 }17961797 if ("passkey" in patch) {1798 1799 1800 1801 nextPatch.passkey = patch.passkey;1802 }18031804 if ("tags" in patch) {1805 nextPatch.tags = patch.tags;1806 }18071808 if ("cardholderName" in patch) {1809 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1810 }1811 if ("cardNumber" in patch) {1812 const n = patch.cardNumber ?? "";1813 nextPatch.cardNumber = n || undefined;1814 }1815 if ("expMonth" in patch) {1816 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1817 }1818 if ("expYear" in patch) {1819 nextPatch.expYear = patch.expYear?.trim() || undefined;1820 }1821 if ("cvc" in patch) {1822 nextPatch.cvc = patch.cvc?.trim() || undefined;1823 }1824 if ("cardNickname" in patch) {1825 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1826 }1827 if ("network" in patch) {1828 nextPatch.network = patch.network;1829 }1830 if ("last4" in patch) {1831 nextPatch.last4 = patch.last4?.trim() || undefined;1832 }18331834 return nextPatch;1835}18361837function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1838 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1839 const modifiedAt =1840 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;18411842 const base: VaultEntry = {1843 id: entry.id,1844 type: "type" in entry ? entry.type : "login",1845 title: entry.title,1846 username: entry.username,1847 password: entry.password,1848 url: entry.url,1849 totp: entry.totp,1850 notes: entry.notes,1851 breach: entry.breach,1852 canary: "canary" in entry ? entry.canary : undefined,1853 passkey: "passkey" in entry ? entry.passkey : undefined,1854 createdAt,1855 modifiedAt,1856 deletedAt: entry.deletedAt,1857 tags: "tags" in entry ? entry.tags : undefined,1858 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1859 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1860 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1861 expYear: "expYear" in entry ? entry.expYear : undefined,1862 cvc: "cvc" in entry ? entry.cvc : undefined,1863 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1864 network: "network" in entry ? entry.network : undefined,1865 last4: "last4" in entry ? entry.last4 : undefined,1866 };18671868 return migrateLegacyPasskey(base, entry);1869}187018711872187318741875187618771878function migrateLegacyPasskey(1879 base: VaultEntry,1880 raw: VaultEntry | LegacyVaultEntry1881): VaultEntry {1882 if (base.passkey) return base;1883 const flat = raw as Partial<VaultEntry>;1884 const isPasskeyType = base.type === "passkey";1885 const hasFlatPasskeyFields =1886 typeof flat.rpId === "string" &&1887 typeof flat.credentialId === "string" &&1888 typeof flat.publicKeyCose === "string" &&1889 typeof flat.userHandle === "string" &&1890 typeof flat.userName === "string";1891 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1892 if (!hasFlatPasskeyFields) return base;18931894 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1895 base.passkey = {1896 rpId: flat.rpId!,1897 rpName: flat.rpName ?? flat.rpId!,1898 credentialId: flat.credentialId!,1899 publicKeyCose: flat.publicKeyCose!,1900 privateKeyJwk: flat.privateKeyJwk,1901 userHandle: flat.userHandle!,1902 userName: flat.userName!,1903 userDisplayName: flat.userDisplayName ?? flat.userName!,1904 algorithm,1905 signCount: flat.signCount ?? 0,1906 aaguid: flat.aaguid ?? "",1907 backupEligible: flat.backupEligible === true,1908 backupState: flat.backupState === true,1909 lastUsedAt: flat.lastUsedAt,1910 browser: flat.browser,1911 transports: flat.transports,1912 createdAt: base.createdAt,1913 };1914 1915 1916 1917 if (isPasskeyType) base.type = "login";1918 if (!base.username) base.username = flat.userName;1919 if (!base.url) base.url = `https://${flat.rpId}`;1920 return base;1921}1922192319241925192619271928function entryDupeKey(e: VaultEntry): string {1929 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1930 1931 1932 1933 1934 if (e.passkey) {1935 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1936 }1937 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1938}193919401941export function countDuplicates(): number {1942 const best = new Map<string, VaultEntry>();1943 let dupeCount = 0;1944 for (const e of entries.filter((e) => !e.deletedAt)) {1945 const key = entryDupeKey(e);1946 const existing = best.get(key);1947 if (!existing) {1948 best.set(key, e);1949 } else {1950 dupeCount++;1951 1952 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1953 best.set(key, e);1954 }1955 }1956 }1957 return dupeCount;1958}195919601961196219631964export async function deduplicateEntries(): Promise<number> {1965 if (!derivedKey) throw new Error("vault locked");1966 1967 1968 if (vaultMode === "decoy") {1969 throw new Error("deduplicate disabled in decoy mode");1970 }19711972 const best = new Map<string, VaultEntry>();1973 for (const e of entries.filter((en) => !en.deletedAt)) {1974 const key = entryDupeKey(e);1975 const existing = best.get(key);1976 if (!existing) {1977 best.set(key, e);1978 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1979 best.set(key, e);1980 }1981 }19821983 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1984 const now = Date.now();1985 let removed = 0;1986 entries = entries.map((e) => {1987 if (!e.deletedAt && !keepIds.has(e.id)) {1988 removed++;1989 return { ...e, deletedAt: now };1990 }1991 return e;1992 });19931994 if (removed > 0) await persistEntries();1995 return removed;1996}199719981999200020012002200320042005const HISTORY_KEY = "peach_vault_history";2006const HISTORY_MAX = 10;20072008200920102011201220132014export async function takeVaultSnapshot(2015 reason: VaultSnapshot["reason"],2016): Promise<void> {2017 try {2018 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2019 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2020 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];20212022 2023 2024 2025 if (!slotA) return;20262027 const vault_a = JSON.stringify(slotA);2028 const vault_b = JSON.stringify(slotB ?? slotA);20292030 2031 2032 2033 let entryCount: number | null = null;2034 if (derivedKey && vaultMode === "real") {2035 try {2036 entryCount = entries.filter((e) => !e.deletedAt).length;2037 } catch {2038 entryCount = null;2039 }2040 }20412042 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2043 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2044 ? existingRaw2045 : { snapshots: [] };20462047 2048 2049 2050 const latest = history.snapshots[0];2051 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2052 return;2053 }20542055 const snapshot: VaultSnapshot = {2056 id: crypto.randomUUID(),2057 savedAt: Date.now(),2058 reason,2059 vault_a,2060 vault_b,2061 entryCount,2062 };20632064 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2065 await chrome.storage.local.set({ [HISTORY_KEY]: history });2066 } catch {2067 2068 2069 }2070}207120722073export async function clearVaultHistory(): Promise<void> {2074 try {2075 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2076 } catch {2077 2078 }2079}208020812082export async function getVaultHistory(): Promise<VaultSnapshot[]> {2083 const raw = await chrome.storage.local.get(HISTORY_KEY);2084 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2085 if (!history || !Array.isArray(history.snapshots)) return [];2086 return history.snapshots;2087}20882089209020912092209320942095export async function restoreVaultSnapshot(2096 snapshotId: string,2097): Promise<{ ok: true } | { ok: false; error: string }> {2098 const history = await getVaultHistory();2099 const snapshot = history.find((s) => s.id === snapshotId);2100 if (!snapshot) return { ok: false, error: "snapshot_not_found" };21012102 2103 2104 2105 await takeVaultSnapshot("restore");21062107 let restoredA: StoredVault;2108 let restoredB: StoredVault;2109 try {2110 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2111 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2112 } catch {2113 return { ok: false, error: "snapshot_corrupt" };2114 }21152116 await writeSlot(SLOT_A, restoredA);2117 await writeSlot(SLOT_B, restoredB);2118 await stampLocalVaultMutation();21192120 2121 2122 2123 lock();21242125 return { ok: true };2126}21272128212921302131213221332134export async function changePassword(2135 currentPassword: string,2136 newPassword: string2137): Promise<boolean> {2138 if (!derivedKey || vaultMode !== "real") return false;21392140 2141 const valid = await verifyMainPassword(currentPassword);2142 if (!valid) return false;21432144 const slotA = await loadSlot(SLOT_A);2145 if (!slotA) return false;2146 if (!rootKey) return false;21472148 2149 const newSalt = generateSalt();2150 const newKey = await deriveKey(newPassword, newSalt);21512152 2153 const plaintext = encodePayload(entries, duressConfigured);2154 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);21552156 2157 2158 2159 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);21602161 const finalSlotA: StoredVault = {2162 ...slotA,2163 salt: hex(newSalt),2164 nonce,2165 ciphertext,2166 tag,2167 ...pwRoot,2168 };21692170 2171 2172 2173 await takeVaultSnapshot("local_save");21742175 2176 2177 2178 2179 await writeSlot(SLOT_A, finalSlotA);21802181 2182 2183 2184 zeroKey(derivedKey);2185 derivedKey = newKey;21862187 2188 2189 2190 if (!duressConfigured) {2191 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2192 }2193 await stampLocalVaultMutation();21942195 return true;2196}219721982199220022012202export async function getVaultBlobForSync(): Promise<{2203 blob: string;2204 timestamp: number;2205 license?: string;2206} | null> {2207 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, 'peach_last_modified_at', 'peach_license']);2208 const slotA = (raw as Record<string, unknown>)[SLOT_A];2209 if (!slotA) return null;2210 const slotB = (raw as Record<string, unknown>)[SLOT_B];2211 const blob = JSON.stringify({ a: slotA, b: slotB });2212 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2213 const timestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;2214 const license = (raw as Record<string, unknown>)['peach_license'];2215 return {2216 blob,2217 timestamp,2218 license: typeof license === 'string' ? license : undefined,2219 };2220}22212222export async function applyIncomingVault(2223 blobJson: string,2224 remoteTimestamp: number,2225 remoteLicense?: string,2226): Promise<boolean> {2227 const raw = await chrome.storage.local.get('peach_last_modified_at');2228 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2229 const localTimestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;22302231 if (remoteLicense) {2232 const licenseRaw = await chrome.storage.local.get('peach_license');2233 const existing = (licenseRaw as Record<string, unknown>)['peach_license'];2234 if (!existing) {2235 await chrome.storage.local.set({ peach_license: remoteLicense });2236 }2237 }22382239 if (remoteTimestamp <= localTimestamp) return false;22402241 2242 2243 2244 2245 2246 let parsed: unknown;2247 try {2248 parsed = JSON.parse(blobJson);2249 } catch {2250 throw new Error('invalid_incoming_vault_blob');2251 }2252 if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {2253 throw new Error('invalid_incoming_vault_blob');2254 }2255 const { a, b } = parsed as { a?: unknown; b?: unknown };2256 if (!hasValidVaultSchema(a)) {2257 throw new Error('invalid_incoming_vault_blob');2258 }22592260 2261 await takeVaultSnapshot('sync_received').catch(() => {});22622263 await writeSlot(SLOT_A, a);2264 if (hasValidVaultSchema(b)) await writeSlot(SLOT_B, b);2265 await chrome.storage.local.set({2266 peach_last_sync_at: String(Date.now()),2267 peach_last_modified_at: String(remoteTimestamp),2268 });22692270 return true;2271}22722273export async function getSyncIdentity(): Promise<{2274 privateKeyBytes: Uint8Array;2275 publicKeyBytes: Uint8Array;2276 publicKeyHex: string;2277}> {2278 const raw = await chrome.storage.local.get('peach_sync_identity');2279 const stored = (raw as Record<string, unknown>)['peach_sync_identity'];2280 if (typeof stored === 'string') {2281 const { priv, pub } = JSON.parse(stored) as { priv: string; pub: string };2282 const privateKeyBytes = base64DecodeBytes(priv);2283 const publicKeyBytes = base64DecodeBytes(pub);2284 return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2285 }22862287 2288 const { generateKeyPair } = await import('@stablelib/x25519');2289 const keypair = generateKeyPair();2290 await chrome.storage.local.set({2291 peach_sync_identity: JSON.stringify({2292 priv: base64EncodeBytes(keypair.secretKey),2293 pub: base64EncodeBytes(keypair.publicKey),2294 }),2295 });2296 return {2297 privateKeyBytes: keypair.secretKey,2298 publicKeyBytes: keypair.publicKey,2299 publicKeyHex: bytesToHex(keypair.publicKey),2300 };2301}23022303230423052306export async function reloadEntriesAfterSync(): Promise<boolean> {2307 if (!derivedKey) return false;2308 const slotA = await loadSlot(SLOT_A);2309 if (!slotA) return false;2310 try {2311 const payload = await tryDecryptSlot(slotA, derivedKey);2312 if (!payload) return false;2313 entries = payload.entries;2314 buildDomainIndexSync(entries);2315 return true;2316 } catch {2317 return false;2318 }2319}23202321function bytesToHex(bytes: Uint8Array): string {2322 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');2323}23242325function base64EncodeBytes(bytes: Uint8Array): string {2326 let binary = '';2327 for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);2328 return btoa(binary);2329}23302331function base64DecodeBytes(b64: string): Uint8Array {2332 const binary = atob(b64);2333 const bytes = new Uint8Array(binary.length);2334 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);2335 return bytes;2336}