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 domain = extractDomain(entry.url ?? "") ?? "";1340 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;13411342 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1343 const existing = map.get(v);1344 if (!existing || modifiedAt > existing.lastUsed) {1345 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1346 } else {1347 map.set(v, { ...existing, count: existing.count + 1 });1348 }1349 }13501351 const uVal = entry.username?.trim();1352 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);13531354 1355 1356 const eVal = entry.email?.trim();1357 if (eVal) tally(emailMap, eVal);1358 }13591360 const toIdentity = (type: "email" | "username") =>1361 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1362 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1363 });13641365 return [1366 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1367 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1368 ];1369}13701371export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1372 if (!derivedKey) throw new Error("vault_locked");13731374 const now = Date.now();1375 const added: VaultEntry[] = [];1376 for (const draft of drafts) {1377 try {1378 const sanitized = sanitizeEntryDraft(draft);1379 const nextEntry: VaultEntry = {1380 ...sanitized,1381 id: crypto.randomUUID(),1382 createdAt: now,1383 modifiedAt: now,1384 };1385 entries.push(nextEntry);1386 added.push(nextEntry);1387 } catch {1388 1389 }1390 }13911392 if (added.length > 0) {1393 await persistEntries();1394 }13951396 return added;1397}139813991400140114021403140414051406export async function restoreEntries(1407 incoming: VaultEntry[],1408 mode: "replace" | "merge"1409): Promise<{ added: number; updated: number; skipped: number }> {1410 if (!derivedKey) throw new Error("vault_locked");14111412 1413 1414 1415 if (mode === "replace" && incoming.length === 0) {1416 throw new Error("refuse to replace with empty entry set");1417 }14181419 let added = 0;1420 let updated = 0;1421 let skipped = 0;14221423 if (mode === "replace") {1424 entries = [];1425 for (const entry of incoming) {1426 const normalized = sanitizeRestoredEntry(entry);1427 if (!normalized) {1428 skipped += 1;1429 continue;1430 }1431 entries.push(normalized);1432 added += 1;1433 }1434 } else {1435 const byId = new Map<string, VaultEntry>();1436 for (const e of entries) byId.set(e.id, e);14371438 for (const entry of incoming) {1439 const normalized = sanitizeRestoredEntry(entry);1440 if (!normalized) {1441 skipped += 1;1442 continue;1443 }1444 const existing = byId.get(normalized.id);1445 if (!existing) {1446 entries.push(normalized);1447 added += 1;1448 } else if (normalized.modifiedAt > existing.modifiedAt) {1449 const idx = entries.findIndex((e) => e.id === existing.id);1450 if (idx !== -1) {1451 entries[idx] = normalized;1452 updated += 1;1453 }1454 } else {1455 skipped += 1;1456 }1457 }1458 }14591460 await persistEntries("import");1461 return { added, updated, skipped };1462}1463146414651466146714681469function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1470 if (!entry || typeof entry !== "object") return null;1471 const title = typeof entry.title === "string" ? entry.title.trim() : "";1472 if (!title) return null;1473 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1474 const now = Date.now();1475 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1476 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;14771478 1479 1480 1481 const migrated: VaultEntry = migrateLegacyPasskey(1482 {1483 ...entry,1484 id,1485 title,1486 type: entry.type ?? "login",1487 createdAt,1488 modifiedAt,1489 },1490 entry1491 );14921493 1494 1495 if (1496 migrated.type === "login" &&1497 !migrated.passkey &&1498 typeof migrated.password !== "string"1499 ) {1500 return null;1501 }1502 if (migrated.passkey && !migrated.passkey.credentialId) {1503 return null;1504 }15051506 return migrated;1507}150815091510151115121513export async function clearVaultEntries(1514 confirmation: "REPLACE_ALL"1515): Promise<void> {1516 if (confirmation !== "REPLACE_ALL") {1517 throw new Error("clearVaultEntries requires explicit confirmation");1518 }1519 if (!derivedKey) throw new Error("vault_locked");1520 for (const entry of entries) {1521 if (!entry.deletedAt) {1522 entry.deletedAt = Date.now();1523 entry.modifiedAt = Date.now();1524 }1525 }1526 await persistEntries();1527}15281529export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1530 if (!derivedKey) throw new Error("vault_locked");15311532 const sanitized = sanitizeEntryDraft(entry);1533 const now = Date.now();1534 const nextEntry: VaultEntry = {1535 ...sanitized,1536 id: crypto.randomUUID(),1537 createdAt: now,1538 modifiedAt: now,1539 1540 1541 passwordChangedAt: sanitized.password ? now : undefined,1542 };15431544 1545 1546 1547 const rollback = entries;1548 entries = [...entries, nextEntry];1549 try {1550 await persistEntries();1551 } catch (err) {1552 entries = rollback;1553 throw err;1554 }15551556 1557 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1558 checkBreach(nextEntry.password).then(async (count) => {1559 if (count > 0) {1560 const idx = entries.findIndex((e) => e.id === nextEntry.id);1561 if (idx !== -1) {1562 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1563 await persistEntries();1564 }1565 }1566 });1567 }15681569 return nextEntry;1570}15711572export async function updateEntry(1573 id: string,1574 patch: Partial<VaultEntry>1575): Promise<void> {1576 if (!derivedKey) throw new Error("vault_locked");15771578 const idx = entries.findIndex((entry) => entry.id === id);1579 if (idx === -1 || entries[idx].deletedAt) {1580 throw new Error("entry_not_found");1581 }15821583 const current = entries[idx];1584 const sanitizedPatch = sanitizeEntryPatch(patch);1585 1586 1587 1588 const passwordChanged =1589 "password" in patch &&1590 typeof sanitizedPatch.password === "string" &&1591 sanitizedPatch.password !== current.password;1592 entries[idx] = {1593 ...current,1594 ...sanitizedPatch,1595 modifiedAt: Date.now(),1596 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1597 };15981599 1600 1601 1602 1603 if (1604 entries[idx].type === "login" &&1605 !entries[idx].password &&1606 !entries[idx].passkey1607 ) {1608 entries[idx] = current;1609 throw new Error("password or passkey required");1610 }16111612 1613 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1614 entries[idx] = current;1615 throw new Error("card number is required for card entries");1616 }16171618 await persistEntries();16191620 1621 1622 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1623 entries[idx] = { ...entries[idx], breach: undefined };1624 await persistEntries();1625 const password = entries[idx].password;1626 if (password) {1627 checkBreach(password).then(async (count) => {1628 const i = entries.findIndex((e) => e.id === id);1629 if (i !== -1 && count > 0) {1630 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1631 await persistEntries();1632 }1633 });1634 }1635 }1636}16371638export async function deleteEntry(id: string): Promise<void> {1639 if (!derivedKey) throw new Error("vault_locked");16401641 const entry = entries.find((item) => item.id === id);1642 if (!entry || entry.deletedAt) {1643 throw new Error("entry_not_found");1644 }16451646 entry.deletedAt = Date.now();1647 entry.modifiedAt = Date.now();1648 await persistEntries();1649}16501651export function getDeletedEntries(): VaultEntry[] {1652 if (!derivedKey) return [];1653 return entries.filter((e) => !!e.deletedAt);1654}16551656export async function restoreEntry(id: string): Promise<void> {1657 if (!derivedKey) throw new Error("vault_locked");1658 const entry = entries.find((e) => e.id === id && e.deletedAt);1659 if (!entry) throw new Error("deleted entry not found");1660 delete entry.deletedAt;1661 entry.modifiedAt = Date.now();1662 await persistEntries();1663 buildDomainIndexSync(entries);1664}16651666export async function purgeEntry(id: string): Promise<void> {1667 if (!derivedKey) throw new Error("vault_locked");1668 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1669 if (idx === -1) throw new Error("deleted entry not found");1670 entries.splice(idx, 1);1671 await persistEntries();1672}167316741675export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1676 if (!derivedKey || retentionMs === 0) return 0;1677 const cutoff = Date.now() - retentionMs;1678 const before = entries.length;1679 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1680 const purged = before - entries.length;1681 if (purged > 0) await persistEntries();1682 return purged;1683}16841685export function queryDomain(domain: string): VaultEntry[] {1686 return domainIndex.get(domain) ?? [];1687}16881689export function getEntryById(id: string): VaultEntry | undefined {1690 return entries.find((entry) => entry.id === id && !entry.deletedAt);1691}16921693async function persistSlotA(1694 reason: VaultSnapshot["reason"] = "local_save",1695): Promise<StoredVault> {1696 if (!derivedKey) throw new Error("vault_locked");16971698 const slotA = await loadSlot(SLOT_A);1699 if (!slotA) throw new Error("missing slot A");17001701 1702 1703 await takeVaultSnapshot(reason);17041705 const plaintext = encodePayload(entries, duressConfigured);1706 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");17071708 1709 const nextSlotA: StoredVault = {1710 ...slotA,1711 nonce,1712 ciphertext,1713 tag,1714 1715 };1716 await writeSlot(SLOT_A, nextSlotA);17171718 1719 1720 1721 if (!duressConfigured) {1722 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1723 }1724 await stampLocalVaultMutation();17251726 return nextSlotA;1727}17281729async function persistSlotB(): Promise<void> {1730 if (!derivedKey) throw new Error("vault_locked");17311732 const slotB = await loadSlot(SLOT_B);1733 if (!slotB) throw new Error("missing slot B");17341735 1736 1737 const plaintext = encodePayload(entries, false);1738 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");17391740 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1741 await stampLocalVaultMutation();1742}17431744async function persistEntries(1745 reason: VaultSnapshot["reason"] = "local_save",1746): Promise<void> {1747 if (!derivedKey) throw new Error("vault_locked");17481749 if (vaultMode === "decoy") {1750 await persistSlotB();1751 } else {1752 await persistSlotA(reason);1753 }1754 buildDomainIndexSync(entries);1755}17561757function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1758 if (!Array.isArray(values)) return undefined;1759 const seen = new Set<string>();1760 const cleaned: string[] = [];1761 for (const value of values) {1762 const trimmed = typeof value === "string" ? value.trim() : "";1763 if (!trimmed) continue;1764 const key = trimmed.toLowerCase();1765 if (seen.has(key)) continue;1766 seen.add(key);1767 cleaned.push(trimmed);1768 }1769 return cleaned.length > 0 ? cleaned : undefined;1770}17711772function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1773 const title = entry.title.trim();1774 1775 1776 1777 let type: VaultEntry["type"] = entry.type ?? "login";1778 if (type === "passkey") type = "login";17791780 if (!title) throw new Error("title is required");1781 1782 1783 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1784 throw new Error("password or passkey required");1785 }1786 if (entry.passkey) {1787 const p = entry.passkey;1788 if (1789 !p.rpId ||1790 !p.credentialId ||1791 !p.publicKeyCose ||1792 !p.userHandle ||1793 !p.userName ||1794 (p.algorithm !== -7 && p.algorithm !== -8)1795 ) {1796 throw new Error("invalid passkey credential");1797 }1798 }1799 if (type === "card") {1800 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1801 throw new Error("cardholder name and card number are required");1802 }1803 }18041805 return {1806 type,1807 title,1808 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1809 email: type === "login" ? entry.email?.trim() || undefined : undefined,1810 password: type === "login" ? entry.password : undefined,1811 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1812 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1813 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1814 notes: entry.notes?.trim() || undefined,1815 breach: type === "login" ? entry.breach : undefined,1816 canary: type === "login" ? entry.canary : undefined,1817 tags: entry.tags,1818 passkey: entry.passkey,1819 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1820 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1821 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1822 expYear: type === "card" ? entry.expYear?.trim() : undefined,1823 cvc: type === "card" ? entry.cvc?.trim() : undefined,1824 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1825 network: type === "card" ? entry.network : undefined,1826 last4: type === "card" ? entry.last4?.trim() : undefined,1827 };1828}18291830function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1831 const nextPatch: Partial<VaultEntry> = {};18321833 if ("type" in patch) {1834 nextPatch.type = patch.type;1835 }18361837 if ("title" in patch) {1838 const title = patch.title?.trim() ?? "";1839 if (!title) throw new Error("title is required");1840 nextPatch.title = title;1841 }18421843 if ("url" in patch) {1844 nextPatch.url = patch.url?.trim() ?? "";1845 }18461847 if ("additionalUrls" in patch) {1848 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1849 const seen = new Set<string>();1850 const cleaned: string[] = [];1851 for (const value of incoming) {1852 const trimmed = typeof value === "string" ? value.trim() : "";1853 if (!trimmed) continue;1854 const key = trimmed.toLowerCase();1855 if (seen.has(key)) continue;1856 seen.add(key);1857 cleaned.push(trimmed);1858 }1859 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1860 }18611862 if ("username" in patch) {1863 nextPatch.username = patch.username?.trim() ?? "";1864 }18651866 if ("email" in patch) {1867 nextPatch.email = patch.email?.trim() || undefined;1868 }18691870 if ("password" in patch) {1871 const password = patch.password ?? "";1872 1873 nextPatch.password = password || undefined;1874 }18751876 if ("totp" in patch) {1877 nextPatch.totp = patch.totp?.trim() || undefined;1878 }18791880 if ("notes" in patch) {1881 nextPatch.notes = patch.notes?.trim() || undefined;1882 }18831884 if ("breach" in patch) {1885 nextPatch.breach = patch.breach;1886 }18871888 if ("canary" in patch) {1889 nextPatch.canary = patch.canary;1890 }18911892 if ("passkey" in patch) {1893 1894 1895 1896 nextPatch.passkey = patch.passkey;1897 }18981899 if ("tags" in patch) {1900 nextPatch.tags = patch.tags;1901 }19021903 if ("cardholderName" in patch) {1904 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1905 }1906 if ("cardNumber" in patch) {1907 const n = patch.cardNumber ?? "";1908 nextPatch.cardNumber = n || undefined;1909 }1910 if ("expMonth" in patch) {1911 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1912 }1913 if ("expYear" in patch) {1914 nextPatch.expYear = patch.expYear?.trim() || undefined;1915 }1916 if ("cvc" in patch) {1917 nextPatch.cvc = patch.cvc?.trim() || undefined;1918 }1919 if ("cardNickname" in patch) {1920 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1921 }1922 if ("network" in patch) {1923 nextPatch.network = patch.network;1924 }1925 if ("last4" in patch) {1926 nextPatch.last4 = patch.last4?.trim() || undefined;1927 }19281929 return nextPatch;1930}19311932function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1933 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1934 const modifiedAt =1935 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;19361937 const base: VaultEntry = {1938 id: entry.id,1939 type: "type" in entry ? entry.type : "login",1940 title: entry.title,1941 username: entry.username,1942 email: "email" in entry ? entry.email : undefined,1943 password: entry.password,1944 url: entry.url,1945 additionalUrls:1946 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)1947 ? entry.additionalUrls1948 : undefined,1949 totp: entry.totp,1950 notes: entry.notes,1951 breach: entry.breach,1952 canary: "canary" in entry ? entry.canary : undefined,1953 passkey: "passkey" in entry ? entry.passkey : undefined,1954 createdAt,1955 modifiedAt,1956 deletedAt: entry.deletedAt,1957 tags: "tags" in entry ? entry.tags : undefined,1958 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1959 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1960 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1961 expYear: "expYear" in entry ? entry.expYear : undefined,1962 cvc: "cvc" in entry ? entry.cvc : undefined,1963 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1964 network: "network" in entry ? entry.network : undefined,1965 last4: "last4" in entry ? entry.last4 : undefined,1966 };19671968 return migrateLegacyPasskey(base, entry);1969}197019711972197319741975197619771978function migrateLegacyPasskey(1979 base: VaultEntry,1980 raw: VaultEntry | LegacyVaultEntry1981): VaultEntry {1982 if (base.passkey) return base;1983 const flat = raw as Partial<VaultEntry>;1984 const isPasskeyType = base.type === "passkey";1985 const hasFlatPasskeyFields =1986 typeof flat.rpId === "string" &&1987 typeof flat.credentialId === "string" &&1988 typeof flat.publicKeyCose === "string" &&1989 typeof flat.userHandle === "string" &&1990 typeof flat.userName === "string";1991 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1992 if (!hasFlatPasskeyFields) return base;19931994 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1995 base.passkey = {1996 rpId: flat.rpId!,1997 rpName: flat.rpName ?? flat.rpId!,1998 credentialId: flat.credentialId!,1999 publicKeyCose: flat.publicKeyCose!,2000 privateKeyJwk: flat.privateKeyJwk,2001 userHandle: flat.userHandle!,2002 userName: flat.userName!,2003 userDisplayName: flat.userDisplayName ?? flat.userName!,2004 algorithm,2005 signCount: flat.signCount ?? 0,2006 aaguid: flat.aaguid ?? "",2007 backupEligible: flat.backupEligible === true,2008 backupState: flat.backupState === true,2009 lastUsedAt: flat.lastUsedAt,2010 browser: flat.browser,2011 transports: flat.transports,2012 createdAt: base.createdAt,2013 };2014 2015 2016 2017 if (isPasskeyType) base.type = "login";2018 if (!base.username) base.username = flat.userName;2019 if (!base.url) base.url = `https://${flat.rpId}`;2020 return base;2021}2022202320242025202620272028function entryDupeKey(e: VaultEntry): string {2029 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2030 2031 2032 2033 2034 if (e.passkey) {2035 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2036 }2037 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2038}203920402041export function countDuplicates(): number {2042 const best = new Map<string, VaultEntry>();2043 let dupeCount = 0;2044 for (const e of entries.filter((e) => !e.deletedAt)) {2045 const key = entryDupeKey(e);2046 const existing = best.get(key);2047 if (!existing) {2048 best.set(key, e);2049 } else {2050 dupeCount++;2051 2052 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2053 best.set(key, e);2054 }2055 }2056 }2057 return dupeCount;2058}205920602061206220632064export async function deduplicateEntries(): Promise<number> {2065 if (!derivedKey) throw new Error("vault_locked");2066 2067 2068 if (vaultMode === "decoy") {2069 throw new Error("deduplicate disabled in decoy mode");2070 }20712072 const best = new Map<string, VaultEntry>();2073 for (const e of entries.filter((en) => !en.deletedAt)) {2074 const key = entryDupeKey(e);2075 const existing = best.get(key);2076 if (!existing) {2077 best.set(key, e);2078 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2079 best.set(key, e);2080 }2081 }20822083 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2084 const now = Date.now();2085 let removed = 0;2086 entries = entries.map((e) => {2087 if (!e.deletedAt && !keepIds.has(e.id)) {2088 removed++;2089 return { ...e, deletedAt: now };2090 }2091 return e;2092 });20932094 if (removed > 0) await persistEntries();2095 return removed;2096}209720982099210021012102210321042105const HISTORY_KEY = "peach_vault_history";2106const HISTORY_MAX = 10;21072108210921102111211221132114export async function takeVaultSnapshot(2115 reason: VaultSnapshot["reason"],2116): Promise<void> {2117 try {2118 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2119 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2120 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];21212122 2123 2124 2125 if (!slotA) return;21262127 const vault_a = JSON.stringify(slotA);2128 const vault_b = JSON.stringify(slotB ?? slotA);21292130 2131 2132 2133 let entryCount: number | null = null;2134 if (derivedKey && vaultMode === "real") {2135 try {2136 entryCount = entries.filter((e) => !e.deletedAt).length;2137 } catch {2138 entryCount = null;2139 }2140 }21412142 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2143 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2144 ? existingRaw2145 : { snapshots: [] };21462147 2148 2149 2150 const latest = history.snapshots[0];2151 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2152 return;2153 }21542155 const snapshot: VaultSnapshot = {2156 id: crypto.randomUUID(),2157 savedAt: Date.now(),2158 reason,2159 vault_a,2160 vault_b,2161 entryCount,2162 };21632164 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2165 await chrome.storage.local.set({ [HISTORY_KEY]: history });2166 } catch (err) {2167 2168 2169 2170 2171 console.warn('[Peach] takeVaultSnapshot failed:', err);2172 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2173 try {2174 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2175 } catch {2176 2177 }2178 }2179 }2180}218121822183export async function clearVaultHistory(): Promise<void> {2184 try {2185 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2186 } catch {2187 2188 }2189}219021912192export async function getVaultHistory(): Promise<VaultSnapshot[]> {2193 const raw = await chrome.storage.local.get(HISTORY_KEY);2194 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2195 if (!history || !Array.isArray(history.snapshots)) return [];2196 return history.snapshots;2197}21982199220022012202220322042205export async function restoreVaultSnapshot(2206 snapshotId: string,2207): Promise<{ ok: true } | { ok: false; error: string }> {2208 const history = await getVaultHistory();2209 const snapshot = history.find((s) => s.id === snapshotId);2210 if (!snapshot) return { ok: false, error: "snapshot_not_found" };22112212 2213 2214 2215 await takeVaultSnapshot("restore");22162217 let restoredA: StoredVault;2218 let restoredB: StoredVault;2219 try {2220 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2221 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2222 } catch {2223 return { ok: false, error: "snapshot_corrupt" };2224 }22252226 await writeSlot(SLOT_A, restoredA);2227 await writeSlot(SLOT_B, restoredB);2228 await stampLocalVaultMutation();22292230 2231 2232 2233 lock();22342235 return { ok: true };2236}22372238223922402241224222432244export async function changePassword(2245 currentPassword: string,2246 newPassword: string2247): Promise<boolean> {2248 if (!derivedKey || vaultMode !== "real") return false;22492250 2251 const valid = await verifyMainPassword(currentPassword);2252 if (!valid) return false;22532254 const slotA = await loadSlot(SLOT_A);2255 if (!slotA) return false;2256 if (!rootKey) return false;22572258 2259 const newSalt = generateSalt();2260 const newKey = await deriveKey(newPassword, newSalt);22612262 2263 const plaintext = encodePayload(entries, duressConfigured);2264 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");22652266 2267 2268 2269 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);22702271 const finalSlotA: StoredVault = {2272 ...slotA,2273 salt: hex(newSalt),2274 nonce,2275 ciphertext,2276 tag,2277 ...pwRoot,2278 };22792280 2281 2282 2283 await takeVaultSnapshot("local_save");22842285 2286 2287 2288 2289 await writeSlot(SLOT_A, finalSlotA);22902291 2292 2293 2294 zeroKey(derivedKey);2295 derivedKey = newKey;22962297 2298 2299 2300 if (!duressConfigured) {2301 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2302 }2303 await stampLocalVaultMutation();23042305 return true;2306}