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;6869707172737475767778let lastUnlockFailureReason: string | null = null;7980export function consumeLastUnlockFailureReason(): string | null {81 const r = lastUnlockFailureReason;82 lastUnlockFailureReason = null;83 return r;84}858687888990export type TextSize = "small" | "medium" | "large";9192export interface VaultSettings {93 accentColor?: AccentColorName;94 theme?: "dark" | "light";95 autoLockMinutes?: number;96 clipboardClearSeconds?: number;97 lockOnClose?: boolean;98 breachMonitoring?: boolean;99 passkeyProviderEnabled?: boolean;100 trashRetentionDays?: number;101 manualSummonDomains?: Record<string, ManualSummonRecord>;102 declinedCardFingerprints?: string[];103 104105106107108 textSize?: TextSize;109 110111112113114 reduceMotion?: boolean;115 116117118119120 biometricsEnabled?: boolean;121 122123124125126127128 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;129}130131let settings: VaultSettings = {};132133export function getSettings(): VaultSettings {134 return settings;135}136137export async function ensureSettingsLoaded(): Promise<VaultSettings> {138 await loadSettings();139 return settings;140}141142export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {143 await loadSettings();144 settings = { ...settings, ...patch };145 await persistSettings();146}147148async function persistSettings(): Promise<void> {149 try {150 await chrome.storage.local.set({ peach_settings: settings });151 } catch (err) {152 throw mapStorageError(err);153 }154}155156function mapStorageError(err: unknown): Error {157 const message = err instanceof Error ? err.message : String(err);158 if (/QUOTA_BYTES|quota/i.test(message)) {159 return new Error("STORAGE_QUOTA_EXCEEDED");160 }161 return err instanceof Error ? err : new Error(message);162}163164async function loadSettings(): Promise<void> {165 const result = await chrome.storage.local.get("peach_settings");166 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};167}168169170171172173export async function checkBreach(password: string): Promise<number> {174 175 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-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));216 return Array.from(new Uint8Array(buf).slice(0, 8))217 .map((b) => b.toString(16).padStart(2, "0"))218 .join("");219}220221function hex(buf: Uint8Array): string {222 return Array.from(buf)223 .map((b) => b.toString(16).padStart(2, "0"))224 .join("");225}226227function unhex(s: string): Uint8Array {228 const out = new Uint8Array(s.length / 2);229 for (let i = 0; i < s.length; i += 2) {230 out[i / 2] = parseInt(s.slice(i, i + 2), 16);231 }232 return out;233}234235236237238239240241242243244245246async function generateAndWrapRootKey(): Promise<{247 rawRootKey: string; 248 recoverySalt: string;249 wrappedRootKey: string;250 wrappedNonce: string;251 wrappedTag: string;252}> {253 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));254255 256 const words = await rootKeyToWords(rootKeyBytes);257 const wordMaterial = await wordsToRootKeyMaterial(words);258 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));259 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);260261 262 263 const nonce = crypto.getRandomValues(new Uint8Array(12));264 const encoded = new TextEncoder().encode(hex(rootKeyBytes));265 const raw = await crypto.subtle.encrypt(266 {267 name: "AES-GCM",268 iv: nonce,269 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,270 tagLength: 128,271 },272 recoveryKey,273 encoded274 );275 276 const rawU8 = new Uint8Array(raw);277 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));278 const tag = hex(rawU8.slice(rawU8.length - 16));279280 return {281 rawRootKey: hex(rootKeyBytes),282 recoverySalt: hex(recoverySalt),283 wrappedRootKey: ciphertext,284 wrappedNonce: hex(nonce),285 wrappedTag: tag,286 };287}288289290291292293294export async function unwrapRootKeyForRecovery(295 wrappedRootKey: string,296 wrappedNonce: string,297 wrappedTag: string,298 recoverySaltHex: string,299 words: string[]300): Promise<void> {301 const wordMaterial = await wordsToRootKeyMaterial(words);302 const recoverySalt = unhex(recoverySaltHex);303 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);304305 306 307 308 309 310 const nonceRaw = unhex(wrappedNonce);311 const ciphertextRaw = unhex(wrappedRootKey);312 const tagRaw = unhex(wrappedTag);313 314 315 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;316 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);317 combined.set(ciphertextRaw, 0);318 combined.set(tagRaw, ciphertextRaw.length);319 const raw = await crypto.subtle.decrypt(320 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },321 recoveryKey,322 combined323 );324 const decryptedHex = new TextDecoder().decode(raw);325 rootKey = unhex(decryptedHex);326}327328329330331332333334335336337export async function rewrapRootKeyForPassword(338 newDerivedKey: Uint8Array,339 _newSalt: Uint8Array340): Promise<void> {341 if (!rootKey) throw new Error("root key not set");342 const slotA = await loadSlot(SLOT_A);343 if (!slotA) throw new Error("missing slot A");344 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);345 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });346}347348349350351352353async function reencryptVaultForNewKey(354 newKey: Uint8Array,355 slotA: StoredVault356): Promise<void> {357 const plaintext = encodePayload(entries, duressConfigured);358 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");359 await writeSlot(SLOT_A, {360 ...slotA,361 nonce,362 ciphertext,363 tag,364 });365}366367368369370371372export async function changePasswordWithNewKey(373 newSalt: Uint8Array,374 newKey: Uint8Array375): Promise<void> {376 const slotA = await loadSlot(SLOT_A);377 if (!slotA) throw new Error("missing slot A");378 379 380 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });381 if (derivedKey) zeroKey(derivedKey);382 derivedKey = newKey;383 384 385 386 387 if (rootKey) {388 const slotAfter = await loadSlot(SLOT_A);389 if (slotAfter) {390 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);391 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });392 }393 }394 if (!duressConfigured) {395 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));396 }397 await stampLocalVaultMutation();398}399400401402403404405export { deriveKey, generateSalt };406407export async function loadSlotA(): Promise<StoredVault | null> {408 return loadSlot(SLOT_A);409}410411412413414415416417418419420421422423async function deriveRootKeyFromDerivedKey(424 derivedKeyBytes: Uint8Array,425 slotA: StoredVault,426): Promise<Uint8Array | null> {427 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {428 return null;429 }430 try {431 const rootHex = await decrypt(432 derivedKeyBytes,433 slotA.pw_root_nonce,434 slotA.pw_root_ciphertext,435 slotA.pw_root_tag,436 "pw_root_v1",437 );438 return unhex(rootHex);439 } catch {440 return null;441 }442}443444445446async function wrapRootKeyForDerivedKey(447 derivedKeyBytes: Uint8Array,448 rootKeyBytes: Uint8Array,449): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {450 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");451 return {452 pw_root_ciphertext: ciphertext,453 pw_root_nonce: nonce,454 pw_root_tag: tag,455 };456}457458459460461462463464465export async function migrateToRecovery(): Promise<void> {466 if (!derivedKey || vaultMode !== "real") {467 throw new Error("vault must be unlocked in real mode");468 }469 const slotA = await loadSlot(SLOT_A);470 if (!slotA) throw new Error("missing slot A");471 const hasRecoveryEscrow =472 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;473 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;474 if (hasRecoveryEscrow && hasPwRoot) return; 475476 477 478 479 480 481 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =482 await generateAndWrapRootKey();483 rootKey = unhex(rawRootKey);484485 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);486 const nextSlotA: StoredVault = {487 ...slotA,488 recovery_salt: recoverySalt,489 recovery_wrapped_root_key: wrappedRootKey,490 recovery_nonce: wrappedNonce,491 recovery_tag: wrappedTag,492 ...pwRoot,493 };494 await writeSlot(SLOT_A, nextSlotA);495 await stampLocalVaultMutation();496}497498499500501502503export async function getRecoveryWords(): Promise<string[]> {504 if (!rootKey) {505 throw new Error("recovery not set up — vault may need migration");506 }507 return rootKeyToWords(rootKey);508}509510async function loadSlot(key: string): Promise<StoredVault | null> {511 const result = await chrome.storage.local.get(key);512 return (result as Record<string, StoredVault | undefined>)[key] ?? null;513}514515async function writeSlot(key: string, vault: StoredVault): Promise<void> {516 try {517 await chrome.storage.local.set({ [key]: vault });518 } catch (err) {519 throw mapStorageError(err);520 }521}522523async function stampLocalVaultMutation(): Promise<void> {524 await chrome.storage.local.set({525 peach_last_modified_at: String(Date.now()),526 });527}528529530531532533534async function ensureMigrated(): Promise<void> {535 if (migrationRan) return;536 migrationRan = true;537538 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];539 const raw = await chrome.storage.local.get(keys);540 const slotAExists = raw[SLOT_A] !== undefined;541 const slotBExists = raw[SLOT_B] !== undefined;542 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;543 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;544545 546 if (!slotAExists && !legacyMain) return;547548 549 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;550551 let didMigrate = false;552 if (legacyMain && !slotAExists) {553 await writeSlot(SLOT_A, legacyMain);554 didMigrate = true;555 }556557 558 if (!slotBExists) {559 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;560 if (legacyDecoy) {561 await writeSlot(SLOT_B, legacyDecoy);562 didMigrate = true;563 } else if (target) {564 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));565 didMigrate = true;566 }567 }568569 const toRemove: string[] = [];570 if (legacyMain) toRemove.push(LEGACY_MAIN);571 if (legacyDecoy) toRemove.push(LEGACY_DECOY);572 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);573 if (toRemove.length > 0) {574 await chrome.storage.local.remove(toRemove);575 didMigrate = true;576 }577 if (didMigrate) {578 await stampLocalVaultMutation();579 }580}581582583584585586587588589590591592function padPlaintext(json: string): string {593 const encoded = new TextEncoder().encode(json);594 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);595 const padLen = target - encoded.length;596 return json + " ".repeat(padLen);597}598599600601602603604605606607608609const CSPRNG_CHUNK = 65_536;610611function fillRandom(buf: Uint8Array): Uint8Array {612 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {613 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);614 const chunk = new Uint8Array(end - offset);615 crypto.getRandomValues(chunk);616 buf.set(chunk, offset);617 }618 return buf;619}620621function generateNoiseBlob(ciphertextBytes: number): StoredVault {622 const salt = crypto.getRandomValues(new Uint8Array(16));623 const nonce = crypto.getRandomValues(new Uint8Array(12));624 const cipher = fillRandom(new Uint8Array(ciphertextBytes));625 const tag = crypto.getRandomValues(new Uint8Array(16));626 return {627 version: 1,628 salt: hex(salt),629 nonce: hex(nonce),630 ciphertext: hex(cipher),631 tag: hex(tag),632 };633}634635636637638639type VaultPayload = {640 entries: VaultEntry[];641 duressConfigured?: boolean;642};643644type DecryptedPayload = {645 entries: VaultEntry[];646 duressConfigured: boolean;647 648649650651652653654 migratedFromLegacy: boolean;655};656657function encodePayload(entries: VaultEntry[], duress: boolean): string {658 const envelope: VaultPayload = { entries, duressConfigured: duress };659 return padPlaintext(JSON.stringify(envelope));660}661662function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {663 if ("created" in raw || "updated" in raw) return true;664 const flat = raw as Partial<VaultEntry>;665 if (flat.type === "passkey") return true;666 667 if (flat.rpId !== undefined && flat.passkey === undefined) return true;668 return false;669}670671function decodePayload(plain: string): DecryptedPayload {672 const parsed = JSON.parse(plain) as673 | Array<VaultEntry | LegacyVaultEntry>674 | VaultPayload;675676 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];677 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);678679 const normalized = rawEntries.map(normalizeEntry);680 const { merged } = mergePasskeyOnlyIntoLogins(normalized);681682 if (Array.isArray(parsed)) {683 return {684 entries: normalized,685 duressConfigured: false,686 migratedFromLegacy: true, 687 };688 }689690 return {691 entries: normalized,692 duressConfigured: parsed.duressConfigured === true,693 migratedFromLegacy: flatMigrationNeeded || merged,694 };695}696697698699700701702703704705706707708709710711712713714715716717718719720function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {721 let merged = false;722 const now = Date.now();723724 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);725 const byRpId = new Map<string, VaultEntry[]>();726 for (const e of candidates) {727 const rpId = e.passkey!.rpId;728 const list = byRpId.get(rpId);729 if (list) list.push(e);730 else byRpId.set(rpId, [e]);731 }732733 for (const [rpId, group] of byRpId) {734 if (group.length === 0) continue;735 group.sort((a, b) => {736 const aT =737 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;738 const bT =739 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;740 return bT - aT;741 });742 const winner = group[0]!;743744 745 746 747 const loginTarget = entries.find((e) => {748 if (e.deletedAt || e.passkey || e.type !== "login") return false;749 if (e.url && extractDomain(e.url) === rpId) return true;750 for (const extra of e.additionalUrls ?? []) {751 if (extractDomain(extra) === rpId) return true;752 }753 return false;754 });755756 if (loginTarget) {757 loginTarget.passkey = winner.passkey;758 loginTarget.modifiedAt = now;759 for (const dup of group) {760 if (!dup.deletedAt) {761 dup.deletedAt = now;762 dup.modifiedAt = now;763 }764 }765 merged = true;766 } else if (group.length > 1) {767 for (let i = 1; i < group.length; i += 1) {768 const dup = group[i]!;769 if (!dup.deletedAt) {770 dup.deletedAt = now;771 dup.modifiedAt = now;772 }773 }774 merged = true;775 }776 }777778 return { merged };779}780781782783784785786787function hasValidVaultSchema(v: unknown): v is StoredVault {788 if (typeof v !== "object" || v === null) return false;789 const r = v as Record<string, unknown>;790 return (791 r.version === 1 &&792 typeof r.salt === "string" &&793 typeof r.nonce === "string" &&794 typeof r.ciphertext === "string" &&795 typeof r.tag === "string"796 );797}798799export type TryDecryptResult =800 | { status: "ok"; payload: DecryptedPayload }801 | { status: "wrong_key" }802 | { status: "corrupted"; reason: string };803804805806807808809export async function tryDecryptSlot(810 vault: StoredVault,811 key: Uint8Array,812 aadTag?: string,813): Promise<DecryptedPayload | null> {814 const res = await tryDecryptSlotResult(vault, key, aadTag);815 return res.status === "ok" ? res.payload : null;816}817818export async function tryDecryptSlotResult(819 vault: StoredVault,820 key: Uint8Array,821 aadTag?: string,822): Promise<TryDecryptResult> {823 if (!hasValidVaultSchema(vault)) {824 return { status: "corrupted", reason: "invalid_slot_schema" };825 }826 let plain: string;827 try {828 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);829 } catch {830 return { status: "wrong_key" };831 }832 try {833 const parsed = JSON.parse(plain) as unknown;834 if (typeof parsed !== "object" || parsed === null) {835 return { status: "corrupted", reason: "invalid_payload_shape" };836 }837 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {838 return { status: "corrupted", reason: "invalid_payload_shape" };839 }840 return { status: "ok", payload: decodePayload(plain) };841 } catch {842 return { status: "corrupted", reason: "invalid_payload_json" };843 }844}845846847848849850function buildDomainIndexSync(all: VaultEntry[]): void {851 domainIndex = new Map();852853 for (const entry of all) {854 if (entry.deletedAt) continue;855 if (entry.type === "note" || entry.type === "card") continue;856857 858 859 860 861 const domains = new Set<string>();862 const primary = extractDomain(entry.url ?? "");863 if (primary) domains.add(primary);864 for (const extra of entry.additionalUrls ?? []) {865 const d = extractDomain(extra);866 if (d) domains.add(d);867 }868 if (domains.size === 0) continue;869870 for (const domain of domains) {871 const bucket = domainIndex.get(domain) ?? [];872 bucket.push(entry);873 domainIndex.set(domain, bucket);874 }875 }876}877878export async function createVault(password: string): Promise<void> {879 await ensureMigrated();880881 const salt = generateSalt();882 derivedKey = await deriveKey(password, salt);883 entries = [];884 vaultMode = "real";885 duressConfigured = false;886 buildDomainIndexSync(entries);887888 889 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =890 await generateAndWrapRootKey();891 rootKey = unhex(rawRootKey);892893 const plaintext = encodePayload(entries, duressConfigured);894 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");895896 897 898 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);899900 const slotA: StoredVault = {901 version: 1,902 salt: hex(salt),903 nonce,904 ciphertext,905 tag,906 recovery_salt: recoverySalt,907 recovery_wrapped_root_key: wrappedRootKey,908 recovery_nonce: wrappedNonce,909 recovery_tag: wrappedTag,910 ...pwRoot,911 };912 await writeSlot(SLOT_A, slotA);913914 915 916 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));917 await stampLocalVaultMutation();918919 touchAutoLock();920}921922export async function hasVault(): Promise<boolean> {923 await ensureMigrated();924 return (await loadSlot(SLOT_A)) !== null;925}926927928929930931932export async function isDuressEnabled(): Promise<boolean> {933 return vaultMode === "real" && duressConfigured;934}935936export async function setupDuress(duressPassword: string): Promise<void> {937 if (!derivedKey || vaultMode !== "real") {938 throw new Error("vault must be unlocked in real mode");939 }940 await ensureMigrated();941942 943 944 945 const slotA = await loadSlot(SLOT_A);946 if (!slotA) throw new Error("missing slot A");947948 const decoySalt = generateSalt();949 const decoyKey = await deriveKey(duressPassword, decoySalt);950 try {951 952 953 const targetBytes = slotA.ciphertext.length / 2;954 let plaintext = encodePayload([], false);955 const encoded = new TextEncoder().encode(plaintext);956 if (encoded.length < targetBytes) {957 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);958 }959 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");960 await writeSlot(SLOT_B, {961 version: 1,962 salt: hex(decoySalt),963 nonce,964 ciphertext,965 tag,966 });967 } finally {968 zeroKey(decoyKey);969 }970971 972 973 duressConfigured = true;974 await persistSlotA();975}976977export async function disableDuress(): Promise<void> {978 if (!derivedKey || vaultMode !== "real") {979 throw new Error("vault must be unlocked in real mode");980 }981 await ensureMigrated();982983 984 985 986 const slotA = await loadSlot(SLOT_A);987 if (slotA) {988 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));989 }990991 duressConfigured = false;992 await persistSlotA();993}99499599699799899910001001export async function verifyMainPassword(password: string): Promise<boolean> {1002 await ensureMigrated();1003 const slotA = await loadSlot(SLOT_A);1004 if (!slotA) return false;1005 const key = await deriveKey(password, unhex(slotA.salt));1006 try {1007 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1008 } finally {1009 zeroKey(key);1010 }1011}10121013export async function unlock(password: string): Promise<boolean> {1014 await ensureMigrated();10151016 1017 1018 1019 1020 1021 const [slotA, slotB] = await Promise.all([1022 loadSlot(SLOT_A),1023 loadSlot(SLOT_B),1024 ]);10251026 1027 const [keyA, keyB] = await Promise.all([1028 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),1029 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),1030 ]);10311032 1033 1034 1035 const [resultA, resultB] = await Promise.all([1036 keyA && slotA1037 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1038 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1039 keyB && slotB1040 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1041 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1042 ]);1043 const payloadA = resultA.status === "ok" ? resultA.payload : null;1044 const payloadB = resultB.status === "ok" ? resultB.payload : null;10451046 if (payloadA && keyA) {1047 derivedKey = keyA;1048 vaultMode = "real";1049 entries = payloadA.entries;1050 duressConfigured = payloadA.duressConfigured;1051 buildDomainIndexSync(entries);1052 await loadSettings();1053 touchAutoLock();1054 if (keyB) zeroKey(keyB);10551056 1057 if (slotA) {1058 const needsRecoverySetup =1059 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1060 if (!needsRecoverySetup) {1061 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1062 }1063 1064 1065 1066 1067 if (needsRecoverySetup || !rootKey) {1068 try {1069 await migrateToRecovery();1070 if (needsRecoverySetup) {1071 1072 1073 1074 await chrome.storage.local.set({ peach_recovery_pending: '1' });1075 }1076 } catch (migrationErr) {1077 console.warn('[Peach] recovery migration failed:', migrationErr);1078 }1079 }1080 }10811082 1083 1084 1085 if (payloadA.migratedFromLegacy) {1086 await persistSlotA();1087 }1088 return true;1089 }10901091 if (payloadB && keyB) {1092 derivedKey = keyB;1093 vaultMode = "decoy";1094 entries = payloadB.entries;1095 duressConfigured = false;1096 buildDomainIndexSync(entries);1097 await loadSettings();1098 touchAutoLock();1099 if (keyA) zeroKey(keyA);1100 return true;1101 }11021103 if (keyA) zeroKey(keyA);1104 if (keyB) zeroKey(keyB);1105 1106 1107 if (resultA.status === "corrupted") {1108 lastUnlockFailureReason = resultA.reason;1109 } else if (resultB.status === "corrupted") {1110 lastUnlockFailureReason = resultB.reason;1111 }1112 return false;1113}11141115export function lock(): void {1116 if (derivedKey) {1117 zeroKey(derivedKey);1118 derivedKey = null;1119 }1120 if (rootKey) {1121 rootKey.fill(0);1122 rootKey = null;1123 }11241125 entries = [];1126 domainIndex = new Map();1127 vaultMode = "real";1128 duressConfigured = false;1129 clearAutoLock();1130}11311132export function isUnlocked(): boolean {1133 return derivedKey !== null;1134}1135113611371138export async function unlockWithKey(1139 masterKeyHex: string,1140): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1141 try {1142 const masterKeyBytes = unhex(masterKeyHex);1143 const stored = await loadSlot(SLOT_A);1144 if (!stored) return { ok: false, error: "invalid_key" };1145 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1146 if (!payload) return { ok: false, error: "invalid_key" };11471148 derivedKey = masterKeyBytes;1149 vaultMode = "real";1150 entries = payload.entries;1151 duressConfigured = payload.duressConfigured;1152 buildDomainIndexSync(entries);1153 await loadSettings();1154 touchAutoLock();1155 return { ok: true, entries };1156 } catch {1157 return { ok: false, error: "invalid_key" };1158 }1159}11601161116211631164116511661167116811691170117111721173117411751176117711781179export async function unlockFromCodex(params: {1180 slotA: string; 1181 slotB: string | null; 1182 timestamp: number;1183 masterPassphrase: string;1184}): Promise<1185 | { ok: true; entryCount: number }1186 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1187> {1188 let parsedSlotA: StoredVault;1189 let parsedSlotB: StoredVault | null = null;1190 try {1191 const rawA = JSON.parse(params.slotA) as unknown;1192 if (!hasValidVaultSchema(rawA)) {1193 return { ok: false, reason: "invalid_slot_format" };1194 }1195 parsedSlotA = rawA;1196 if (params.slotB !== null) {1197 const rawB = JSON.parse(params.slotB) as unknown;1198 if (!hasValidVaultSchema(rawB)) {1199 return { ok: false, reason: "invalid_slot_format" };1200 }1201 parsedSlotB = rawB;1202 }1203 } catch {1204 return { ok: false, reason: "invalid_slot_format" };1205 }12061207 1208 1209 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1210 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1211 if (!trialPayload) {1212 zeroKey(trialKey);1213 return { ok: false, reason: "wrong_passphrase" };1214 }1215 1216 1217 zeroKey(trialKey);12181219 1220 1221 await takeVaultSnapshot("restore");12221223 await writeSlot(SLOT_A, parsedSlotA);1224 if (parsedSlotB) {1225 await writeSlot(SLOT_B, parsedSlotB);1226 } else {1227 1228 1229 1230 await writeSlot(1231 SLOT_B,1232 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1233 );1234 }1235 await chrome.storage.local.set({1236 peach_last_modified_at: String(params.timestamp),1237 });12381239 1240 1241 const ok = await unlock(params.masterPassphrase);1242 if (!ok) {1243 1244 1245 1246 return { ok: false, reason: "wrong_passphrase" };1247 }1248 return { ok: true, entryCount: getEntries().length };1249}1250125112521253export function getDerivedKeyHex(): string {1254 if (!derivedKey) throw new Error("vault_not_unlocked");1255 return hex(derivedKey);1256}12571258export function getEntries(): VaultEntry[] {1259 if (!derivedKey) throw new Error("vault_locked");1260 return entries.filter((entry) => !entry.deletedAt);1261}12621263const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;12641265export function getIdentities(): SavedIdentity[] {1266 if (!derivedKey) throw new Error("vault_locked");1267 const active = entries.filter((e) => !e.deletedAt && e.type === "login");12681269 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1270 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();12711272 for (const entry of active) {1273 const value = entry.username?.trim();1274 if (!value) continue;1275 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;1276 const existing = map.get(value);1277 const domain = extractDomain(entry.url ?? "") ?? "";1278 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;1279 if (!existing || modifiedAt > existing.lastUsed) {1280 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1281 } else {1282 map.set(value, { ...existing, count: existing.count + 1 });1283 }1284 }12851286 const toIdentity = (type: "email" | "username") =>1287 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1288 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1289 });12901291 return [1292 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1293 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1294 ];1295}12961297export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1298 if (!derivedKey) throw new Error("vault_locked");12991300 const now = Date.now();1301 const added: VaultEntry[] = [];1302 for (const draft of drafts) {1303 try {1304 const sanitized = sanitizeEntryDraft(draft);1305 const nextEntry: VaultEntry = {1306 ...sanitized,1307 id: crypto.randomUUID(),1308 createdAt: now,1309 modifiedAt: now,1310 };1311 entries.push(nextEntry);1312 added.push(nextEntry);1313 } catch {1314 1315 }1316 }13171318 if (added.length > 0) {1319 await persistEntries();1320 }13211322 return added;1323}132413251326132713281329133013311332export async function restoreEntries(1333 incoming: VaultEntry[],1334 mode: "replace" | "merge"1335): Promise<{ added: number; updated: number; skipped: number }> {1336 if (!derivedKey) throw new Error("vault_locked");13371338 1339 1340 1341 if (mode === "replace" && incoming.length === 0) {1342 throw new Error("refuse to replace with empty entry set");1343 }13441345 let added = 0;1346 let updated = 0;1347 let skipped = 0;13481349 if (mode === "replace") {1350 entries = [];1351 for (const entry of incoming) {1352 const normalized = sanitizeRestoredEntry(entry);1353 if (!normalized) {1354 skipped += 1;1355 continue;1356 }1357 entries.push(normalized);1358 added += 1;1359 }1360 } else {1361 const byId = new Map<string, VaultEntry>();1362 for (const e of entries) byId.set(e.id, e);13631364 for (const entry of incoming) {1365 const normalized = sanitizeRestoredEntry(entry);1366 if (!normalized) {1367 skipped += 1;1368 continue;1369 }1370 const existing = byId.get(normalized.id);1371 if (!existing) {1372 entries.push(normalized);1373 added += 1;1374 } else if (normalized.modifiedAt > existing.modifiedAt) {1375 const idx = entries.findIndex((e) => e.id === existing.id);1376 if (idx !== -1) {1377 entries[idx] = normalized;1378 updated += 1;1379 }1380 } else {1381 skipped += 1;1382 }1383 }1384 }13851386 await persistEntries("import");1387 return { added, updated, skipped };1388}1389139013911392139313941395function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1396 if (!entry || typeof entry !== "object") return null;1397 const title = typeof entry.title === "string" ? entry.title.trim() : "";1398 if (!title) return null;1399 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1400 const now = Date.now();1401 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1402 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;14031404 1405 1406 1407 const migrated: VaultEntry = migrateLegacyPasskey(1408 {1409 ...entry,1410 id,1411 title,1412 type: entry.type ?? "login",1413 createdAt,1414 modifiedAt,1415 },1416 entry1417 );14181419 1420 1421 if (1422 migrated.type === "login" &&1423 !migrated.passkey &&1424 typeof migrated.password !== "string"1425 ) {1426 return null;1427 }1428 if (migrated.passkey && !migrated.passkey.credentialId) {1429 return null;1430 }14311432 return migrated;1433}143414351436143714381439export async function clearVaultEntries(1440 confirmation: "REPLACE_ALL"1441): Promise<void> {1442 if (confirmation !== "REPLACE_ALL") {1443 throw new Error("clearVaultEntries requires explicit confirmation");1444 }1445 if (!derivedKey) throw new Error("vault_locked");1446 for (const entry of entries) {1447 if (!entry.deletedAt) {1448 entry.deletedAt = Date.now();1449 entry.modifiedAt = Date.now();1450 }1451 }1452 await persistEntries();1453}14541455export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1456 if (!derivedKey) throw new Error("vault_locked");14571458 const sanitized = sanitizeEntryDraft(entry);1459 const now = Date.now();1460 const nextEntry: VaultEntry = {1461 ...sanitized,1462 id: crypto.randomUUID(),1463 createdAt: now,1464 modifiedAt: now,1465 1466 1467 passwordChangedAt: sanitized.password ? now : undefined,1468 };14691470 1471 1472 1473 const rollback = entries;1474 entries = [...entries, nextEntry];1475 try {1476 await persistEntries();1477 } catch (err) {1478 entries = rollback;1479 throw err;1480 }14811482 1483 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1484 checkBreach(nextEntry.password).then(async (count) => {1485 if (count > 0) {1486 const idx = entries.findIndex((e) => e.id === nextEntry.id);1487 if (idx !== -1) {1488 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1489 await persistEntries();1490 }1491 }1492 });1493 }14941495 return nextEntry;1496}14971498export async function updateEntry(1499 id: string,1500 patch: Partial<VaultEntry>1501): Promise<void> {1502 if (!derivedKey) throw new Error("vault_locked");15031504 const idx = entries.findIndex((entry) => entry.id === id);1505 if (idx === -1 || entries[idx].deletedAt) {1506 throw new Error("entry_not_found");1507 }15081509 const current = entries[idx];1510 const sanitizedPatch = sanitizeEntryPatch(patch);1511 1512 1513 1514 const passwordChanged =1515 "password" in patch &&1516 typeof sanitizedPatch.password === "string" &&1517 sanitizedPatch.password !== current.password;1518 entries[idx] = {1519 ...current,1520 ...sanitizedPatch,1521 modifiedAt: Date.now(),1522 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1523 };15241525 1526 1527 1528 1529 if (1530 entries[idx].type === "login" &&1531 !entries[idx].password &&1532 !entries[idx].passkey1533 ) {1534 entries[idx] = current;1535 throw new Error("password or passkey required");1536 }15371538 1539 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1540 entries[idx] = current;1541 throw new Error("card number is required for card entries");1542 }15431544 await persistEntries();15451546 1547 1548 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1549 entries[idx] = { ...entries[idx], breach: undefined };1550 await persistEntries();1551 const password = entries[idx].password;1552 if (password) {1553 checkBreach(password).then(async (count) => {1554 const i = entries.findIndex((e) => e.id === id);1555 if (i !== -1 && count > 0) {1556 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1557 await persistEntries();1558 }1559 });1560 }1561 }1562}15631564export async function deleteEntry(id: string): Promise<void> {1565 if (!derivedKey) throw new Error("vault_locked");15661567 const entry = entries.find((item) => item.id === id);1568 if (!entry || entry.deletedAt) {1569 throw new Error("entry_not_found");1570 }15711572 entry.deletedAt = Date.now();1573 entry.modifiedAt = Date.now();1574 await persistEntries();1575}15761577export function getDeletedEntries(): VaultEntry[] {1578 if (!derivedKey) return [];1579 return entries.filter((e) => !!e.deletedAt);1580}15811582export async function restoreEntry(id: string): Promise<void> {1583 if (!derivedKey) throw new Error("vault_locked");1584 const entry = entries.find((e) => e.id === id && e.deletedAt);1585 if (!entry) throw new Error("deleted entry not found");1586 delete entry.deletedAt;1587 entry.modifiedAt = Date.now();1588 await persistEntries();1589 buildDomainIndexSync(entries);1590}15911592export async function purgeEntry(id: string): Promise<void> {1593 if (!derivedKey) throw new Error("vault_locked");1594 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1595 if (idx === -1) throw new Error("deleted entry not found");1596 entries.splice(idx, 1);1597 await persistEntries();1598}159916001601export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1602 if (!derivedKey || retentionMs === 0) return 0;1603 const cutoff = Date.now() - retentionMs;1604 const before = entries.length;1605 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1606 const purged = before - entries.length;1607 if (purged > 0) await persistEntries();1608 return purged;1609}16101611export function queryDomain(domain: string): VaultEntry[] {1612 return domainIndex.get(domain) ?? [];1613}16141615export function getEntryById(id: string): VaultEntry | undefined {1616 return entries.find((entry) => entry.id === id && !entry.deletedAt);1617}16181619async function persistSlotA(1620 reason: VaultSnapshot["reason"] = "local_save",1621): Promise<StoredVault> {1622 if (!derivedKey) throw new Error("vault_locked");16231624 const slotA = await loadSlot(SLOT_A);1625 if (!slotA) throw new Error("missing slot A");16261627 1628 1629 await takeVaultSnapshot(reason);16301631 const plaintext = encodePayload(entries, duressConfigured);1632 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");16331634 1635 const nextSlotA: StoredVault = {1636 ...slotA,1637 nonce,1638 ciphertext,1639 tag,1640 1641 };1642 await writeSlot(SLOT_A, nextSlotA);16431644 1645 1646 1647 if (!duressConfigured) {1648 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1649 }1650 await stampLocalVaultMutation();16511652 return nextSlotA;1653}16541655async function persistSlotB(): Promise<void> {1656 if (!derivedKey) throw new Error("vault_locked");16571658 const slotB = await loadSlot(SLOT_B);1659 if (!slotB) throw new Error("missing slot B");16601661 1662 1663 const plaintext = encodePayload(entries, false);1664 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");16651666 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1667 await stampLocalVaultMutation();1668}16691670async function persistEntries(1671 reason: VaultSnapshot["reason"] = "local_save",1672): Promise<void> {1673 if (!derivedKey) throw new Error("vault_locked");16741675 if (vaultMode === "decoy") {1676 await persistSlotB();1677 } else {1678 await persistSlotA(reason);1679 }1680 buildDomainIndexSync(entries);1681}16821683function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1684 if (!Array.isArray(values)) return undefined;1685 const seen = new Set<string>();1686 const cleaned: string[] = [];1687 for (const value of values) {1688 const trimmed = typeof value === "string" ? value.trim() : "";1689 if (!trimmed) continue;1690 const key = trimmed.toLowerCase();1691 if (seen.has(key)) continue;1692 seen.add(key);1693 cleaned.push(trimmed);1694 }1695 return cleaned.length > 0 ? cleaned : undefined;1696}16971698function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1699 const title = entry.title.trim();1700 1701 1702 1703 let type: VaultEntry["type"] = entry.type ?? "login";1704 if (type === "passkey") type = "login";17051706 if (!title) throw new Error("title is required");1707 1708 1709 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1710 throw new Error("password or passkey required");1711 }1712 if (entry.passkey) {1713 const p = entry.passkey;1714 if (1715 !p.rpId ||1716 !p.credentialId ||1717 !p.publicKeyCose ||1718 !p.userHandle ||1719 !p.userName ||1720 (p.algorithm !== -7 && p.algorithm !== -8)1721 ) {1722 throw new Error("invalid passkey credential");1723 }1724 }1725 if (type === "card") {1726 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1727 throw new Error("cardholder name and card number are required");1728 }1729 }17301731 return {1732 type,1733 title,1734 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1735 password: type === "login" ? entry.password : undefined,1736 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1737 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1738 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1739 notes: entry.notes?.trim() || undefined,1740 breach: type === "login" ? entry.breach : undefined,1741 canary: type === "login" ? entry.canary : undefined,1742 tags: entry.tags,1743 passkey: entry.passkey,1744 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1745 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1746 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1747 expYear: type === "card" ? entry.expYear?.trim() : undefined,1748 cvc: type === "card" ? entry.cvc?.trim() : undefined,1749 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1750 network: type === "card" ? entry.network : undefined,1751 last4: type === "card" ? entry.last4?.trim() : undefined,1752 };1753}17541755function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1756 const nextPatch: Partial<VaultEntry> = {};17571758 if ("type" in patch) {1759 nextPatch.type = patch.type;1760 }17611762 if ("title" in patch) {1763 const title = patch.title?.trim() ?? "";1764 if (!title) throw new Error("title is required");1765 nextPatch.title = title;1766 }17671768 if ("url" in patch) {1769 nextPatch.url = patch.url?.trim() ?? "";1770 }17711772 if ("additionalUrls" in patch) {1773 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1774 const seen = new Set<string>();1775 const cleaned: string[] = [];1776 for (const value of incoming) {1777 const trimmed = typeof value === "string" ? value.trim() : "";1778 if (!trimmed) continue;1779 const key = trimmed.toLowerCase();1780 if (seen.has(key)) continue;1781 seen.add(key);1782 cleaned.push(trimmed);1783 }1784 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1785 }17861787 if ("username" in patch) {1788 nextPatch.username = patch.username?.trim() ?? "";1789 }17901791 if ("email" in patch) {1792 nextPatch.email = patch.email?.trim() || undefined;1793 }17941795 if ("password" in patch) {1796 const password = patch.password ?? "";1797 1798 nextPatch.password = password || undefined;1799 }18001801 if ("totp" in patch) {1802 nextPatch.totp = patch.totp?.trim() || undefined;1803 }18041805 if ("notes" in patch) {1806 nextPatch.notes = patch.notes?.trim() || undefined;1807 }18081809 if ("breach" in patch) {1810 nextPatch.breach = patch.breach;1811 }18121813 if ("canary" in patch) {1814 nextPatch.canary = patch.canary;1815 }18161817 if ("passkey" in patch) {1818 1819 1820 1821 nextPatch.passkey = patch.passkey;1822 }18231824 if ("tags" in patch) {1825 nextPatch.tags = patch.tags;1826 }18271828 if ("cardholderName" in patch) {1829 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1830 }1831 if ("cardNumber" in patch) {1832 const n = patch.cardNumber ?? "";1833 nextPatch.cardNumber = n || undefined;1834 }1835 if ("expMonth" in patch) {1836 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1837 }1838 if ("expYear" in patch) {1839 nextPatch.expYear = patch.expYear?.trim() || undefined;1840 }1841 if ("cvc" in patch) {1842 nextPatch.cvc = patch.cvc?.trim() || undefined;1843 }1844 if ("cardNickname" in patch) {1845 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1846 }1847 if ("network" in patch) {1848 nextPatch.network = patch.network;1849 }1850 if ("last4" in patch) {1851 nextPatch.last4 = patch.last4?.trim() || undefined;1852 }18531854 return nextPatch;1855}18561857function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1858 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1859 const modifiedAt =1860 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;18611862 const base: VaultEntry = {1863 id: entry.id,1864 type: "type" in entry ? entry.type : "login",1865 title: entry.title,1866 username: entry.username,1867 password: entry.password,1868 url: entry.url,1869 totp: entry.totp,1870 notes: entry.notes,1871 breach: entry.breach,1872 canary: "canary" in entry ? entry.canary : undefined,1873 passkey: "passkey" in entry ? entry.passkey : undefined,1874 createdAt,1875 modifiedAt,1876 deletedAt: entry.deletedAt,1877 tags: "tags" in entry ? entry.tags : undefined,1878 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1879 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1880 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1881 expYear: "expYear" in entry ? entry.expYear : undefined,1882 cvc: "cvc" in entry ? entry.cvc : undefined,1883 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1884 network: "network" in entry ? entry.network : undefined,1885 last4: "last4" in entry ? entry.last4 : undefined,1886 };18871888 return migrateLegacyPasskey(base, entry);1889}189018911892189318941895189618971898function migrateLegacyPasskey(1899 base: VaultEntry,1900 raw: VaultEntry | LegacyVaultEntry1901): VaultEntry {1902 if (base.passkey) return base;1903 const flat = raw as Partial<VaultEntry>;1904 const isPasskeyType = base.type === "passkey";1905 const hasFlatPasskeyFields =1906 typeof flat.rpId === "string" &&1907 typeof flat.credentialId === "string" &&1908 typeof flat.publicKeyCose === "string" &&1909 typeof flat.userHandle === "string" &&1910 typeof flat.userName === "string";1911 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1912 if (!hasFlatPasskeyFields) return base;19131914 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1915 base.passkey = {1916 rpId: flat.rpId!,1917 rpName: flat.rpName ?? flat.rpId!,1918 credentialId: flat.credentialId!,1919 publicKeyCose: flat.publicKeyCose!,1920 privateKeyJwk: flat.privateKeyJwk,1921 userHandle: flat.userHandle!,1922 userName: flat.userName!,1923 userDisplayName: flat.userDisplayName ?? flat.userName!,1924 algorithm,1925 signCount: flat.signCount ?? 0,1926 aaguid: flat.aaguid ?? "",1927 backupEligible: flat.backupEligible === true,1928 backupState: flat.backupState === true,1929 lastUsedAt: flat.lastUsedAt,1930 browser: flat.browser,1931 transports: flat.transports,1932 createdAt: base.createdAt,1933 };1934 1935 1936 1937 if (isPasskeyType) base.type = "login";1938 if (!base.username) base.username = flat.userName;1939 if (!base.url) base.url = `https://${flat.rpId}`;1940 return base;1941}1942194319441945194619471948function entryDupeKey(e: VaultEntry): string {1949 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1950 1951 1952 1953 1954 if (e.passkey) {1955 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1956 }1957 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1958}195919601961export function countDuplicates(): number {1962 const best = new Map<string, VaultEntry>();1963 let dupeCount = 0;1964 for (const e of entries.filter((e) => !e.deletedAt)) {1965 const key = entryDupeKey(e);1966 const existing = best.get(key);1967 if (!existing) {1968 best.set(key, e);1969 } else {1970 dupeCount++;1971 1972 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1973 best.set(key, e);1974 }1975 }1976 }1977 return dupeCount;1978}197919801981198219831984export async function deduplicateEntries(): Promise<number> {1985 if (!derivedKey) throw new Error("vault_locked");1986 1987 1988 if (vaultMode === "decoy") {1989 throw new Error("deduplicate disabled in decoy mode");1990 }19911992 const best = new Map<string, VaultEntry>();1993 for (const e of entries.filter((en) => !en.deletedAt)) {1994 const key = entryDupeKey(e);1995 const existing = best.get(key);1996 if (!existing) {1997 best.set(key, e);1998 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1999 best.set(key, e);2000 }2001 }20022003 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2004 const now = Date.now();2005 let removed = 0;2006 entries = entries.map((e) => {2007 if (!e.deletedAt && !keepIds.has(e.id)) {2008 removed++;2009 return { ...e, deletedAt: now };2010 }2011 return e;2012 });20132014 if (removed > 0) await persistEntries();2015 return removed;2016}201720182019202020212022202320242025const HISTORY_KEY = "peach_vault_history";2026const HISTORY_MAX = 10;20272028202920302031203220332034export async function takeVaultSnapshot(2035 reason: VaultSnapshot["reason"],2036): Promise<void> {2037 try {2038 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2039 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2040 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];20412042 2043 2044 2045 if (!slotA) return;20462047 const vault_a = JSON.stringify(slotA);2048 const vault_b = JSON.stringify(slotB ?? slotA);20492050 2051 2052 2053 let entryCount: number | null = null;2054 if (derivedKey && vaultMode === "real") {2055 try {2056 entryCount = entries.filter((e) => !e.deletedAt).length;2057 } catch {2058 entryCount = null;2059 }2060 }20612062 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2063 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2064 ? existingRaw2065 : { snapshots: [] };20662067 2068 2069 2070 const latest = history.snapshots[0];2071 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2072 return;2073 }20742075 const snapshot: VaultSnapshot = {2076 id: crypto.randomUUID(),2077 savedAt: Date.now(),2078 reason,2079 vault_a,2080 vault_b,2081 entryCount,2082 };20832084 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2085 await chrome.storage.local.set({ [HISTORY_KEY]: history });2086 } catch (err) {2087 2088 2089 2090 2091 console.warn('[Peach] takeVaultSnapshot failed:', err);2092 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2093 try {2094 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2095 } catch {2096 2097 }2098 }2099 }2100}210121022103export async function clearVaultHistory(): Promise<void> {2104 try {2105 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2106 } catch {2107 2108 }2109}211021112112export async function getVaultHistory(): Promise<VaultSnapshot[]> {2113 const raw = await chrome.storage.local.get(HISTORY_KEY);2114 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2115 if (!history || !Array.isArray(history.snapshots)) return [];2116 return history.snapshots;2117}21182119212021212122212321242125export async function restoreVaultSnapshot(2126 snapshotId: string,2127): Promise<{ ok: true } | { ok: false; error: string }> {2128 const history = await getVaultHistory();2129 const snapshot = history.find((s) => s.id === snapshotId);2130 if (!snapshot) return { ok: false, error: "snapshot_not_found" };21312132 2133 2134 2135 await takeVaultSnapshot("restore");21362137 let restoredA: StoredVault;2138 let restoredB: StoredVault;2139 try {2140 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2141 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2142 } catch {2143 return { ok: false, error: "snapshot_corrupt" };2144 }21452146 await writeSlot(SLOT_A, restoredA);2147 await writeSlot(SLOT_B, restoredB);2148 await stampLocalVaultMutation();21492150 2151 2152 2153 lock();21542155 return { ok: true };2156}21572158215921602161216221632164export async function changePassword(2165 currentPassword: string,2166 newPassword: string2167): Promise<boolean> {2168 if (!derivedKey || vaultMode !== "real") return false;21692170 2171 const valid = await verifyMainPassword(currentPassword);2172 if (!valid) return false;21732174 const slotA = await loadSlot(SLOT_A);2175 if (!slotA) return false;2176 if (!rootKey) return false;21772178 2179 const newSalt = generateSalt();2180 const newKey = await deriveKey(newPassword, newSalt);21812182 2183 const plaintext = encodePayload(entries, duressConfigured);2184 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");21852186 2187 2188 2189 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);21902191 const finalSlotA: StoredVault = {2192 ...slotA,2193 salt: hex(newSalt),2194 nonce,2195 ciphertext,2196 tag,2197 ...pwRoot,2198 };21992200 2201 2202 2203 await takeVaultSnapshot("local_save");22042205 2206 2207 2208 2209 await writeSlot(SLOT_A, finalSlotA);22102211 2212 2213 2214 zeroKey(derivedKey);2215 derivedKey = newKey;22162217 2218 2219 2220 if (!duressConfigured) {2221 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2222 }2223 await stampLocalVaultMutation();22242225 return true;2226}2227