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 parsedSlotA = JSON.parse(params.slotA) as StoredVault;1181 if (params.slotB !== null) {1182 parsedSlotB = JSON.parse(params.slotB) as StoredVault;1183 }1184 1185 1186 1187 if (1188 typeof parsedSlotA?.salt !== "string" ||1189 typeof parsedSlotA?.nonce !== "string" ||1190 typeof parsedSlotA?.ciphertext !== "string" ||1191 typeof parsedSlotA?.tag !== "string"1192 ) {1193 return { ok: false, reason: "invalid_slot_format" };1194 }1195 } catch {1196 return { ok: false, reason: "invalid_slot_format" };1197 }11981199 1200 1201 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1202 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey);1203 if (!trialPayload) {1204 zeroKey(trialKey);1205 return { ok: false, reason: "wrong_passphrase" };1206 }1207 1208 1209 zeroKey(trialKey);12101211 1212 1213 await takeVaultSnapshot("restore");12141215 await writeSlot(SLOT_A, parsedSlotA);1216 if (parsedSlotB) {1217 await writeSlot(SLOT_B, parsedSlotB);1218 } else {1219 1220 1221 1222 await writeSlot(1223 SLOT_B,1224 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1225 );1226 }1227 await chrome.storage.local.set({1228 peach_last_modified_at: String(params.timestamp),1229 });12301231 1232 1233 const ok = await unlock(params.masterPassphrase);1234 if (!ok) {1235 1236 1237 1238 return { ok: false, reason: "wrong_passphrase" };1239 }1240 return { ok: true, entryCount: getEntries().length };1241}1242124312441245export function getDerivedKeyHex(): string {1246 if (!derivedKey) throw new Error("vault_not_unlocked");1247 return hex(derivedKey);1248}12491250export function getEntries(): VaultEntry[] {1251 if (!derivedKey) throw new Error("vault locked");1252 return entries.filter((entry) => !entry.deletedAt);1253}12541255const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;12561257export function getIdentities(): SavedIdentity[] {1258 if (!derivedKey) throw new Error("vault locked");1259 const active = entries.filter((e) => !e.deletedAt && e.type === "login");12601261 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1262 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();12631264 for (const entry of active) {1265 const value = entry.username?.trim();1266 if (!value) continue;1267 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;1268 const existing = map.get(value);1269 const domain = extractDomain(entry.url ?? "") ?? "";1270 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;1271 if (!existing || modifiedAt > existing.lastUsed) {1272 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1273 } else {1274 map.set(value, { ...existing, count: existing.count + 1 });1275 }1276 }12771278 const toIdentity = (type: "email" | "username") =>1279 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1280 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1281 });12821283 return [1284 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1285 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1286 ];1287}12881289export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1290 if (!derivedKey) throw new Error("vault locked");12911292 const now = Date.now();1293 const added: VaultEntry[] = [];1294 for (const draft of drafts) {1295 try {1296 const sanitized = sanitizeEntryDraft(draft);1297 const nextEntry: VaultEntry = {1298 ...sanitized,1299 id: crypto.randomUUID(),1300 createdAt: now,1301 modifiedAt: now,1302 };1303 entries.push(nextEntry);1304 added.push(nextEntry);1305 } catch {1306 1307 }1308 }13091310 if (added.length > 0) {1311 await persistEntries();1312 }13131314 return added;1315}131613171318131913201321132213231324export async function restoreEntries(1325 incoming: VaultEntry[],1326 mode: "replace" | "merge"1327): Promise<{ added: number; updated: number; skipped: number }> {1328 if (!derivedKey) throw new Error("vault locked");13291330 1331 1332 1333 if (mode === "replace" && incoming.length === 0) {1334 throw new Error("refuse to replace with empty entry set");1335 }13361337 let added = 0;1338 let updated = 0;1339 let skipped = 0;13401341 if (mode === "replace") {1342 entries = [];1343 for (const entry of incoming) {1344 const normalized = sanitizeRestoredEntry(entry);1345 if (!normalized) {1346 skipped += 1;1347 continue;1348 }1349 entries.push(normalized);1350 added += 1;1351 }1352 } else {1353 const byId = new Map<string, VaultEntry>();1354 for (const e of entries) byId.set(e.id, e);13551356 for (const entry of incoming) {1357 const normalized = sanitizeRestoredEntry(entry);1358 if (!normalized) {1359 skipped += 1;1360 continue;1361 }1362 const existing = byId.get(normalized.id);1363 if (!existing) {1364 entries.push(normalized);1365 added += 1;1366 } else if (normalized.modifiedAt > existing.modifiedAt) {1367 const idx = entries.findIndex((e) => e.id === existing.id);1368 if (idx !== -1) {1369 entries[idx] = normalized;1370 updated += 1;1371 }1372 } else {1373 skipped += 1;1374 }1375 }1376 }13771378 await persistEntries("import");1379 return { added, updated, skipped };1380}1381138213831384138513861387function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1388 if (!entry || typeof entry !== "object") return null;1389 const title = typeof entry.title === "string" ? entry.title.trim() : "";1390 if (!title) return null;1391 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1392 const now = Date.now();1393 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1394 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;13951396 1397 1398 1399 const migrated: VaultEntry = migrateLegacyPasskey(1400 {1401 ...entry,1402 id,1403 title,1404 type: entry.type ?? "login",1405 createdAt,1406 modifiedAt,1407 },1408 entry1409 );14101411 1412 1413 if (1414 migrated.type === "login" &&1415 !migrated.passkey &&1416 typeof migrated.password !== "string"1417 ) {1418 return null;1419 }1420 if (migrated.passkey && !migrated.passkey.credentialId) {1421 return null;1422 }14231424 return migrated;1425}142614271428142914301431export async function clearVaultEntries(1432 confirmation: "REPLACE_ALL"1433): Promise<void> {1434 if (confirmation !== "REPLACE_ALL") {1435 throw new Error("clearVaultEntries requires explicit confirmation");1436 }1437 if (!derivedKey) throw new Error("vault locked");1438 for (const entry of entries) {1439 if (!entry.deletedAt) {1440 entry.deletedAt = Date.now();1441 entry.modifiedAt = Date.now();1442 }1443 }1444 await persistEntries();1445}14461447export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1448 if (!derivedKey) throw new Error("vault locked");14491450 const sanitized = sanitizeEntryDraft(entry);1451 const now = Date.now();1452 const nextEntry: VaultEntry = {1453 ...sanitized,1454 id: crypto.randomUUID(),1455 createdAt: now,1456 modifiedAt: now,1457 1458 1459 passwordChangedAt: sanitized.password ? now : undefined,1460 };14611462 entries.push(nextEntry);1463 await persistEntries();14641465 1466 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1467 checkBreach(nextEntry.password).then(async (count) => {1468 if (count > 0) {1469 const idx = entries.findIndex((e) => e.id === nextEntry.id);1470 if (idx !== -1) {1471 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1472 await persistEntries();1473 }1474 }1475 });1476 }14771478 return nextEntry;1479}14801481export async function updateEntry(1482 id: string,1483 patch: Partial<VaultEntry>1484): Promise<void> {1485 if (!derivedKey) throw new Error("vault locked");14861487 const idx = entries.findIndex((entry) => entry.id === id);1488 if (idx === -1 || entries[idx].deletedAt) {1489 throw new Error("entry not found");1490 }14911492 const current = entries[idx];1493 const sanitizedPatch = sanitizeEntryPatch(patch);1494 1495 1496 1497 const passwordChanged =1498 "password" in patch &&1499 typeof sanitizedPatch.password === "string" &&1500 sanitizedPatch.password !== current.password;1501 entries[idx] = {1502 ...current,1503 ...sanitizedPatch,1504 modifiedAt: Date.now(),1505 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1506 };15071508 1509 1510 1511 1512 if (1513 entries[idx].type === "login" &&1514 !entries[idx].password &&1515 !entries[idx].passkey1516 ) {1517 entries[idx] = current;1518 throw new Error("password or passkey required");1519 }15201521 1522 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1523 entries[idx] = current;1524 throw new Error("card number is required for card entries");1525 }15261527 await persistEntries();15281529 1530 1531 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1532 entries[idx] = { ...entries[idx], breach: undefined };1533 await persistEntries();1534 const password = entries[idx].password;1535 if (password) {1536 checkBreach(password).then(async (count) => {1537 const i = entries.findIndex((e) => e.id === id);1538 if (i !== -1 && count > 0) {1539 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1540 await persistEntries();1541 }1542 });1543 }1544 }1545}15461547export async function deleteEntry(id: string): Promise<void> {1548 if (!derivedKey) throw new Error("vault locked");15491550 const entry = entries.find((item) => item.id === id);1551 if (!entry || entry.deletedAt) {1552 throw new Error("entry not found");1553 }15541555 entry.deletedAt = Date.now();1556 entry.modifiedAt = Date.now();1557 await persistEntries();1558}15591560export function getDeletedEntries(): VaultEntry[] {1561 if (!derivedKey) return [];1562 return entries.filter((e) => !!e.deletedAt);1563}15641565export async function restoreEntry(id: string): Promise<void> {1566 if (!derivedKey) throw new Error("vault locked");1567 const entry = entries.find((e) => e.id === id && e.deletedAt);1568 if (!entry) throw new Error("deleted entry not found");1569 delete entry.deletedAt;1570 entry.modifiedAt = Date.now();1571 await persistEntries();1572 buildDomainIndexSync(entries);1573}15741575export async function purgeEntry(id: string): Promise<void> {1576 if (!derivedKey) throw new Error("vault locked");1577 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1578 if (idx === -1) throw new Error("deleted entry not found");1579 entries.splice(idx, 1);1580 await persistEntries();1581}158215831584export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1585 if (!derivedKey || retentionMs === 0) return 0;1586 const cutoff = Date.now() - retentionMs;1587 const before = entries.length;1588 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1589 const purged = before - entries.length;1590 if (purged > 0) await persistEntries();1591 return purged;1592}15931594export function queryDomain(domain: string): VaultEntry[] {1595 return domainIndex.get(domain) ?? [];1596}15971598export function getEntryById(id: string): VaultEntry | undefined {1599 return entries.find((entry) => entry.id === id && !entry.deletedAt);1600}16011602async function persistSlotA(1603 reason: VaultSnapshot["reason"] = "local_save",1604): Promise<StoredVault> {1605 if (!derivedKey) throw new Error("vault locked");16061607 const slotA = await loadSlot(SLOT_A);1608 if (!slotA) throw new Error("missing slot A");16091610 1611 1612 await takeVaultSnapshot(reason);16131614 const plaintext = encodePayload(entries, duressConfigured);1615 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);16161617 1618 const nextSlotA: StoredVault = {1619 ...slotA,1620 nonce,1621 ciphertext,1622 tag,1623 1624 };1625 await writeSlot(SLOT_A, nextSlotA);16261627 1628 1629 1630 if (!duressConfigured) {1631 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1632 }1633 await stampLocalVaultMutation();16341635 return nextSlotA;1636}16371638async function persistSlotB(): Promise<void> {1639 if (!derivedKey) throw new Error("vault locked");16401641 const slotB = await loadSlot(SLOT_B);1642 if (!slotB) throw new Error("missing slot B");16431644 1645 1646 const plaintext = encodePayload(entries, false);1647 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);16481649 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1650 await stampLocalVaultMutation();1651}16521653async function persistEntries(1654 reason: VaultSnapshot["reason"] = "local_save",1655): Promise<void> {1656 if (!derivedKey) throw new Error("vault locked");16571658 if (vaultMode === "decoy") {1659 await persistSlotB();1660 } else {1661 await persistSlotA(reason);1662 }1663 buildDomainIndexSync(entries);1664}16651666function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1667 if (!Array.isArray(values)) return undefined;1668 const seen = new Set<string>();1669 const cleaned: string[] = [];1670 for (const value of values) {1671 const trimmed = typeof value === "string" ? value.trim() : "";1672 if (!trimmed) continue;1673 const key = trimmed.toLowerCase();1674 if (seen.has(key)) continue;1675 seen.add(key);1676 cleaned.push(trimmed);1677 }1678 return cleaned.length > 0 ? cleaned : undefined;1679}16801681function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1682 const title = entry.title.trim();1683 1684 1685 1686 let type: VaultEntry["type"] = entry.type ?? "login";1687 if (type === "passkey") type = "login";16881689 if (!title) throw new Error("title is required");1690 1691 1692 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1693 throw new Error("password or passkey required");1694 }1695 if (entry.passkey) {1696 const p = entry.passkey;1697 if (1698 !p.rpId ||1699 !p.credentialId ||1700 !p.publicKeyCose ||1701 !p.userHandle ||1702 !p.userName ||1703 (p.algorithm !== -7 && p.algorithm !== -8)1704 ) {1705 throw new Error("invalid passkey credential");1706 }1707 }1708 if (type === "card") {1709 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1710 throw new Error("cardholder name and card number are required");1711 }1712 }17131714 return {1715 type,1716 title,1717 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1718 password: type === "login" ? entry.password : undefined,1719 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1720 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1721 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1722 notes: entry.notes?.trim() || undefined,1723 breach: type === "login" ? entry.breach : undefined,1724 canary: type === "login" ? entry.canary : undefined,1725 tags: entry.tags,1726 passkey: entry.passkey,1727 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1728 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1729 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1730 expYear: type === "card" ? entry.expYear?.trim() : undefined,1731 cvc: type === "card" ? entry.cvc?.trim() : undefined,1732 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1733 network: type === "card" ? entry.network : undefined,1734 last4: type === "card" ? entry.last4?.trim() : undefined,1735 };1736}17371738function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1739 const nextPatch: Partial<VaultEntry> = {};17401741 if ("type" in patch) {1742 nextPatch.type = patch.type;1743 }17441745 if ("title" in patch) {1746 const title = patch.title?.trim() ?? "";1747 if (!title) throw new Error("title is required");1748 nextPatch.title = title;1749 }17501751 if ("url" in patch) {1752 nextPatch.url = patch.url?.trim() ?? "";1753 }17541755 if ("additionalUrls" in patch) {1756 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1757 const seen = new Set<string>();1758 const cleaned: string[] = [];1759 for (const value of incoming) {1760 const trimmed = typeof value === "string" ? value.trim() : "";1761 if (!trimmed) continue;1762 const key = trimmed.toLowerCase();1763 if (seen.has(key)) continue;1764 seen.add(key);1765 cleaned.push(trimmed);1766 }1767 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1768 }17691770 if ("username" in patch) {1771 nextPatch.username = patch.username?.trim() ?? "";1772 }17731774 if ("email" in patch) {1775 nextPatch.email = patch.email?.trim() || undefined;1776 }17771778 if ("password" in patch) {1779 const password = patch.password ?? "";1780 1781 nextPatch.password = password || undefined;1782 }17831784 if ("totp" in patch) {1785 nextPatch.totp = patch.totp?.trim() || undefined;1786 }17871788 if ("notes" in patch) {1789 nextPatch.notes = patch.notes?.trim() || undefined;1790 }17911792 if ("breach" in patch) {1793 nextPatch.breach = patch.breach;1794 }17951796 if ("canary" in patch) {1797 nextPatch.canary = patch.canary;1798 }17991800 if ("passkey" in patch) {1801 1802 1803 1804 nextPatch.passkey = patch.passkey;1805 }18061807 if ("tags" in patch) {1808 nextPatch.tags = patch.tags;1809 }18101811 if ("cardholderName" in patch) {1812 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1813 }1814 if ("cardNumber" in patch) {1815 const n = patch.cardNumber ?? "";1816 nextPatch.cardNumber = n || undefined;1817 }1818 if ("expMonth" in patch) {1819 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1820 }1821 if ("expYear" in patch) {1822 nextPatch.expYear = patch.expYear?.trim() || undefined;1823 }1824 if ("cvc" in patch) {1825 nextPatch.cvc = patch.cvc?.trim() || undefined;1826 }1827 if ("cardNickname" in patch) {1828 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1829 }1830 if ("network" in patch) {1831 nextPatch.network = patch.network;1832 }1833 if ("last4" in patch) {1834 nextPatch.last4 = patch.last4?.trim() || undefined;1835 }18361837 return nextPatch;1838}18391840function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1841 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1842 const modifiedAt =1843 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;18441845 const base: VaultEntry = {1846 id: entry.id,1847 type: "type" in entry ? entry.type : "login",1848 title: entry.title,1849 username: entry.username,1850 password: entry.password,1851 url: entry.url,1852 totp: entry.totp,1853 notes: entry.notes,1854 breach: entry.breach,1855 canary: "canary" in entry ? entry.canary : undefined,1856 passkey: "passkey" in entry ? entry.passkey : undefined,1857 createdAt,1858 modifiedAt,1859 deletedAt: entry.deletedAt,1860 tags: "tags" in entry ? entry.tags : undefined,1861 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1862 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1863 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1864 expYear: "expYear" in entry ? entry.expYear : undefined,1865 cvc: "cvc" in entry ? entry.cvc : undefined,1866 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1867 network: "network" in entry ? entry.network : undefined,1868 last4: "last4" in entry ? entry.last4 : undefined,1869 };18701871 return migrateLegacyPasskey(base, entry);1872}187318741875187618771878187918801881function migrateLegacyPasskey(1882 base: VaultEntry,1883 raw: VaultEntry | LegacyVaultEntry1884): VaultEntry {1885 if (base.passkey) return base;1886 const flat = raw as Partial<VaultEntry>;1887 const isPasskeyType = base.type === "passkey";1888 const hasFlatPasskeyFields =1889 typeof flat.rpId === "string" &&1890 typeof flat.credentialId === "string" &&1891 typeof flat.publicKeyCose === "string" &&1892 typeof flat.userHandle === "string" &&1893 typeof flat.userName === "string";1894 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1895 if (!hasFlatPasskeyFields) return base;18961897 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1898 base.passkey = {1899 rpId: flat.rpId!,1900 rpName: flat.rpName ?? flat.rpId!,1901 credentialId: flat.credentialId!,1902 publicKeyCose: flat.publicKeyCose!,1903 privateKeyJwk: flat.privateKeyJwk,1904 userHandle: flat.userHandle!,1905 userName: flat.userName!,1906 userDisplayName: flat.userDisplayName ?? flat.userName!,1907 algorithm,1908 signCount: flat.signCount ?? 0,1909 aaguid: flat.aaguid ?? "",1910 backupEligible: flat.backupEligible === true,1911 backupState: flat.backupState === true,1912 lastUsedAt: flat.lastUsedAt,1913 browser: flat.browser,1914 transports: flat.transports,1915 createdAt: base.createdAt,1916 };1917 1918 1919 1920 if (isPasskeyType) base.type = "login";1921 if (!base.username) base.username = flat.userName;1922 if (!base.url) base.url = `https://${flat.rpId}`;1923 return base;1924}1925192619271928192919301931function entryDupeKey(e: VaultEntry): string {1932 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1933 1934 1935 1936 1937 if (e.passkey) {1938 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1939 }1940 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1941}194219431944export function countDuplicates(): number {1945 const best = new Map<string, VaultEntry>();1946 let dupeCount = 0;1947 for (const e of entries.filter((e) => !e.deletedAt)) {1948 const key = entryDupeKey(e);1949 const existing = best.get(key);1950 if (!existing) {1951 best.set(key, e);1952 } else {1953 dupeCount++;1954 1955 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1956 best.set(key, e);1957 }1958 }1959 }1960 return dupeCount;1961}196219631964196519661967export async function deduplicateEntries(): Promise<number> {1968 if (!derivedKey) throw new Error("vault locked");1969 1970 1971 if (vaultMode === "decoy") {1972 throw new Error("deduplicate disabled in decoy mode");1973 }19741975 const best = new Map<string, VaultEntry>();1976 for (const e of entries.filter((en) => !en.deletedAt)) {1977 const key = entryDupeKey(e);1978 const existing = best.get(key);1979 if (!existing) {1980 best.set(key, e);1981 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1982 best.set(key, e);1983 }1984 }19851986 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1987 const now = Date.now();1988 let removed = 0;1989 entries = entries.map((e) => {1990 if (!e.deletedAt && !keepIds.has(e.id)) {1991 removed++;1992 return { ...e, deletedAt: now };1993 }1994 return e;1995 });19961997 if (removed > 0) await persistEntries();1998 return removed;1999}200020012002200320042005200620072008const HISTORY_KEY = "peach_vault_history";2009const HISTORY_MAX = 10;20102011201220132014201520162017export async function takeVaultSnapshot(2018 reason: VaultSnapshot["reason"],2019): Promise<void> {2020 try {2021 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2022 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2023 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];20242025 2026 2027 2028 if (!slotA) return;20292030 const vault_a = JSON.stringify(slotA);2031 const vault_b = JSON.stringify(slotB ?? slotA);20322033 2034 2035 2036 let entryCount: number | null = null;2037 if (derivedKey && vaultMode === "real") {2038 try {2039 entryCount = entries.filter((e) => !e.deletedAt).length;2040 } catch {2041 entryCount = null;2042 }2043 }20442045 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2046 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2047 ? existingRaw2048 : { snapshots: [] };20492050 2051 2052 2053 const latest = history.snapshots[0];2054 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2055 return;2056 }20572058 const snapshot: VaultSnapshot = {2059 id: crypto.randomUUID(),2060 savedAt: Date.now(),2061 reason,2062 vault_a,2063 vault_b,2064 entryCount,2065 };20662067 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2068 await chrome.storage.local.set({ [HISTORY_KEY]: history });2069 } catch {2070 2071 2072 }2073}207420752076export async function clearVaultHistory(): Promise<void> {2077 try {2078 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2079 } catch {2080 2081 }2082}208320842085export async function getVaultHistory(): Promise<VaultSnapshot[]> {2086 const raw = await chrome.storage.local.get(HISTORY_KEY);2087 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2088 if (!history || !Array.isArray(history.snapshots)) return [];2089 return history.snapshots;2090}20912092209320942095209620972098export async function restoreVaultSnapshot(2099 snapshotId: string,2100): Promise<{ ok: true } | { ok: false; error: string }> {2101 const history = await getVaultHistory();2102 const snapshot = history.find((s) => s.id === snapshotId);2103 if (!snapshot) return { ok: false, error: "snapshot_not_found" };21042105 2106 2107 2108 await takeVaultSnapshot("restore");21092110 let restoredA: StoredVault;2111 let restoredB: StoredVault;2112 try {2113 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2114 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2115 } catch {2116 return { ok: false, error: "snapshot_corrupt" };2117 }21182119 await writeSlot(SLOT_A, restoredA);2120 await writeSlot(SLOT_B, restoredB);2121 await stampLocalVaultMutation();21222123 2124 2125 2126 lock();21272128 return { ok: true };2129}21302131213221332134213521362137export async function changePassword(2138 currentPassword: string,2139 newPassword: string2140): Promise<boolean> {2141 if (!derivedKey || vaultMode !== "real") return false;21422143 2144 const valid = await verifyMainPassword(currentPassword);2145 if (!valid) return false;21462147 const slotA = await loadSlot(SLOT_A);2148 if (!slotA) return false;2149 if (!rootKey) return false;21502151 2152 const newSalt = generateSalt();2153 const newKey = await deriveKey(newPassword, newSalt);21542155 2156 const plaintext = encodePayload(entries, duressConfigured);2157 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);21582159 2160 2161 2162 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);21632164 const finalSlotA: StoredVault = {2165 ...slotA,2166 salt: hex(newSalt),2167 nonce,2168 ciphertext,2169 tag,2170 ...pwRoot,2171 };21722173 2174 2175 2176 await takeVaultSnapshot("local_save");21772178 2179 2180 2181 2182 await writeSlot(SLOT_A, finalSlotA);21832184 2185 2186 2187 zeroKey(derivedKey);2188 derivedKey = newKey;21892190 2191 2192 2193 if (!duressConfigured) {2194 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2195 }2196 await stampLocalVaultMutation();21972198 return true;2199}220022012202220322042205export async function getVaultBlobForSync(): Promise<{2206 blob: string;2207 timestamp: number;2208 license?: string;2209} | null> {2210 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, 'peach_last_modified_at', 'peach_license']);2211 const slotA = (raw as Record<string, unknown>)[SLOT_A];2212 if (!slotA) return null;2213 const slotB = (raw as Record<string, unknown>)[SLOT_B];2214 const blob = JSON.stringify({ a: slotA, b: slotB });2215 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2216 const timestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;2217 const license = (raw as Record<string, unknown>)['peach_license'];2218 return {2219 blob,2220 timestamp,2221 license: typeof license === 'string' ? license : undefined,2222 };2223}22242225export async function applyIncomingVault(2226 blobJson: string,2227 remoteTimestamp: number,2228 remoteLicense?: string,2229): Promise<boolean> {2230 const raw = await chrome.storage.local.get('peach_last_modified_at');2231 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2232 const localTimestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;22332234 if (remoteLicense) {2235 const licenseRaw = await chrome.storage.local.get('peach_license');2236 const existing = (licenseRaw as Record<string, unknown>)['peach_license'];2237 if (!existing) {2238 await chrome.storage.local.set({ peach_license: remoteLicense });2239 }2240 }22412242 if (remoteTimestamp <= localTimestamp) return false;22432244 2245 await takeVaultSnapshot('sync_received').catch(() => {});22462247 const { a, b } = JSON.parse(blobJson) as { a: unknown; b?: unknown };2248 await writeSlot(SLOT_A, a as StoredVault);2249 if (b) await writeSlot(SLOT_B, b as StoredVault);2250 await chrome.storage.local.set({2251 peach_last_sync_at: String(Date.now()),2252 peach_last_modified_at: String(remoteTimestamp),2253 });22542255 return true;2256}22572258export async function getSyncIdentity(): Promise<{2259 privateKeyBytes: Uint8Array;2260 publicKeyBytes: Uint8Array;2261 publicKeyHex: string;2262}> {2263 const raw = await chrome.storage.local.get('peach_sync_identity');2264 const stored = (raw as Record<string, unknown>)['peach_sync_identity'];2265 if (typeof stored === 'string') {2266 const { priv, pub } = JSON.parse(stored) as { priv: string; pub: string };2267 const privateKeyBytes = base64DecodeBytes(priv);2268 const publicKeyBytes = base64DecodeBytes(pub);2269 return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2270 }22712272 2273 const { generateKeyPair } = await import('@stablelib/x25519');2274 const keypair = generateKeyPair();2275 await chrome.storage.local.set({2276 peach_sync_identity: JSON.stringify({2277 priv: base64EncodeBytes(keypair.secretKey),2278 pub: base64EncodeBytes(keypair.publicKey),2279 }),2280 });2281 return {2282 privateKeyBytes: keypair.secretKey,2283 publicKeyBytes: keypair.publicKey,2284 publicKeyHex: bytesToHex(keypair.publicKey),2285 };2286}22872288228922902291export async function reloadEntriesAfterSync(): Promise<boolean> {2292 if (!derivedKey) return false;2293 const slotA = await loadSlot(SLOT_A);2294 if (!slotA) return false;2295 try {2296 const payload = await tryDecryptSlot(slotA, derivedKey);2297 if (!payload) return false;2298 entries = payload.entries;2299 buildDomainIndexSync(entries);2300 return true;2301 } catch {2302 return false;2303 }2304}23052306function bytesToHex(bytes: Uint8Array): string {2307 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');2308}23092310function base64EncodeBytes(bytes: Uint8Array): string {2311 let binary = '';2312 for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);2313 return btoa(binary);2314}23152316function base64DecodeBytes(b64: string): Uint8Array {2317 const binary = atob(b64);2318 const bytes = new Uint8Array(binary.length);2319 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);2320 return bytes;2321}