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 const raw = await chrome.storage.local.get("peach_last_modified_at");525 const previous = Number(526 (raw as Record<string, unknown>).peach_last_modified_at,527 );528 const next = Math.max(529 Date.now(),530 Number.isFinite(previous) ? previous + 1 : 0,531 );532 await chrome.storage.local.set({533 peach_last_modified_at: String(next),534 });535}536537538539540541542async function ensureMigrated(): Promise<void> {543 if (migrationRan) return;544 migrationRan = true;545546 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];547 const raw = await chrome.storage.local.get(keys);548 const slotAExists = raw[SLOT_A] !== undefined;549 const slotBExists = raw[SLOT_B] !== undefined;550 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;551 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;552553 554 if (!slotAExists && !legacyMain) return;555556 557 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;558559 let didMigrate = false;560 if (legacyMain && !slotAExists) {561 await writeSlot(SLOT_A, legacyMain);562 didMigrate = true;563 }564565 566 if (!slotBExists) {567 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;568 if (legacyDecoy) {569 await writeSlot(SLOT_B, legacyDecoy);570 didMigrate = true;571 } else if (target) {572 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));573 didMigrate = true;574 }575 }576577 const toRemove: string[] = [];578 if (legacyMain) toRemove.push(LEGACY_MAIN);579 if (legacyDecoy) toRemove.push(LEGACY_DECOY);580 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);581 if (toRemove.length > 0) {582 await chrome.storage.local.remove(toRemove);583 didMigrate = true;584 }585 if (didMigrate) {586 await stampLocalVaultMutation();587 }588}589590591592593594595596597598599600function padPlaintext(json: string): string {601 const encoded = new TextEncoder().encode(json);602 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);603 const padLen = target - encoded.length;604 return json + " ".repeat(padLen);605}606607608609610611612613614615616617const CSPRNG_CHUNK = 65_536;618619function fillRandom(buf: Uint8Array): Uint8Array {620 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {621 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);622 const chunk = new Uint8Array(end - offset);623 crypto.getRandomValues(chunk);624 buf.set(chunk, offset);625 }626 return buf;627}628629function generateNoiseBlob(ciphertextBytes: number): StoredVault {630 const salt = crypto.getRandomValues(new Uint8Array(16));631 const nonce = crypto.getRandomValues(new Uint8Array(12));632 const cipher = fillRandom(new Uint8Array(ciphertextBytes));633 const tag = crypto.getRandomValues(new Uint8Array(16));634 return {635 version: 1,636 salt: hex(salt),637 nonce: hex(nonce),638 ciphertext: hex(cipher),639 tag: hex(tag),640 };641}642643644645646647type VaultPayload = {648 entries: VaultEntry[];649 duressConfigured?: boolean;650};651652type DecryptedPayload = {653 entries: VaultEntry[];654 duressConfigured: boolean;655 656657658659660661662 migratedFromLegacy: boolean;663};664665function encodePayload(entries: VaultEntry[], duress: boolean): string {666 const envelope: VaultPayload = { entries, duressConfigured: duress };667 return padPlaintext(JSON.stringify(envelope));668}669670function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {671 if ("created" in raw || "updated" in raw) return true;672 const flat = raw as Partial<VaultEntry>;673 if (flat.type === "passkey") return true;674 675 if (flat.rpId !== undefined && flat.passkey === undefined) return true;676 return false;677}678679function decodePayload(plain: string): DecryptedPayload {680 const parsed = JSON.parse(plain) as681 | Array<VaultEntry | LegacyVaultEntry>682 | VaultPayload;683684 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];685 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);686687 const normalized = rawEntries.map(normalizeEntry);688 const { merged } = mergePasskeyOnlyIntoLogins(normalized);689690 if (Array.isArray(parsed)) {691 return {692 entries: normalized,693 duressConfigured: false,694 migratedFromLegacy: true, 695 };696 }697698 return {699 entries: normalized,700 duressConfigured: parsed.duressConfigured === true,701 migratedFromLegacy: flatMigrationNeeded || merged,702 };703}704705706707708709710711712713714715716717718719720721722723724725726727728function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {729 let merged = false;730 const now = Date.now();731732 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);733 const byRpId = new Map<string, VaultEntry[]>();734 for (const e of candidates) {735 const rpId = e.passkey!.rpId;736 const list = byRpId.get(rpId);737 if (list) list.push(e);738 else byRpId.set(rpId, [e]);739 }740741 for (const [rpId, group] of byRpId) {742 if (group.length === 0) continue;743 group.sort((a, b) => {744 const aT =745 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;746 const bT =747 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;748 return bT - aT;749 });750 const winner = group[0]!;751752 753 754 755 const loginTarget = entries.find((e) => {756 if (e.deletedAt || e.passkey || e.type !== "login") return false;757 if (e.url && extractDomain(e.url) === rpId) return true;758 for (const extra of e.additionalUrls ?? []) {759 if (extractDomain(extra) === rpId) return true;760 }761 return false;762 });763764 if (loginTarget) {765 loginTarget.passkey = winner.passkey;766 loginTarget.modifiedAt = now;767 for (const dup of group) {768 if (!dup.deletedAt) {769 dup.deletedAt = now;770 dup.modifiedAt = now;771 }772 }773 merged = true;774 } else if (group.length > 1) {775 for (let i = 1; i < group.length; i += 1) {776 const dup = group[i]!;777 if (!dup.deletedAt) {778 dup.deletedAt = now;779 dup.modifiedAt = now;780 }781 }782 merged = true;783 }784 }785786 return { merged };787}788789790791792793794795function hasValidVaultSchema(v: unknown): v is StoredVault {796 if (typeof v !== "object" || v === null) return false;797 const r = v as Record<string, unknown>;798 return (799 r.version === 1 &&800 typeof r.salt === "string" &&801 typeof r.nonce === "string" &&802 typeof r.ciphertext === "string" &&803 typeof r.tag === "string"804 );805}806807export type TryDecryptResult =808 | { status: "ok"; payload: DecryptedPayload }809 | { status: "wrong_key" }810 | { status: "corrupted"; reason: string };811812813814815816817export async function tryDecryptSlot(818 vault: StoredVault,819 key: Uint8Array,820 aadTag?: string,821): Promise<DecryptedPayload | null> {822 const res = await tryDecryptSlotResult(vault, key, aadTag);823 return res.status === "ok" ? res.payload : null;824}825826export async function tryDecryptSlotResult(827 vault: StoredVault,828 key: Uint8Array,829 aadTag?: string,830): Promise<TryDecryptResult> {831 if (!hasValidVaultSchema(vault)) {832 return { status: "corrupted", reason: "invalid_slot_schema" };833 }834 let plain: string;835 try {836 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);837 } catch {838 return { status: "wrong_key" };839 }840 try {841 const parsed = JSON.parse(plain) as unknown;842 if (typeof parsed !== "object" || parsed === null) {843 return { status: "corrupted", reason: "invalid_payload_shape" };844 }845 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {846 return { status: "corrupted", reason: "invalid_payload_shape" };847 }848 return { status: "ok", payload: decodePayload(plain) };849 } catch {850 return { status: "corrupted", reason: "invalid_payload_json" };851 }852}853854855856857858function buildDomainIndexSync(all: VaultEntry[]): void {859 domainIndex = new Map();860861 for (const entry of all) {862 if (entry.deletedAt) continue;863 if (entry.type === "note" || entry.type === "card") continue;864865 866 867 868 869 const domains = new Set<string>();870 const primary = extractDomain(entry.url ?? "");871 if (primary) domains.add(primary);872 for (const extra of entry.additionalUrls ?? []) {873 const d = extractDomain(extra);874 if (d) domains.add(d);875 }876 if (domains.size === 0) continue;877878 for (const domain of domains) {879 const bucket = domainIndex.get(domain) ?? [];880 bucket.push(entry);881 domainIndex.set(domain, bucket);882 }883 }884}885886export async function createVault(password: string): Promise<void> {887 await ensureMigrated();888889 const salt = generateSalt();890 derivedKey = await deriveKey(password, salt);891 entries = [];892 vaultMode = "real";893 duressConfigured = false;894 buildDomainIndexSync(entries);895896 897 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =898 await generateAndWrapRootKey();899 rootKey = unhex(rawRootKey);900901 const plaintext = encodePayload(entries, duressConfigured);902 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");903904 905 906 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);907908 const slotA: StoredVault = {909 version: 1,910 salt: hex(salt),911 nonce,912 ciphertext,913 tag,914 recovery_salt: recoverySalt,915 recovery_wrapped_root_key: wrappedRootKey,916 recovery_nonce: wrappedNonce,917 recovery_tag: wrappedTag,918 ...pwRoot,919 };920 await writeSlot(SLOT_A, slotA);921922 923 924 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));925 await stampLocalVaultMutation();926927 touchAutoLock();928}929930export async function hasVault(): Promise<boolean> {931 await ensureMigrated();932 return (await loadSlot(SLOT_A)) !== null;933}934935936937938939940export async function isDuressEnabled(): Promise<boolean> {941 return vaultMode === "real" && duressConfigured;942}943944export async function setupDuress(duressPassword: string): Promise<void> {945 if (!derivedKey || vaultMode !== "real") {946 throw new Error("vault must be unlocked in real mode");947 }948 await ensureMigrated();949950 951 952 953 const slotA = await loadSlot(SLOT_A);954 if (!slotA) throw new Error("missing slot A");955956 const decoySalt = generateSalt();957 const decoyKey = await deriveKey(duressPassword, decoySalt);958 try {959 960 961 const targetBytes = slotA.ciphertext.length / 2;962 let plaintext = encodePayload([], false);963 const encoded = new TextEncoder().encode(plaintext);964 if (encoded.length < targetBytes) {965 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);966 }967 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");968 await writeSlot(SLOT_B, {969 version: 1,970 salt: hex(decoySalt),971 nonce,972 ciphertext,973 tag,974 });975 } finally {976 zeroKey(decoyKey);977 }978979 980 981 duressConfigured = true;982 await persistSlotA();983}984985export async function disableDuress(): Promise<void> {986 if (!derivedKey || vaultMode !== "real") {987 throw new Error("vault must be unlocked in real mode");988 }989 await ensureMigrated();990991 992 993 994 const slotA = await loadSlot(SLOT_A);995 if (slotA) {996 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));997 }998999 duressConfigured = false;1000 await persistSlotA();1001}10021003100410051006100710081009export async function verifyMainPassword(password: string): Promise<boolean> {1010 await ensureMigrated();1011 const slotA = await loadSlot(SLOT_A);1012 if (!slotA) return false;1013 const key = await deriveKey(password, unhex(slotA.salt));1014 try {1015 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1016 } finally {1017 zeroKey(key);1018 }1019}10201021export async function unlock(password: string): Promise<boolean> {1022 await ensureMigrated();10231024 1025 1026 1027 1028 1029 const [slotA, slotB] = await Promise.all([1030 loadSlot(SLOT_A),1031 loadSlot(SLOT_B),1032 ]);10331034 1035 const [keyA, keyB] = await Promise.all([1036 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),1037 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),1038 ]);10391040 1041 1042 1043 const [resultA, resultB] = await Promise.all([1044 keyA && slotA1045 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1046 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1047 keyB && slotB1048 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1049 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1050 ]);1051 const payloadA = resultA.status === "ok" ? resultA.payload : null;1052 const payloadB = resultB.status === "ok" ? resultB.payload : null;10531054 if (payloadA && keyA) {1055 derivedKey = keyA;1056 void saveSessionKey(keyA, "real");1057 vaultMode = "real";1058 entries = payloadA.entries;1059 duressConfigured = payloadA.duressConfigured;1060 buildDomainIndexSync(entries);1061 await loadSettings();1062 touchAutoLock();1063 if (keyB) zeroKey(keyB);10641065 1066 if (slotA) {1067 const needsRecoverySetup =1068 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1069 if (!needsRecoverySetup) {1070 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1071 }1072 1073 1074 1075 1076 if (needsRecoverySetup || !rootKey) {1077 try {1078 await migrateToRecovery();1079 if (needsRecoverySetup) {1080 1081 1082 1083 await chrome.storage.local.set({ peach_recovery_pending: '1' });1084 }1085 } catch (migrationErr) {1086 console.warn('[Peach] recovery migration failed:', migrationErr);1087 }1088 }1089 }10901091 1092 1093 1094 if (payloadA.migratedFromLegacy) {1095 await persistSlotA();1096 }1097 return true;1098 }10991100 if (payloadB && keyB) {1101 derivedKey = keyB;1102 void saveSessionKey(keyB, "decoy");1103 vaultMode = "decoy";1104 entries = payloadB.entries;1105 duressConfigured = false;1106 buildDomainIndexSync(entries);1107 await loadSettings();1108 touchAutoLock();1109 if (keyA) zeroKey(keyA);1110 return true;1111 }11121113 if (keyA) zeroKey(keyA);1114 if (keyB) zeroKey(keyB);1115 1116 1117 if (resultA.status === "corrupted") {1118 lastUnlockFailureReason = resultA.reason;1119 } else if (resultB.status === "corrupted") {1120 lastUnlockFailureReason = resultB.reason;1121 }1122 return false;1123}11241125112611271128112911301131const SESSION_KEY_STORAGE = "peach_session_dk";11321133async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1134 if (!chrome.storage.session) return;1135 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1136}11371138async function clearSessionKey(): Promise<void> {1139 if (!chrome.storage.session) return;1140 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1141}11421143114411451146114711481149export async function restoreFromSession(): Promise<boolean> {1150 if (derivedKey !== null) return true;1151 if (!chrome.storage.session) return false;1152 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1153 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1154 if (!entry?.keyHex) return false;11551156 const key = unhex(entry.keyHex);1157 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1158 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1159 const slot = await loadSlot(slotKey);1160 if (!slot) { await clearSessionKey(); return false; }11611162 const result = await tryDecryptSlotResult(slot, key, aadTag);1163 if (result.status !== "ok") { await clearSessionKey(); return false; }11641165 derivedKey = key;1166 vaultMode = entry.mode;1167 entries = result.payload.entries;1168 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1169 buildDomainIndexSync(entries);1170 await loadSettings();1171 touchAutoLock();11721173 if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1174 try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { }1175 }11761177 return true;1178}11791180export function lock(): void {1181 void clearSessionKey();1182 if (derivedKey) {1183 zeroKey(derivedKey);1184 derivedKey = null;1185 }1186 if (rootKey) {1187 rootKey.fill(0);1188 rootKey = null;1189 }11901191 entries = [];1192 domainIndex = new Map();1193 vaultMode = "real";1194 duressConfigured = false;1195 clearAutoLock();1196}11971198export function isUnlocked(): boolean {1199 return derivedKey !== null;1200}1201120212031204export async function unlockWithKey(1205 masterKeyHex: string,1206): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1207 try {1208 const masterKeyBytes = unhex(masterKeyHex);1209 const stored = await loadSlot(SLOT_A);1210 if (!stored) return { ok: false, error: "invalid_key" };1211 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1212 if (!payload) return { ok: false, error: "invalid_key" };12131214 derivedKey = masterKeyBytes;1215 vaultMode = "real";1216 entries = payload.entries;1217 duressConfigured = payload.duressConfigured;1218 buildDomainIndexSync(entries);1219 await loadSettings();1220 touchAutoLock();1221 return { ok: true, entries };1222 } catch {1223 return { ok: false, error: "invalid_key" };1224 }1225}12261227122812291230123112321233123412351236123712381239124012411242124312441245export async function unlockFromCodex(params: {1246 slotA: string; 1247 slotB: string | null; 1248 timestamp: number;1249 masterPassphrase: string;1250}): Promise<1251 | { ok: true; entryCount: number }1252 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1253> {1254 let parsedSlotA: StoredVault;1255 let parsedSlotB: StoredVault | null = null;1256 try {1257 const rawA = JSON.parse(params.slotA) as unknown;1258 if (!hasValidVaultSchema(rawA)) {1259 return { ok: false, reason: "invalid_slot_format" };1260 }1261 parsedSlotA = rawA;1262 if (params.slotB !== null) {1263 const rawB = JSON.parse(params.slotB) as unknown;1264 if (!hasValidVaultSchema(rawB)) {1265 return { ok: false, reason: "invalid_slot_format" };1266 }1267 parsedSlotB = rawB;1268 }1269 } catch {1270 return { ok: false, reason: "invalid_slot_format" };1271 }12721273 1274 1275 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1276 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1277 if (!trialPayload) {1278 zeroKey(trialKey);1279 return { ok: false, reason: "wrong_passphrase" };1280 }1281 1282 1283 zeroKey(trialKey);12841285 1286 1287 await takeVaultSnapshot("restore");12881289 await writeSlot(SLOT_A, parsedSlotA);1290 if (parsedSlotB) {1291 await writeSlot(SLOT_B, parsedSlotB);1292 } else {1293 1294 1295 1296 await writeSlot(1297 SLOT_B,1298 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1299 );1300 }1301 await chrome.storage.local.set({1302 peach_last_modified_at: String(params.timestamp),1303 });13041305 1306 1307 const ok = await unlock(params.masterPassphrase);1308 if (!ok) {1309 1310 1311 1312 return { ok: false, reason: "wrong_passphrase" };1313 }1314 return { ok: true, entryCount: getEntries().length };1315}1316131713181319export function getDerivedKeyHex(): string {1320 if (!derivedKey) throw new Error("vault_not_unlocked");1321 return hex(derivedKey);1322}13231324export function getEntries(): VaultEntry[] {1325 if (!derivedKey) throw new Error("vault_locked");1326 return entries.filter((entry) => !entry.deletedAt);1327}13281329const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;13301331export function getIdentities(): SavedIdentity[] {1332 if (!derivedKey) throw new Error("vault_locked");1333 const active = entries.filter((e) => !e.deletedAt && e.type === "login");13341335 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1336 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();13371338 for (const entry of active) {1339 const value = entry.username?.trim();1340 if (!value) continue;1341 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;1342 const existing = map.get(value);1343 const domain = extractDomain(entry.url ?? "") ?? "";1344 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;1345 if (!existing || modifiedAt > existing.lastUsed) {1346 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1347 } else {1348 map.set(value, { ...existing, count: existing.count + 1 });1349 }1350 }13511352 const toIdentity = (type: "email" | "username") =>1353 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1354 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1355 });13561357 return [1358 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1359 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1360 ];1361}13621363export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1364 if (!derivedKey) throw new Error("vault_locked");13651366 const now = Date.now();1367 const added: VaultEntry[] = [];1368 for (const draft of drafts) {1369 try {1370 const sanitized = sanitizeEntryDraft(draft);1371 const nextEntry: VaultEntry = {1372 ...sanitized,1373 id: crypto.randomUUID(),1374 createdAt: now,1375 modifiedAt: now,1376 };1377 entries.push(nextEntry);1378 added.push(nextEntry);1379 } catch {1380 1381 }1382 }13831384 if (added.length > 0) {1385 await persistEntries();1386 }13871388 return added;1389}139013911392139313941395139613971398export async function restoreEntries(1399 incoming: VaultEntry[],1400 mode: "replace" | "merge"1401): Promise<{ added: number; updated: number; skipped: number }> {1402 if (!derivedKey) throw new Error("vault_locked");14031404 1405 1406 1407 if (mode === "replace" && incoming.length === 0) {1408 throw new Error("refuse to replace with empty entry set");1409 }14101411 let added = 0;1412 let updated = 0;1413 let skipped = 0;14141415 if (mode === "replace") {1416 entries = [];1417 for (const entry of incoming) {1418 const normalized = sanitizeRestoredEntry(entry);1419 if (!normalized) {1420 skipped += 1;1421 continue;1422 }1423 entries.push(normalized);1424 added += 1;1425 }1426 } else {1427 const byId = new Map<string, VaultEntry>();1428 for (const e of entries) byId.set(e.id, e);14291430 for (const entry of incoming) {1431 const normalized = sanitizeRestoredEntry(entry);1432 if (!normalized) {1433 skipped += 1;1434 continue;1435 }1436 const existing = byId.get(normalized.id);1437 if (!existing) {1438 entries.push(normalized);1439 added += 1;1440 } else if (normalized.modifiedAt > existing.modifiedAt) {1441 const idx = entries.findIndex((e) => e.id === existing.id);1442 if (idx !== -1) {1443 entries[idx] = normalized;1444 updated += 1;1445 }1446 } else {1447 skipped += 1;1448 }1449 }1450 }14511452 await persistEntries("import");1453 return { added, updated, skipped };1454}1455145614571458145914601461function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1462 if (!entry || typeof entry !== "object") return null;1463 const title = typeof entry.title === "string" ? entry.title.trim() : "";1464 if (!title) return null;1465 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1466 const now = Date.now();1467 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1468 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;14691470 1471 1472 1473 const migrated: VaultEntry = migrateLegacyPasskey(1474 {1475 ...entry,1476 id,1477 title,1478 type: entry.type ?? "login",1479 createdAt,1480 modifiedAt,1481 },1482 entry1483 );14841485 1486 1487 if (1488 migrated.type === "login" &&1489 !migrated.passkey &&1490 typeof migrated.password !== "string"1491 ) {1492 return null;1493 }1494 if (migrated.passkey && !migrated.passkey.credentialId) {1495 return null;1496 }14971498 return migrated;1499}150015011502150315041505export async function clearVaultEntries(1506 confirmation: "REPLACE_ALL"1507): Promise<void> {1508 if (confirmation !== "REPLACE_ALL") {1509 throw new Error("clearVaultEntries requires explicit confirmation");1510 }1511 if (!derivedKey) throw new Error("vault_locked");1512 for (const entry of entries) {1513 if (!entry.deletedAt) {1514 entry.deletedAt = Date.now();1515 entry.modifiedAt = Date.now();1516 }1517 }1518 await persistEntries();1519}15201521export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1522 if (!derivedKey) throw new Error("vault_locked");15231524 const sanitized = sanitizeEntryDraft(entry);1525 const now = Date.now();1526 const nextEntry: VaultEntry = {1527 ...sanitized,1528 id: crypto.randomUUID(),1529 createdAt: now,1530 modifiedAt: now,1531 1532 1533 passwordChangedAt: sanitized.password ? now : undefined,1534 };15351536 1537 1538 1539 const rollback = entries;1540 entries = [...entries, nextEntry];1541 try {1542 await persistEntries();1543 } catch (err) {1544 entries = rollback;1545 throw err;1546 }15471548 1549 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1550 checkBreach(nextEntry.password).then(async (count) => {1551 if (count > 0) {1552 const idx = entries.findIndex((e) => e.id === nextEntry.id);1553 if (idx !== -1) {1554 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1555 await persistEntries();1556 }1557 }1558 });1559 }15601561 return nextEntry;1562}15631564export async function updateEntry(1565 id: string,1566 patch: Partial<VaultEntry>1567): Promise<void> {1568 if (!derivedKey) throw new Error("vault_locked");15691570 const idx = entries.findIndex((entry) => entry.id === id);1571 if (idx === -1 || entries[idx].deletedAt) {1572 throw new Error("entry_not_found");1573 }15741575 const current = entries[idx];1576 const sanitizedPatch = sanitizeEntryPatch(patch);1577 1578 1579 1580 const passwordChanged =1581 "password" in patch &&1582 typeof sanitizedPatch.password === "string" &&1583 sanitizedPatch.password !== current.password;1584 entries[idx] = {1585 ...current,1586 ...sanitizedPatch,1587 modifiedAt: Date.now(),1588 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1589 };15901591 1592 1593 1594 1595 if (1596 entries[idx].type === "login" &&1597 !entries[idx].password &&1598 !entries[idx].passkey1599 ) {1600 entries[idx] = current;1601 throw new Error("password or passkey required");1602 }16031604 1605 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1606 entries[idx] = current;1607 throw new Error("card number is required for card entries");1608 }16091610 await persistEntries();16111612 1613 1614 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1615 entries[idx] = { ...entries[idx], breach: undefined };1616 await persistEntries();1617 const password = entries[idx].password;1618 if (password) {1619 checkBreach(password).then(async (count) => {1620 const i = entries.findIndex((e) => e.id === id);1621 if (i !== -1 && count > 0) {1622 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1623 await persistEntries();1624 }1625 });1626 }1627 }1628}16291630export async function deleteEntry(id: string): Promise<void> {1631 if (!derivedKey) throw new Error("vault_locked");16321633 const entry = entries.find((item) => item.id === id);1634 if (!entry || entry.deletedAt) {1635 throw new Error("entry_not_found");1636 }16371638 entry.deletedAt = Date.now();1639 entry.modifiedAt = Date.now();1640 await persistEntries();1641}16421643export function getDeletedEntries(): VaultEntry[] {1644 if (!derivedKey) return [];1645 return entries.filter((e) => !!e.deletedAt);1646}16471648export async function restoreEntry(id: string): Promise<void> {1649 if (!derivedKey) throw new Error("vault_locked");1650 const entry = entries.find((e) => e.id === id && e.deletedAt);1651 if (!entry) throw new Error("deleted entry not found");1652 delete entry.deletedAt;1653 entry.modifiedAt = Date.now();1654 await persistEntries();1655 buildDomainIndexSync(entries);1656}16571658export async function purgeEntry(id: string): Promise<void> {1659 if (!derivedKey) throw new Error("vault_locked");1660 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1661 if (idx === -1) throw new Error("deleted entry not found");1662 entries.splice(idx, 1);1663 await persistEntries();1664}166516661667export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1668 if (!derivedKey || retentionMs === 0) return 0;1669 const cutoff = Date.now() - retentionMs;1670 const before = entries.length;1671 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1672 const purged = before - entries.length;1673 if (purged > 0) await persistEntries();1674 return purged;1675}16761677export function queryDomain(domain: string): VaultEntry[] {1678 return domainIndex.get(domain) ?? [];1679}16801681export function getEntryById(id: string): VaultEntry | undefined {1682 return entries.find((entry) => entry.id === id && !entry.deletedAt);1683}16841685async function persistSlotA(1686 reason: VaultSnapshot["reason"] = "local_save",1687): Promise<StoredVault> {1688 if (!derivedKey) throw new Error("vault_locked");16891690 const slotA = await loadSlot(SLOT_A);1691 if (!slotA) throw new Error("missing slot A");16921693 1694 1695 await takeVaultSnapshot(reason);16961697 const plaintext = encodePayload(entries, duressConfigured);1698 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");16991700 1701 const nextSlotA: StoredVault = {1702 ...slotA,1703 nonce,1704 ciphertext,1705 tag,1706 1707 };1708 await writeSlot(SLOT_A, nextSlotA);17091710 1711 1712 1713 if (!duressConfigured) {1714 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1715 }1716 await stampLocalVaultMutation();17171718 return nextSlotA;1719}17201721async function persistSlotB(): Promise<void> {1722 if (!derivedKey) throw new Error("vault_locked");17231724 const slotB = await loadSlot(SLOT_B);1725 if (!slotB) throw new Error("missing slot B");17261727 1728 1729 const plaintext = encodePayload(entries, false);1730 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");17311732 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1733 await stampLocalVaultMutation();1734}17351736async function persistEntries(1737 reason: VaultSnapshot["reason"] = "local_save",1738): Promise<void> {1739 if (!derivedKey) throw new Error("vault_locked");17401741 if (vaultMode === "decoy") {1742 await persistSlotB();1743 } else {1744 await persistSlotA(reason);1745 }1746 buildDomainIndexSync(entries);1747}17481749function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1750 if (!Array.isArray(values)) return undefined;1751 const seen = new Set<string>();1752 const cleaned: string[] = [];1753 for (const value of values) {1754 const trimmed = typeof value === "string" ? value.trim() : "";1755 if (!trimmed) continue;1756 const key = trimmed.toLowerCase();1757 if (seen.has(key)) continue;1758 seen.add(key);1759 cleaned.push(trimmed);1760 }1761 return cleaned.length > 0 ? cleaned : undefined;1762}17631764function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1765 const title = entry.title.trim();1766 1767 1768 1769 let type: VaultEntry["type"] = entry.type ?? "login";1770 if (type === "passkey") type = "login";17711772 if (!title) throw new Error("title is required");1773 1774 1775 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1776 throw new Error("password or passkey required");1777 }1778 if (entry.passkey) {1779 const p = entry.passkey;1780 if (1781 !p.rpId ||1782 !p.credentialId ||1783 !p.publicKeyCose ||1784 !p.userHandle ||1785 !p.userName ||1786 (p.algorithm !== -7 && p.algorithm !== -8)1787 ) {1788 throw new Error("invalid passkey credential");1789 }1790 }1791 if (type === "card") {1792 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1793 throw new Error("cardholder name and card number are required");1794 }1795 }17961797 return {1798 type,1799 title,1800 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1801 password: type === "login" ? entry.password : undefined,1802 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1803 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1804 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1805 notes: entry.notes?.trim() || undefined,1806 breach: type === "login" ? entry.breach : undefined,1807 canary: type === "login" ? entry.canary : undefined,1808 tags: entry.tags,1809 passkey: entry.passkey,1810 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1811 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1812 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1813 expYear: type === "card" ? entry.expYear?.trim() : undefined,1814 cvc: type === "card" ? entry.cvc?.trim() : undefined,1815 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1816 network: type === "card" ? entry.network : undefined,1817 last4: type === "card" ? entry.last4?.trim() : undefined,1818 };1819}18201821function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1822 const nextPatch: Partial<VaultEntry> = {};18231824 if ("type" in patch) {1825 nextPatch.type = patch.type;1826 }18271828 if ("title" in patch) {1829 const title = patch.title?.trim() ?? "";1830 if (!title) throw new Error("title is required");1831 nextPatch.title = title;1832 }18331834 if ("url" in patch) {1835 nextPatch.url = patch.url?.trim() ?? "";1836 }18371838 if ("additionalUrls" in patch) {1839 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1840 const seen = new Set<string>();1841 const cleaned: string[] = [];1842 for (const value of incoming) {1843 const trimmed = typeof value === "string" ? value.trim() : "";1844 if (!trimmed) continue;1845 const key = trimmed.toLowerCase();1846 if (seen.has(key)) continue;1847 seen.add(key);1848 cleaned.push(trimmed);1849 }1850 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1851 }18521853 if ("username" in patch) {1854 nextPatch.username = patch.username?.trim() ?? "";1855 }18561857 if ("email" in patch) {1858 nextPatch.email = patch.email?.trim() || undefined;1859 }18601861 if ("password" in patch) {1862 const password = patch.password ?? "";1863 1864 nextPatch.password = password || undefined;1865 }18661867 if ("totp" in patch) {1868 nextPatch.totp = patch.totp?.trim() || undefined;1869 }18701871 if ("notes" in patch) {1872 nextPatch.notes = patch.notes?.trim() || undefined;1873 }18741875 if ("breach" in patch) {1876 nextPatch.breach = patch.breach;1877 }18781879 if ("canary" in patch) {1880 nextPatch.canary = patch.canary;1881 }18821883 if ("passkey" in patch) {1884 1885 1886 1887 nextPatch.passkey = patch.passkey;1888 }18891890 if ("tags" in patch) {1891 nextPatch.tags = patch.tags;1892 }18931894 if ("cardholderName" in patch) {1895 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1896 }1897 if ("cardNumber" in patch) {1898 const n = patch.cardNumber ?? "";1899 nextPatch.cardNumber = n || undefined;1900 }1901 if ("expMonth" in patch) {1902 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1903 }1904 if ("expYear" in patch) {1905 nextPatch.expYear = patch.expYear?.trim() || undefined;1906 }1907 if ("cvc" in patch) {1908 nextPatch.cvc = patch.cvc?.trim() || undefined;1909 }1910 if ("cardNickname" in patch) {1911 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1912 }1913 if ("network" in patch) {1914 nextPatch.network = patch.network;1915 }1916 if ("last4" in patch) {1917 nextPatch.last4 = patch.last4?.trim() || undefined;1918 }19191920 return nextPatch;1921}19221923function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1924 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1925 const modifiedAt =1926 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;19271928 const base: VaultEntry = {1929 id: entry.id,1930 type: "type" in entry ? entry.type : "login",1931 title: entry.title,1932 username: entry.username,1933 password: entry.password,1934 url: entry.url,1935 totp: entry.totp,1936 notes: entry.notes,1937 breach: entry.breach,1938 canary: "canary" in entry ? entry.canary : undefined,1939 passkey: "passkey" in entry ? entry.passkey : undefined,1940 createdAt,1941 modifiedAt,1942 deletedAt: entry.deletedAt,1943 tags: "tags" in entry ? entry.tags : undefined,1944 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1945 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1946 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1947 expYear: "expYear" in entry ? entry.expYear : undefined,1948 cvc: "cvc" in entry ? entry.cvc : undefined,1949 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1950 network: "network" in entry ? entry.network : undefined,1951 last4: "last4" in entry ? entry.last4 : undefined,1952 };19531954 return migrateLegacyPasskey(base, entry);1955}195619571958195919601961196219631964function migrateLegacyPasskey(1965 base: VaultEntry,1966 raw: VaultEntry | LegacyVaultEntry1967): VaultEntry {1968 if (base.passkey) return base;1969 const flat = raw as Partial<VaultEntry>;1970 const isPasskeyType = base.type === "passkey";1971 const hasFlatPasskeyFields =1972 typeof flat.rpId === "string" &&1973 typeof flat.credentialId === "string" &&1974 typeof flat.publicKeyCose === "string" &&1975 typeof flat.userHandle === "string" &&1976 typeof flat.userName === "string";1977 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1978 if (!hasFlatPasskeyFields) return base;19791980 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1981 base.passkey = {1982 rpId: flat.rpId!,1983 rpName: flat.rpName ?? flat.rpId!,1984 credentialId: flat.credentialId!,1985 publicKeyCose: flat.publicKeyCose!,1986 privateKeyJwk: flat.privateKeyJwk,1987 userHandle: flat.userHandle!,1988 userName: flat.userName!,1989 userDisplayName: flat.userDisplayName ?? flat.userName!,1990 algorithm,1991 signCount: flat.signCount ?? 0,1992 aaguid: flat.aaguid ?? "",1993 backupEligible: flat.backupEligible === true,1994 backupState: flat.backupState === true,1995 lastUsedAt: flat.lastUsedAt,1996 browser: flat.browser,1997 transports: flat.transports,1998 createdAt: base.createdAt,1999 };2000 2001 2002 2003 if (isPasskeyType) base.type = "login";2004 if (!base.username) base.username = flat.userName;2005 if (!base.url) base.url = `https://${flat.rpId}`;2006 return base;2007}2008200920102011201220132014function entryDupeKey(e: VaultEntry): string {2015 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2016 2017 2018 2019 2020 if (e.passkey) {2021 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2022 }2023 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2024}202520262027export function countDuplicates(): number {2028 const best = new Map<string, VaultEntry>();2029 let dupeCount = 0;2030 for (const e of entries.filter((e) => !e.deletedAt)) {2031 const key = entryDupeKey(e);2032 const existing = best.get(key);2033 if (!existing) {2034 best.set(key, e);2035 } else {2036 dupeCount++;2037 2038 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2039 best.set(key, e);2040 }2041 }2042 }2043 return dupeCount;2044}204520462047204820492050export async function deduplicateEntries(): Promise<number> {2051 if (!derivedKey) throw new Error("vault_locked");2052 2053 2054 if (vaultMode === "decoy") {2055 throw new Error("deduplicate disabled in decoy mode");2056 }20572058 const best = new Map<string, VaultEntry>();2059 for (const e of entries.filter((en) => !en.deletedAt)) {2060 const key = entryDupeKey(e);2061 const existing = best.get(key);2062 if (!existing) {2063 best.set(key, e);2064 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2065 best.set(key, e);2066 }2067 }20682069 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2070 const now = Date.now();2071 let removed = 0;2072 entries = entries.map((e) => {2073 if (!e.deletedAt && !keepIds.has(e.id)) {2074 removed++;2075 return { ...e, deletedAt: now };2076 }2077 return e;2078 });20792080 if (removed > 0) await persistEntries();2081 return removed;2082}208320842085208620872088208920902091const HISTORY_KEY = "peach_vault_history";2092const HISTORY_MAX = 10;20932094209520962097209820992100export async function takeVaultSnapshot(2101 reason: VaultSnapshot["reason"],2102): Promise<void> {2103 try {2104 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2105 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2106 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];21072108 2109 2110 2111 if (!slotA) return;21122113 const vault_a = JSON.stringify(slotA);2114 const vault_b = JSON.stringify(slotB ?? slotA);21152116 2117 2118 2119 let entryCount: number | null = null;2120 if (derivedKey && vaultMode === "real") {2121 try {2122 entryCount = entries.filter((e) => !e.deletedAt).length;2123 } catch {2124 entryCount = null;2125 }2126 }21272128 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2129 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2130 ? existingRaw2131 : { snapshots: [] };21322133 2134 2135 2136 const latest = history.snapshots[0];2137 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2138 return;2139 }21402141 const snapshot: VaultSnapshot = {2142 id: crypto.randomUUID(),2143 savedAt: Date.now(),2144 reason,2145 vault_a,2146 vault_b,2147 entryCount,2148 };21492150 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2151 await chrome.storage.local.set({ [HISTORY_KEY]: history });2152 } catch (err) {2153 2154 2155 2156 2157 console.warn('[Peach] takeVaultSnapshot failed:', err);2158 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2159 try {2160 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2161 } catch {2162 2163 }2164 }2165 }2166}216721682169export async function clearVaultHistory(): Promise<void> {2170 try {2171 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2172 } catch {2173 2174 }2175}217621772178export async function getVaultHistory(): Promise<VaultSnapshot[]> {2179 const raw = await chrome.storage.local.get(HISTORY_KEY);2180 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2181 if (!history || !Array.isArray(history.snapshots)) return [];2182 return history.snapshots;2183}21842185218621872188218921902191export async function restoreVaultSnapshot(2192 snapshotId: string,2193): Promise<{ ok: true } | { ok: false; error: string }> {2194 const history = await getVaultHistory();2195 const snapshot = history.find((s) => s.id === snapshotId);2196 if (!snapshot) return { ok: false, error: "snapshot_not_found" };21972198 2199 2200 2201 await takeVaultSnapshot("restore");22022203 let restoredA: StoredVault;2204 let restoredB: StoredVault;2205 try {2206 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2207 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2208 } catch {2209 return { ok: false, error: "snapshot_corrupt" };2210 }22112212 await writeSlot(SLOT_A, restoredA);2213 await writeSlot(SLOT_B, restoredB);2214 await stampLocalVaultMutation();22152216 2217 2218 2219 lock();22202221 return { ok: true };2222}22232224222522262227222822292230export async function changePassword(2231 currentPassword: string,2232 newPassword: string2233): Promise<boolean> {2234 if (!derivedKey || vaultMode !== "real") return false;22352236 2237 const valid = await verifyMainPassword(currentPassword);2238 if (!valid) return false;22392240 const slotA = await loadSlot(SLOT_A);2241 if (!slotA) return false;2242 if (!rootKey) return false;22432244 2245 const newSalt = generateSalt();2246 const newKey = await deriveKey(newPassword, newSalt);22472248 2249 const plaintext = encodePayload(entries, duressConfigured);2250 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");22512252 2253 2254 2255 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);22562257 const finalSlotA: StoredVault = {2258 ...slotA,2259 salt: hex(newSalt),2260 nonce,2261 ciphertext,2262 tag,2263 ...pwRoot,2264 };22652266 2267 2268 2269 await takeVaultSnapshot("local_save");22702271 2272 2273 2274 2275 await writeSlot(SLOT_A, finalSlotA);22762277 2278 2279 2280 zeroKey(derivedKey);2281 derivedKey = newKey;22822283 2284 2285 2286 if (!duressConfigured) {2287 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2288 }2289 await stampLocalVaultMutation();22902291 return true;2292}