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 void saveSessionKey(keyA, "real");1049 vaultMode = "real";1050 entries = payloadA.entries;1051 duressConfigured = payloadA.duressConfigured;1052 buildDomainIndexSync(entries);1053 await loadSettings();1054 touchAutoLock();1055 if (keyB) zeroKey(keyB);10561057 1058 if (slotA) {1059 const needsRecoverySetup =1060 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1061 if (!needsRecoverySetup) {1062 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1063 }1064 1065 1066 1067 1068 if (needsRecoverySetup || !rootKey) {1069 try {1070 await migrateToRecovery();1071 if (needsRecoverySetup) {1072 1073 1074 1075 await chrome.storage.local.set({ peach_recovery_pending: '1' });1076 }1077 } catch (migrationErr) {1078 console.warn('[Peach] recovery migration failed:', migrationErr);1079 }1080 }1081 }10821083 1084 1085 1086 if (payloadA.migratedFromLegacy) {1087 await persistSlotA();1088 }1089 return true;1090 }10911092 if (payloadB && keyB) {1093 derivedKey = keyB;1094 void saveSessionKey(keyB, "decoy");1095 vaultMode = "decoy";1096 entries = payloadB.entries;1097 duressConfigured = false;1098 buildDomainIndexSync(entries);1099 await loadSettings();1100 touchAutoLock();1101 if (keyA) zeroKey(keyA);1102 return true;1103 }11041105 if (keyA) zeroKey(keyA);1106 if (keyB) zeroKey(keyB);1107 1108 1109 if (resultA.status === "corrupted") {1110 lastUnlockFailureReason = resultA.reason;1111 } else if (resultB.status === "corrupted") {1112 lastUnlockFailureReason = resultB.reason;1113 }1114 return false;1115}11161117111811191120112111221123const SESSION_KEY_STORAGE = "peach_session_dk";11241125async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1126 if (!chrome.storage.session) return;1127 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1128}11291130async function clearSessionKey(): Promise<void> {1131 if (!chrome.storage.session) return;1132 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1133}11341135113611371138113911401141export async function restoreFromSession(): Promise<boolean> {1142 if (derivedKey !== null) return true;1143 if (!chrome.storage.session) return false;1144 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1145 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1146 if (!entry?.keyHex) return false;11471148 const key = unhex(entry.keyHex);1149 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1150 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1151 const slot = await loadSlot(slotKey);1152 if (!slot) { await clearSessionKey(); return false; }11531154 const result = await tryDecryptSlotResult(slot, key, aadTag);1155 if (result.status !== "ok") { await clearSessionKey(); return false; }11561157 derivedKey = key;1158 vaultMode = entry.mode;1159 entries = result.payload.entries;1160 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1161 buildDomainIndexSync(entries);1162 await loadSettings();1163 touchAutoLock();11641165 if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1166 try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { }1167 }11681169 return true;1170}11711172export function lock(): void {1173 void clearSessionKey();1174 if (derivedKey) {1175 zeroKey(derivedKey);1176 derivedKey = null;1177 }1178 if (rootKey) {1179 rootKey.fill(0);1180 rootKey = null;1181 }11821183 entries = [];1184 domainIndex = new Map();1185 vaultMode = "real";1186 duressConfigured = false;1187 clearAutoLock();1188}11891190export function isUnlocked(): boolean {1191 return derivedKey !== null;1192}1193119411951196export async function unlockWithKey(1197 masterKeyHex: string,1198): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1199 try {1200 const masterKeyBytes = unhex(masterKeyHex);1201 const stored = await loadSlot(SLOT_A);1202 if (!stored) return { ok: false, error: "invalid_key" };1203 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1204 if (!payload) return { ok: false, error: "invalid_key" };12051206 derivedKey = masterKeyBytes;1207 vaultMode = "real";1208 entries = payload.entries;1209 duressConfigured = payload.duressConfigured;1210 buildDomainIndexSync(entries);1211 await loadSettings();1212 touchAutoLock();1213 return { ok: true, entries };1214 } catch {1215 return { ok: false, error: "invalid_key" };1216 }1217}12181219122012211222122312241225122612271228122912301231123212331234123512361237export async function unlockFromCodex(params: {1238 slotA: string; 1239 slotB: string | null; 1240 timestamp: number;1241 masterPassphrase: string;1242}): Promise<1243 | { ok: true; entryCount: number }1244 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1245> {1246 let parsedSlotA: StoredVault;1247 let parsedSlotB: StoredVault | null = null;1248 try {1249 const rawA = JSON.parse(params.slotA) as unknown;1250 if (!hasValidVaultSchema(rawA)) {1251 return { ok: false, reason: "invalid_slot_format" };1252 }1253 parsedSlotA = rawA;1254 if (params.slotB !== null) {1255 const rawB = JSON.parse(params.slotB) as unknown;1256 if (!hasValidVaultSchema(rawB)) {1257 return { ok: false, reason: "invalid_slot_format" };1258 }1259 parsedSlotB = rawB;1260 }1261 } catch {1262 return { ok: false, reason: "invalid_slot_format" };1263 }12641265 1266 1267 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1268 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1269 if (!trialPayload) {1270 zeroKey(trialKey);1271 return { ok: false, reason: "wrong_passphrase" };1272 }1273 1274 1275 zeroKey(trialKey);12761277 1278 1279 await takeVaultSnapshot("restore");12801281 await writeSlot(SLOT_A, parsedSlotA);1282 if (parsedSlotB) {1283 await writeSlot(SLOT_B, parsedSlotB);1284 } else {1285 1286 1287 1288 await writeSlot(1289 SLOT_B,1290 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1291 );1292 }1293 await chrome.storage.local.set({1294 peach_last_modified_at: String(params.timestamp),1295 });12961297 1298 1299 const ok = await unlock(params.masterPassphrase);1300 if (!ok) {1301 1302 1303 1304 return { ok: false, reason: "wrong_passphrase" };1305 }1306 return { ok: true, entryCount: getEntries().length };1307}1308130913101311export function getDerivedKeyHex(): string {1312 if (!derivedKey) throw new Error("vault_not_unlocked");1313 return hex(derivedKey);1314}13151316export function getEntries(): VaultEntry[] {1317 if (!derivedKey) throw new Error("vault_locked");1318 return entries.filter((entry) => !entry.deletedAt);1319}13201321const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;13221323export function getIdentities(): SavedIdentity[] {1324 if (!derivedKey) throw new Error("vault_locked");1325 const active = entries.filter((e) => !e.deletedAt && e.type === "login");13261327 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1328 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();13291330 for (const entry of active) {1331 const value = entry.username?.trim();1332 if (!value) continue;1333 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;1334 const existing = map.get(value);1335 const domain = extractDomain(entry.url ?? "") ?? "";1336 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;1337 if (!existing || modifiedAt > existing.lastUsed) {1338 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1339 } else {1340 map.set(value, { ...existing, count: existing.count + 1 });1341 }1342 }13431344 const toIdentity = (type: "email" | "username") =>1345 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1346 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1347 });13481349 return [1350 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1351 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1352 ];1353}13541355export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1356 if (!derivedKey) throw new Error("vault_locked");13571358 const now = Date.now();1359 const added: VaultEntry[] = [];1360 for (const draft of drafts) {1361 try {1362 const sanitized = sanitizeEntryDraft(draft);1363 const nextEntry: VaultEntry = {1364 ...sanitized,1365 id: crypto.randomUUID(),1366 createdAt: now,1367 modifiedAt: now,1368 };1369 entries.push(nextEntry);1370 added.push(nextEntry);1371 } catch {1372 1373 }1374 }13751376 if (added.length > 0) {1377 await persistEntries();1378 }13791380 return added;1381}138213831384138513861387138813891390export async function restoreEntries(1391 incoming: VaultEntry[],1392 mode: "replace" | "merge"1393): Promise<{ added: number; updated: number; skipped: number }> {1394 if (!derivedKey) throw new Error("vault_locked");13951396 1397 1398 1399 if (mode === "replace" && incoming.length === 0) {1400 throw new Error("refuse to replace with empty entry set");1401 }14021403 let added = 0;1404 let updated = 0;1405 let skipped = 0;14061407 if (mode === "replace") {1408 entries = [];1409 for (const entry of incoming) {1410 const normalized = sanitizeRestoredEntry(entry);1411 if (!normalized) {1412 skipped += 1;1413 continue;1414 }1415 entries.push(normalized);1416 added += 1;1417 }1418 } else {1419 const byId = new Map<string, VaultEntry>();1420 for (const e of entries) byId.set(e.id, e);14211422 for (const entry of incoming) {1423 const normalized = sanitizeRestoredEntry(entry);1424 if (!normalized) {1425 skipped += 1;1426 continue;1427 }1428 const existing = byId.get(normalized.id);1429 if (!existing) {1430 entries.push(normalized);1431 added += 1;1432 } else if (normalized.modifiedAt > existing.modifiedAt) {1433 const idx = entries.findIndex((e) => e.id === existing.id);1434 if (idx !== -1) {1435 entries[idx] = normalized;1436 updated += 1;1437 }1438 } else {1439 skipped += 1;1440 }1441 }1442 }14431444 await persistEntries("import");1445 return { added, updated, skipped };1446}1447144814491450145114521453function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1454 if (!entry || typeof entry !== "object") return null;1455 const title = typeof entry.title === "string" ? entry.title.trim() : "";1456 if (!title) return null;1457 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1458 const now = Date.now();1459 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1460 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;14611462 1463 1464 1465 const migrated: VaultEntry = migrateLegacyPasskey(1466 {1467 ...entry,1468 id,1469 title,1470 type: entry.type ?? "login",1471 createdAt,1472 modifiedAt,1473 },1474 entry1475 );14761477 1478 1479 if (1480 migrated.type === "login" &&1481 !migrated.passkey &&1482 typeof migrated.password !== "string"1483 ) {1484 return null;1485 }1486 if (migrated.passkey && !migrated.passkey.credentialId) {1487 return null;1488 }14891490 return migrated;1491}149214931494149514961497export async function clearVaultEntries(1498 confirmation: "REPLACE_ALL"1499): Promise<void> {1500 if (confirmation !== "REPLACE_ALL") {1501 throw new Error("clearVaultEntries requires explicit confirmation");1502 }1503 if (!derivedKey) throw new Error("vault_locked");1504 for (const entry of entries) {1505 if (!entry.deletedAt) {1506 entry.deletedAt = Date.now();1507 entry.modifiedAt = Date.now();1508 }1509 }1510 await persistEntries();1511}15121513export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1514 if (!derivedKey) throw new Error("vault_locked");15151516 const sanitized = sanitizeEntryDraft(entry);1517 const now = Date.now();1518 const nextEntry: VaultEntry = {1519 ...sanitized,1520 id: crypto.randomUUID(),1521 createdAt: now,1522 modifiedAt: now,1523 1524 1525 passwordChangedAt: sanitized.password ? now : undefined,1526 };15271528 1529 1530 1531 const rollback = entries;1532 entries = [...entries, nextEntry];1533 try {1534 await persistEntries();1535 } catch (err) {1536 entries = rollback;1537 throw err;1538 }15391540 1541 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1542 checkBreach(nextEntry.password).then(async (count) => {1543 if (count > 0) {1544 const idx = entries.findIndex((e) => e.id === nextEntry.id);1545 if (idx !== -1) {1546 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1547 await persistEntries();1548 }1549 }1550 });1551 }15521553 return nextEntry;1554}15551556export async function updateEntry(1557 id: string,1558 patch: Partial<VaultEntry>1559): Promise<void> {1560 if (!derivedKey) throw new Error("vault_locked");15611562 const idx = entries.findIndex((entry) => entry.id === id);1563 if (idx === -1 || entries[idx].deletedAt) {1564 throw new Error("entry_not_found");1565 }15661567 const current = entries[idx];1568 const sanitizedPatch = sanitizeEntryPatch(patch);1569 1570 1571 1572 const passwordChanged =1573 "password" in patch &&1574 typeof sanitizedPatch.password === "string" &&1575 sanitizedPatch.password !== current.password;1576 entries[idx] = {1577 ...current,1578 ...sanitizedPatch,1579 modifiedAt: Date.now(),1580 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1581 };15821583 1584 1585 1586 1587 if (1588 entries[idx].type === "login" &&1589 !entries[idx].password &&1590 !entries[idx].passkey1591 ) {1592 entries[idx] = current;1593 throw new Error("password or passkey required");1594 }15951596 1597 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1598 entries[idx] = current;1599 throw new Error("card number is required for card entries");1600 }16011602 await persistEntries();16031604 1605 1606 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1607 entries[idx] = { ...entries[idx], breach: undefined };1608 await persistEntries();1609 const password = entries[idx].password;1610 if (password) {1611 checkBreach(password).then(async (count) => {1612 const i = entries.findIndex((e) => e.id === id);1613 if (i !== -1 && count > 0) {1614 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1615 await persistEntries();1616 }1617 });1618 }1619 }1620}16211622export async function deleteEntry(id: string): Promise<void> {1623 if (!derivedKey) throw new Error("vault_locked");16241625 const entry = entries.find((item) => item.id === id);1626 if (!entry || entry.deletedAt) {1627 throw new Error("entry_not_found");1628 }16291630 entry.deletedAt = Date.now();1631 entry.modifiedAt = Date.now();1632 await persistEntries();1633}16341635export function getDeletedEntries(): VaultEntry[] {1636 if (!derivedKey) return [];1637 return entries.filter((e) => !!e.deletedAt);1638}16391640export async function restoreEntry(id: string): Promise<void> {1641 if (!derivedKey) throw new Error("vault_locked");1642 const entry = entries.find((e) => e.id === id && e.deletedAt);1643 if (!entry) throw new Error("deleted entry not found");1644 delete entry.deletedAt;1645 entry.modifiedAt = Date.now();1646 await persistEntries();1647 buildDomainIndexSync(entries);1648}16491650export async function purgeEntry(id: string): Promise<void> {1651 if (!derivedKey) throw new Error("vault_locked");1652 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1653 if (idx === -1) throw new Error("deleted entry not found");1654 entries.splice(idx, 1);1655 await persistEntries();1656}165716581659export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1660 if (!derivedKey || retentionMs === 0) return 0;1661 const cutoff = Date.now() - retentionMs;1662 const before = entries.length;1663 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1664 const purged = before - entries.length;1665 if (purged > 0) await persistEntries();1666 return purged;1667}16681669export function queryDomain(domain: string): VaultEntry[] {1670 return domainIndex.get(domain) ?? [];1671}16721673export function getEntryById(id: string): VaultEntry | undefined {1674 return entries.find((entry) => entry.id === id && !entry.deletedAt);1675}16761677async function persistSlotA(1678 reason: VaultSnapshot["reason"] = "local_save",1679): Promise<StoredVault> {1680 if (!derivedKey) throw new Error("vault_locked");16811682 const slotA = await loadSlot(SLOT_A);1683 if (!slotA) throw new Error("missing slot A");16841685 1686 1687 await takeVaultSnapshot(reason);16881689 const plaintext = encodePayload(entries, duressConfigured);1690 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");16911692 1693 const nextSlotA: StoredVault = {1694 ...slotA,1695 nonce,1696 ciphertext,1697 tag,1698 1699 };1700 await writeSlot(SLOT_A, nextSlotA);17011702 1703 1704 1705 if (!duressConfigured) {1706 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1707 }1708 await stampLocalVaultMutation();17091710 return nextSlotA;1711}17121713async function persistSlotB(): Promise<void> {1714 if (!derivedKey) throw new Error("vault_locked");17151716 const slotB = await loadSlot(SLOT_B);1717 if (!slotB) throw new Error("missing slot B");17181719 1720 1721 const plaintext = encodePayload(entries, false);1722 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");17231724 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1725 await stampLocalVaultMutation();1726}17271728async function persistEntries(1729 reason: VaultSnapshot["reason"] = "local_save",1730): Promise<void> {1731 if (!derivedKey) throw new Error("vault_locked");17321733 if (vaultMode === "decoy") {1734 await persistSlotB();1735 } else {1736 await persistSlotA(reason);1737 }1738 buildDomainIndexSync(entries);1739}17401741function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1742 if (!Array.isArray(values)) return undefined;1743 const seen = new Set<string>();1744 const cleaned: string[] = [];1745 for (const value of values) {1746 const trimmed = typeof value === "string" ? value.trim() : "";1747 if (!trimmed) continue;1748 const key = trimmed.toLowerCase();1749 if (seen.has(key)) continue;1750 seen.add(key);1751 cleaned.push(trimmed);1752 }1753 return cleaned.length > 0 ? cleaned : undefined;1754}17551756function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1757 const title = entry.title.trim();1758 1759 1760 1761 let type: VaultEntry["type"] = entry.type ?? "login";1762 if (type === "passkey") type = "login";17631764 if (!title) throw new Error("title is required");1765 1766 1767 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1768 throw new Error("password or passkey required");1769 }1770 if (entry.passkey) {1771 const p = entry.passkey;1772 if (1773 !p.rpId ||1774 !p.credentialId ||1775 !p.publicKeyCose ||1776 !p.userHandle ||1777 !p.userName ||1778 (p.algorithm !== -7 && p.algorithm !== -8)1779 ) {1780 throw new Error("invalid passkey credential");1781 }1782 }1783 if (type === "card") {1784 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1785 throw new Error("cardholder name and card number are required");1786 }1787 }17881789 return {1790 type,1791 title,1792 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1793 password: type === "login" ? entry.password : undefined,1794 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1795 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1796 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1797 notes: entry.notes?.trim() || undefined,1798 breach: type === "login" ? entry.breach : undefined,1799 canary: type === "login" ? entry.canary : undefined,1800 tags: entry.tags,1801 passkey: entry.passkey,1802 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1803 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1804 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1805 expYear: type === "card" ? entry.expYear?.trim() : undefined,1806 cvc: type === "card" ? entry.cvc?.trim() : undefined,1807 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1808 network: type === "card" ? entry.network : undefined,1809 last4: type === "card" ? entry.last4?.trim() : undefined,1810 };1811}18121813function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1814 const nextPatch: Partial<VaultEntry> = {};18151816 if ("type" in patch) {1817 nextPatch.type = patch.type;1818 }18191820 if ("title" in patch) {1821 const title = patch.title?.trim() ?? "";1822 if (!title) throw new Error("title is required");1823 nextPatch.title = title;1824 }18251826 if ("url" in patch) {1827 nextPatch.url = patch.url?.trim() ?? "";1828 }18291830 if ("additionalUrls" in patch) {1831 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1832 const seen = new Set<string>();1833 const cleaned: string[] = [];1834 for (const value of incoming) {1835 const trimmed = typeof value === "string" ? value.trim() : "";1836 if (!trimmed) continue;1837 const key = trimmed.toLowerCase();1838 if (seen.has(key)) continue;1839 seen.add(key);1840 cleaned.push(trimmed);1841 }1842 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1843 }18441845 if ("username" in patch) {1846 nextPatch.username = patch.username?.trim() ?? "";1847 }18481849 if ("email" in patch) {1850 nextPatch.email = patch.email?.trim() || undefined;1851 }18521853 if ("password" in patch) {1854 const password = patch.password ?? "";1855 1856 nextPatch.password = password || undefined;1857 }18581859 if ("totp" in patch) {1860 nextPatch.totp = patch.totp?.trim() || undefined;1861 }18621863 if ("notes" in patch) {1864 nextPatch.notes = patch.notes?.trim() || undefined;1865 }18661867 if ("breach" in patch) {1868 nextPatch.breach = patch.breach;1869 }18701871 if ("canary" in patch) {1872 nextPatch.canary = patch.canary;1873 }18741875 if ("passkey" in patch) {1876 1877 1878 1879 nextPatch.passkey = patch.passkey;1880 }18811882 if ("tags" in patch) {1883 nextPatch.tags = patch.tags;1884 }18851886 if ("cardholderName" in patch) {1887 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1888 }1889 if ("cardNumber" in patch) {1890 const n = patch.cardNumber ?? "";1891 nextPatch.cardNumber = n || undefined;1892 }1893 if ("expMonth" in patch) {1894 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1895 }1896 if ("expYear" in patch) {1897 nextPatch.expYear = patch.expYear?.trim() || undefined;1898 }1899 if ("cvc" in patch) {1900 nextPatch.cvc = patch.cvc?.trim() || undefined;1901 }1902 if ("cardNickname" in patch) {1903 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1904 }1905 if ("network" in patch) {1906 nextPatch.network = patch.network;1907 }1908 if ("last4" in patch) {1909 nextPatch.last4 = patch.last4?.trim() || undefined;1910 }19111912 return nextPatch;1913}19141915function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1916 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1917 const modifiedAt =1918 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;19191920 const base: VaultEntry = {1921 id: entry.id,1922 type: "type" in entry ? entry.type : "login",1923 title: entry.title,1924 username: entry.username,1925 password: entry.password,1926 url: entry.url,1927 totp: entry.totp,1928 notes: entry.notes,1929 breach: entry.breach,1930 canary: "canary" in entry ? entry.canary : undefined,1931 passkey: "passkey" in entry ? entry.passkey : undefined,1932 createdAt,1933 modifiedAt,1934 deletedAt: entry.deletedAt,1935 tags: "tags" in entry ? entry.tags : undefined,1936 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1937 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1938 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1939 expYear: "expYear" in entry ? entry.expYear : undefined,1940 cvc: "cvc" in entry ? entry.cvc : undefined,1941 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1942 network: "network" in entry ? entry.network : undefined,1943 last4: "last4" in entry ? entry.last4 : undefined,1944 };19451946 return migrateLegacyPasskey(base, entry);1947}194819491950195119521953195419551956function migrateLegacyPasskey(1957 base: VaultEntry,1958 raw: VaultEntry | LegacyVaultEntry1959): VaultEntry {1960 if (base.passkey) return base;1961 const flat = raw as Partial<VaultEntry>;1962 const isPasskeyType = base.type === "passkey";1963 const hasFlatPasskeyFields =1964 typeof flat.rpId === "string" &&1965 typeof flat.credentialId === "string" &&1966 typeof flat.publicKeyCose === "string" &&1967 typeof flat.userHandle === "string" &&1968 typeof flat.userName === "string";1969 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1970 if (!hasFlatPasskeyFields) return base;19711972 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1973 base.passkey = {1974 rpId: flat.rpId!,1975 rpName: flat.rpName ?? flat.rpId!,1976 credentialId: flat.credentialId!,1977 publicKeyCose: flat.publicKeyCose!,1978 privateKeyJwk: flat.privateKeyJwk,1979 userHandle: flat.userHandle!,1980 userName: flat.userName!,1981 userDisplayName: flat.userDisplayName ?? flat.userName!,1982 algorithm,1983 signCount: flat.signCount ?? 0,1984 aaguid: flat.aaguid ?? "",1985 backupEligible: flat.backupEligible === true,1986 backupState: flat.backupState === true,1987 lastUsedAt: flat.lastUsedAt,1988 browser: flat.browser,1989 transports: flat.transports,1990 createdAt: base.createdAt,1991 };1992 1993 1994 1995 if (isPasskeyType) base.type = "login";1996 if (!base.username) base.username = flat.userName;1997 if (!base.url) base.url = `https://${flat.rpId}`;1998 return base;1999}2000200120022003200420052006function entryDupeKey(e: VaultEntry): string {2007 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2008 2009 2010 2011 2012 if (e.passkey) {2013 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2014 }2015 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2016}201720182019export function countDuplicates(): number {2020 const best = new Map<string, VaultEntry>();2021 let dupeCount = 0;2022 for (const e of entries.filter((e) => !e.deletedAt)) {2023 const key = entryDupeKey(e);2024 const existing = best.get(key);2025 if (!existing) {2026 best.set(key, e);2027 } else {2028 dupeCount++;2029 2030 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2031 best.set(key, e);2032 }2033 }2034 }2035 return dupeCount;2036}203720382039204020412042export async function deduplicateEntries(): Promise<number> {2043 if (!derivedKey) throw new Error("vault_locked");2044 2045 2046 if (vaultMode === "decoy") {2047 throw new Error("deduplicate disabled in decoy mode");2048 }20492050 const best = new Map<string, VaultEntry>();2051 for (const e of entries.filter((en) => !en.deletedAt)) {2052 const key = entryDupeKey(e);2053 const existing = best.get(key);2054 if (!existing) {2055 best.set(key, e);2056 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2057 best.set(key, e);2058 }2059 }20602061 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2062 const now = Date.now();2063 let removed = 0;2064 entries = entries.map((e) => {2065 if (!e.deletedAt && !keepIds.has(e.id)) {2066 removed++;2067 return { ...e, deletedAt: now };2068 }2069 return e;2070 });20712072 if (removed > 0) await persistEntries();2073 return removed;2074}207520762077207820792080208120822083const HISTORY_KEY = "peach_vault_history";2084const HISTORY_MAX = 10;20852086208720882089209020912092export async function takeVaultSnapshot(2093 reason: VaultSnapshot["reason"],2094): Promise<void> {2095 try {2096 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2097 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2098 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];20992100 2101 2102 2103 if (!slotA) return;21042105 const vault_a = JSON.stringify(slotA);2106 const vault_b = JSON.stringify(slotB ?? slotA);21072108 2109 2110 2111 let entryCount: number | null = null;2112 if (derivedKey && vaultMode === "real") {2113 try {2114 entryCount = entries.filter((e) => !e.deletedAt).length;2115 } catch {2116 entryCount = null;2117 }2118 }21192120 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2121 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2122 ? existingRaw2123 : { snapshots: [] };21242125 2126 2127 2128 const latest = history.snapshots[0];2129 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2130 return;2131 }21322133 const snapshot: VaultSnapshot = {2134 id: crypto.randomUUID(),2135 savedAt: Date.now(),2136 reason,2137 vault_a,2138 vault_b,2139 entryCount,2140 };21412142 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2143 await chrome.storage.local.set({ [HISTORY_KEY]: history });2144 } catch (err) {2145 2146 2147 2148 2149 console.warn('[Peach] takeVaultSnapshot failed:', err);2150 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2151 try {2152 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2153 } catch {2154 2155 }2156 }2157 }2158}215921602161export async function clearVaultHistory(): Promise<void> {2162 try {2163 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2164 } catch {2165 2166 }2167}216821692170export async function getVaultHistory(): Promise<VaultSnapshot[]> {2171 const raw = await chrome.storage.local.get(HISTORY_KEY);2172 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2173 if (!history || !Array.isArray(history.snapshots)) return [];2174 return history.snapshots;2175}21762177217821792180218121822183export async function restoreVaultSnapshot(2184 snapshotId: string,2185): Promise<{ ok: true } | { ok: false; error: string }> {2186 const history = await getVaultHistory();2187 const snapshot = history.find((s) => s.id === snapshotId);2188 if (!snapshot) return { ok: false, error: "snapshot_not_found" };21892190 2191 2192 2193 await takeVaultSnapshot("restore");21942195 let restoredA: StoredVault;2196 let restoredB: StoredVault;2197 try {2198 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2199 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2200 } catch {2201 return { ok: false, error: "snapshot_corrupt" };2202 }22032204 await writeSlot(SLOT_A, restoredA);2205 await writeSlot(SLOT_B, restoredB);2206 await stampLocalVaultMutation();22072208 2209 2210 2211 lock();22122213 return { ok: true };2214}22152216221722182219222022212222export async function changePassword(2223 currentPassword: string,2224 newPassword: string2225): Promise<boolean> {2226 if (!derivedKey || vaultMode !== "real") return false;22272228 2229 const valid = await verifyMainPassword(currentPassword);2230 if (!valid) return false;22312232 const slotA = await loadSlot(SLOT_A);2233 if (!slotA) return false;2234 if (!rootKey) return false;22352236 2237 const newSalt = generateSalt();2238 const newKey = await deriveKey(newPassword, newSalt);22392240 2241 const plaintext = encodePayload(entries, duressConfigured);2242 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");22432244 2245 2246 2247 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);22482249 const finalSlotA: StoredVault = {2250 ...slotA,2251 salt: hex(newSalt),2252 nonce,2253 ciphertext,2254 tag,2255 ...pwRoot,2256 };22572258 2259 2260 2261 await takeVaultSnapshot("local_save");22622263 2264 2265 2266 2267 await writeSlot(SLOT_A, finalSlotA);22682269 2270 2271 2272 zeroKey(derivedKey);2273 derivedKey = newKey;22742275 2276 2277 2278 if (!duressConfigured) {2279 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2280 }2281 await stampLocalVaultMutation();22822283 return true;2284}2285