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}168169170171172173174function fireSyncTrigger(reason: "vault_change"): void {175 void import("./sync-trigger")176 .then((mod) => mod.triggerSyncIfReachable({ reason }))177 .catch(() => {178 179 });180}181182183184185186export async function checkBreach(password: string): Promise<number> {187 188 189 const encoder = new TextEncoder();190 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));191 const hashHex = Array.from(new Uint8Array(hashBuffer))192 .map((b) => b.toString(16).padStart(2, "0"))193 .join("")194 .toUpperCase();195196 const prefix = hashHex.slice(0, 5);197 const suffix = hashHex.slice(5);198199 try {200 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);201 if (!res.ok) return 0;202 const text = await res.text();203 const match = text.split("\r\n").find((l) => l.startsWith(suffix));204 return match ? parseInt(match.split(":")[1], 10) : 0;205 } catch {206 return 0; 207 }208}209210export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {211 const fingerprint = await cardFingerprint(last4, expMonth, expYear);212 await loadSettings();213 settings = {214 ...settings,215 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],216 };217 await persistSettings();218}219220export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {221 await loadSettings();222 const fingerprint = await cardFingerprint(last4, expMonth, expYear);223 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);224}225226async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {227 const encoder = new TextEncoder();228 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));229 return Array.from(new Uint8Array(buf).slice(0, 8))230 .map((b) => b.toString(16).padStart(2, "0"))231 .join("");232}233234function hex(buf: Uint8Array): string {235 return Array.from(buf)236 .map((b) => b.toString(16).padStart(2, "0"))237 .join("");238}239240function unhex(s: string): Uint8Array {241 const out = new Uint8Array(s.length / 2);242 for (let i = 0; i < s.length; i += 2) {243 out[i / 2] = parseInt(s.slice(i, i + 2), 16);244 }245 return out;246}247248249250251252253254255256257258259async function generateAndWrapRootKey(): Promise<{260 rawRootKey: string; 261 recoverySalt: string;262 wrappedRootKey: string;263 wrappedNonce: string;264 wrappedTag: string;265}> {266 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));267268 269 const words = await rootKeyToWords(rootKeyBytes);270 const wordMaterial = await wordsToRootKeyMaterial(words);271 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));272 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);273274 275 276 const nonce = crypto.getRandomValues(new Uint8Array(12));277 const encoded = new TextEncoder().encode(hex(rootKeyBytes));278 const raw = await crypto.subtle.encrypt(279 {280 name: "AES-GCM",281 iv: nonce,282 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,283 tagLength: 128,284 },285 recoveryKey,286 encoded287 );288 289 const rawU8 = new Uint8Array(raw);290 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));291 const tag = hex(rawU8.slice(rawU8.length - 16));292293 return {294 rawRootKey: hex(rootKeyBytes),295 recoverySalt: hex(recoverySalt),296 wrappedRootKey: ciphertext,297 wrappedNonce: hex(nonce),298 wrappedTag: tag,299 };300}301302303304305306307export async function unwrapRootKeyForRecovery(308 wrappedRootKey: string,309 wrappedNonce: string,310 wrappedTag: string,311 recoverySaltHex: string,312 words: string[]313): Promise<void> {314 const wordMaterial = await wordsToRootKeyMaterial(words);315 const recoverySalt = unhex(recoverySaltHex);316 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);317318 319 320 321 322 323 const nonceRaw = unhex(wrappedNonce);324 const ciphertextRaw = unhex(wrappedRootKey);325 const tagRaw = unhex(wrappedTag);326 327 328 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;329 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);330 combined.set(ciphertextRaw, 0);331 combined.set(tagRaw, ciphertextRaw.length);332 const raw = await crypto.subtle.decrypt(333 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },334 recoveryKey,335 combined336 );337 const decryptedHex = new TextDecoder().decode(raw);338 rootKey = unhex(decryptedHex);339}340341342343344345346347348349350export async function rewrapRootKeyForPassword(351 newDerivedKey: Uint8Array,352 _newSalt: Uint8Array353): Promise<void> {354 if (!rootKey) throw new Error("root key not set");355 const slotA = await loadSlot(SLOT_A);356 if (!slotA) throw new Error("missing slot A");357 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);358 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });359}360361362363364365366async function reencryptVaultForNewKey(367 newKey: Uint8Array,368 slotA: StoredVault369): Promise<void> {370 const plaintext = encodePayload(entries, duressConfigured);371 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");372 await writeSlot(SLOT_A, {373 ...slotA,374 nonce,375 ciphertext,376 tag,377 });378}379380381382383384385export async function changePasswordWithNewKey(386 newSalt: Uint8Array,387 newKey: Uint8Array388): Promise<void> {389 const slotA = await loadSlot(SLOT_A);390 if (!slotA) throw new Error("missing slot A");391 392 393 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });394 if (derivedKey) zeroKey(derivedKey);395 derivedKey = newKey;396 397 398 399 400 if (rootKey) {401 const slotAfter = await loadSlot(SLOT_A);402 if (slotAfter) {403 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);404 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });405 }406 }407 if (!duressConfigured) {408 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));409 }410 await stampLocalVaultMutation();411}412413414415416417418export { deriveKey, generateSalt };419420export async function loadSlotA(): Promise<StoredVault | null> {421 return loadSlot(SLOT_A);422}423424425426427428429430431432433434435436async function deriveRootKeyFromDerivedKey(437 derivedKeyBytes: Uint8Array,438 slotA: StoredVault,439): Promise<Uint8Array | null> {440 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {441 return null;442 }443 try {444 const rootHex = await decrypt(445 derivedKeyBytes,446 slotA.pw_root_nonce,447 slotA.pw_root_ciphertext,448 slotA.pw_root_tag,449 "pw_root_v1",450 );451 return unhex(rootHex);452 } catch {453 return null;454 }455}456457458459async function wrapRootKeyForDerivedKey(460 derivedKeyBytes: Uint8Array,461 rootKeyBytes: Uint8Array,462): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {463 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");464 return {465 pw_root_ciphertext: ciphertext,466 pw_root_nonce: nonce,467 pw_root_tag: tag,468 };469}470471472473474475476477478export async function migrateToRecovery(): Promise<void> {479 if (!derivedKey || vaultMode !== "real") {480 throw new Error("vault must be unlocked in real mode");481 }482 const slotA = await loadSlot(SLOT_A);483 if (!slotA) throw new Error("missing slot A");484 const hasRecoveryEscrow =485 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;486 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;487 if (hasRecoveryEscrow && hasPwRoot) return; 488489 490 491 492 493 494 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =495 await generateAndWrapRootKey();496 rootKey = unhex(rawRootKey);497498 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);499 const nextSlotA: StoredVault = {500 ...slotA,501 recovery_salt: recoverySalt,502 recovery_wrapped_root_key: wrappedRootKey,503 recovery_nonce: wrappedNonce,504 recovery_tag: wrappedTag,505 ...pwRoot,506 };507 await writeSlot(SLOT_A, nextSlotA);508 await stampLocalVaultMutation();509}510511512513514515516export async function getRecoveryWords(): Promise<string[]> {517 if (!rootKey) {518 throw new Error("recovery not set up — vault may need migration");519 }520 return rootKeyToWords(rootKey);521}522523async function loadSlot(key: string): Promise<StoredVault | null> {524 const result = await chrome.storage.local.get(key);525 return (result as Record<string, StoredVault | undefined>)[key] ?? null;526}527528async function writeSlot(key: string, vault: StoredVault): Promise<void> {529 try {530 await chrome.storage.local.set({ [key]: vault });531 } catch (err) {532 throw mapStorageError(err);533 }534}535536async function stampLocalVaultMutation(): Promise<void> {537 const raw = await chrome.storage.local.get("peach_last_modified_at");538 const previous = Number(539 (raw as Record<string, unknown>).peach_last_modified_at,540 );541 const next = Math.max(542 Date.now(),543 Number.isFinite(previous) ? previous + 1 : 0,544 );545 await chrome.storage.local.set({546 peach_last_modified_at: String(next),547 });548}549550551552553554555async function ensureMigrated(): Promise<void> {556 if (migrationRan) return;557 migrationRan = true;558559 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];560 const raw = await chrome.storage.local.get(keys);561 const slotAExists = raw[SLOT_A] !== undefined;562 const slotBExists = raw[SLOT_B] !== undefined;563 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;564 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;565566 567 if (!slotAExists && !legacyMain) return;568569 570 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;571572 let didMigrate = false;573 if (legacyMain && !slotAExists) {574 await writeSlot(SLOT_A, legacyMain);575 didMigrate = true;576 }577578 579 if (!slotBExists) {580 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;581 if (legacyDecoy) {582 await writeSlot(SLOT_B, legacyDecoy);583 didMigrate = true;584 } else if (target) {585 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));586 didMigrate = true;587 }588 }589590 const toRemove: string[] = [];591 if (legacyMain) toRemove.push(LEGACY_MAIN);592 if (legacyDecoy) toRemove.push(LEGACY_DECOY);593 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);594 if (toRemove.length > 0) {595 await chrome.storage.local.remove(toRemove);596 didMigrate = true;597 }598 if (didMigrate) {599 await stampLocalVaultMutation();600 }601}602603604605606607608609610611612613function padPlaintext(json: string): string {614 const encoded = new TextEncoder().encode(json);615 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);616 const padLen = target - encoded.length;617 return json + " ".repeat(padLen);618}619620621622623624625626627628629630const CSPRNG_CHUNK = 65_536;631632function fillRandom(buf: Uint8Array): Uint8Array {633 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {634 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);635 const chunk = new Uint8Array(end - offset);636 crypto.getRandomValues(chunk);637 buf.set(chunk, offset);638 }639 return buf;640}641642function generateNoiseBlob(ciphertextBytes: number): StoredVault {643 const salt = crypto.getRandomValues(new Uint8Array(16));644 const nonce = crypto.getRandomValues(new Uint8Array(12));645 const cipher = fillRandom(new Uint8Array(ciphertextBytes));646 const tag = crypto.getRandomValues(new Uint8Array(16));647 return {648 version: 1,649 salt: hex(salt),650 nonce: hex(nonce),651 ciphertext: hex(cipher),652 tag: hex(tag),653 };654}655656657658659660type VaultPayload = {661 entries: VaultEntry[];662 duressConfigured?: boolean;663};664665type DecryptedPayload = {666 entries: VaultEntry[];667 duressConfigured: boolean;668 669670671672673674675 migratedFromLegacy: boolean;676};677678function encodePayload(entries: VaultEntry[], duress: boolean): string {679 const envelope: VaultPayload = { entries, duressConfigured: duress };680 return padPlaintext(JSON.stringify(envelope));681}682683function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {684 if ("created" in raw || "updated" in raw) return true;685 const flat = raw as Partial<VaultEntry>;686 if (flat.type === "passkey") return true;687 688 if (flat.rpId !== undefined && flat.passkey === undefined) return true;689 return false;690}691692function decodePayload(plain: string): DecryptedPayload {693 const parsed = JSON.parse(plain) as694 | Array<VaultEntry | LegacyVaultEntry>695 | VaultPayload;696697 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];698 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);699700 const normalized = rawEntries.map(normalizeEntry);701 const { merged } = mergePasskeyOnlyIntoLogins(normalized);702703 if (Array.isArray(parsed)) {704 return {705 entries: normalized,706 duressConfigured: false,707 migratedFromLegacy: true, 708 };709 }710711 return {712 entries: normalized,713 duressConfigured: parsed.duressConfigured === true,714 migratedFromLegacy: flatMigrationNeeded || merged,715 };716}717718719720721722723724725726727728729730731732733734735736737738739740741function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {742 let merged = false;743 const now = Date.now();744745 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);746 const byRpId = new Map<string, VaultEntry[]>();747 for (const e of candidates) {748 const rpId = e.passkey!.rpId;749 const list = byRpId.get(rpId);750 if (list) list.push(e);751 else byRpId.set(rpId, [e]);752 }753754 for (const [rpId, group] of byRpId) {755 if (group.length === 0) continue;756 group.sort((a, b) => {757 const aT =758 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;759 const bT =760 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;761 return bT - aT;762 });763 const winner = group[0]!;764765 766 767 768 const loginTarget = entries.find((e) => {769 if (e.deletedAt || e.passkey || e.type !== "login") return false;770 if (e.url && extractDomain(e.url) === rpId) return true;771 for (const extra of e.additionalUrls ?? []) {772 if (extractDomain(extra) === rpId) return true;773 }774 return false;775 });776777 if (loginTarget) {778 loginTarget.passkey = winner.passkey;779 loginTarget.modifiedAt = now;780 for (const dup of group) {781 if (!dup.deletedAt) {782 dup.deletedAt = now;783 dup.modifiedAt = now;784 }785 }786 merged = true;787 } else if (group.length > 1) {788 for (let i = 1; i < group.length; i += 1) {789 const dup = group[i]!;790 if (!dup.deletedAt) {791 dup.deletedAt = now;792 dup.modifiedAt = now;793 }794 }795 merged = true;796 }797 }798799 return { merged };800}801802803804805806807808function hasValidVaultSchema(v: unknown): v is StoredVault {809 if (typeof v !== "object" || v === null) return false;810 const r = v as Record<string, unknown>;811 return (812 r.version === 1 &&813 typeof r.salt === "string" &&814 typeof r.nonce === "string" &&815 typeof r.ciphertext === "string" &&816 typeof r.tag === "string"817 );818}819820export type TryDecryptResult =821 | { status: "ok"; payload: DecryptedPayload }822 | { status: "wrong_key" }823 | { status: "corrupted"; reason: string };824825826827828829830export async function tryDecryptSlot(831 vault: StoredVault,832 key: Uint8Array,833 aadTag?: string,834): Promise<DecryptedPayload | null> {835 const res = await tryDecryptSlotResult(vault, key, aadTag);836 return res.status === "ok" ? res.payload : null;837}838839export async function tryDecryptSlotResult(840 vault: StoredVault,841 key: Uint8Array,842 aadTag?: string,843): Promise<TryDecryptResult> {844 if (!hasValidVaultSchema(vault)) {845 return { status: "corrupted", reason: "invalid_slot_schema" };846 }847 let plain: string;848 try {849 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);850 } catch {851 return { status: "wrong_key" };852 }853 try {854 const parsed = JSON.parse(plain) as unknown;855 if (typeof parsed !== "object" || parsed === null) {856 return { status: "corrupted", reason: "invalid_payload_shape" };857 }858 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {859 return { status: "corrupted", reason: "invalid_payload_shape" };860 }861 return { status: "ok", payload: decodePayload(plain) };862 } catch {863 return { status: "corrupted", reason: "invalid_payload_json" };864 }865}866867868869870871function buildDomainIndexSync(all: VaultEntry[]): void {872 domainIndex = new Map();873874 for (const entry of all) {875 if (entry.deletedAt) continue;876 if (entry.type === "note" || entry.type === "card") continue;877878 879 880 881 882 const domains = new Set<string>();883 const primary = extractDomain(entry.url ?? "");884 if (primary) domains.add(primary);885 for (const extra of entry.additionalUrls ?? []) {886 const d = extractDomain(extra);887 if (d) domains.add(d);888 }889 if (domains.size === 0) continue;890891 for (const domain of domains) {892 const bucket = domainIndex.get(domain) ?? [];893 bucket.push(entry);894 domainIndex.set(domain, bucket);895 }896 }897}898899export async function createVault(password: string): Promise<void> {900 await ensureMigrated();901902 const salt = generateSalt();903 derivedKey = await deriveKey(password, salt);904 entries = [];905 vaultMode = "real";906 duressConfigured = false;907 buildDomainIndexSync(entries);908909 910 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =911 await generateAndWrapRootKey();912 rootKey = unhex(rawRootKey);913914 const plaintext = encodePayload(entries, duressConfigured);915 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");916917 918 919 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);920921 const slotA: StoredVault = {922 version: 1,923 salt: hex(salt),924 nonce,925 ciphertext,926 tag,927 recovery_salt: recoverySalt,928 recovery_wrapped_root_key: wrappedRootKey,929 recovery_nonce: wrappedNonce,930 recovery_tag: wrappedTag,931 ...pwRoot,932 };933 await writeSlot(SLOT_A, slotA);934935 936 937 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));938 await stampLocalVaultMutation();939940 touchAutoLock();941}942943export async function hasVault(): Promise<boolean> {944 await ensureMigrated();945 return (await loadSlot(SLOT_A)) !== null;946}947948949950951952953export async function isDuressEnabled(): Promise<boolean> {954 return vaultMode === "real" && duressConfigured;955}956957export async function setupDuress(duressPassword: string): Promise<void> {958 if (!derivedKey || vaultMode !== "real") {959 throw new Error("vault must be unlocked in real mode");960 }961 await ensureMigrated();962963 964 965 966 const slotA = await loadSlot(SLOT_A);967 if (!slotA) throw new Error("missing slot A");968969 const decoySalt = generateSalt();970 const decoyKey = await deriveKey(duressPassword, decoySalt);971 try {972 973 974 const targetBytes = slotA.ciphertext.length / 2;975 let plaintext = encodePayload([], false);976 const encoded = new TextEncoder().encode(plaintext);977 if (encoded.length < targetBytes) {978 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);979 }980 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");981 await writeSlot(SLOT_B, {982 version: 1,983 salt: hex(decoySalt),984 nonce,985 ciphertext,986 tag,987 });988 } finally {989 zeroKey(decoyKey);990 }991992 993 994 duressConfigured = true;995 await persistSlotA();996}997998export async function disableDuress(): Promise<void> {999 if (!derivedKey || vaultMode !== "real") {1000 throw new Error("vault must be unlocked in real mode");1001 }1002 await ensureMigrated();10031004 1005 1006 1007 const slotA = await loadSlot(SLOT_A);1008 if (slotA) {1009 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1010 }10111012 duressConfigured = false;1013 await persistSlotA();1014}10151016101710181019102010211022export async function verifyMainPassword(password: string): Promise<boolean> {1023 await ensureMigrated();1024 const slotA = await loadSlot(SLOT_A);1025 if (!slotA) return false;1026 const key = await deriveKey(password, unhex(slotA.salt));1027 try {1028 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1029 } finally {1030 zeroKey(key);1031 }1032}10331034export async function unlock(password: string): Promise<boolean> {1035 await ensureMigrated();10361037 1038 1039 1040 1041 1042 const [slotA, slotB] = await Promise.all([1043 loadSlot(SLOT_A),1044 loadSlot(SLOT_B),1045 ]);10461047 1048 const [keyA, keyB] = await Promise.all([1049 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),1050 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),1051 ]);10521053 1054 1055 1056 const [resultA, resultB] = await Promise.all([1057 keyA && slotA1058 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1059 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1060 keyB && slotB1061 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1062 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1063 ]);1064 const payloadA = resultA.status === "ok" ? resultA.payload : null;1065 const payloadB = resultB.status === "ok" ? resultB.payload : null;10661067 if (payloadA && keyA) {1068 derivedKey = keyA;1069 void saveSessionKey(keyA, "real");1070 vaultMode = "real";1071 entries = payloadA.entries;1072 duressConfigured = payloadA.duressConfigured;1073 buildDomainIndexSync(entries);1074 await loadSettings();1075 touchAutoLock();1076 if (keyB) zeroKey(keyB);10771078 1079 if (slotA) {1080 const needsRecoverySetup =1081 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1082 if (!needsRecoverySetup) {1083 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1084 }1085 1086 1087 1088 1089 if (needsRecoverySetup || !rootKey) {1090 try {1091 await migrateToRecovery();1092 if (needsRecoverySetup) {1093 1094 1095 1096 await chrome.storage.local.set({ peach_recovery_pending: '1' });1097 }1098 } catch (migrationErr) {1099 console.warn('[Peach] recovery migration failed:', migrationErr);1100 }1101 }1102 }11031104 1105 1106 1107 if (payloadA.migratedFromLegacy) {1108 await persistSlotA();1109 }1110 return true;1111 }11121113 if (payloadB && keyB) {1114 derivedKey = keyB;1115 void saveSessionKey(keyB, "decoy");1116 vaultMode = "decoy";1117 entries = payloadB.entries;1118 duressConfigured = false;1119 buildDomainIndexSync(entries);1120 await loadSettings();1121 touchAutoLock();1122 if (keyA) zeroKey(keyA);1123 return true;1124 }11251126 if (keyA) zeroKey(keyA);1127 if (keyB) zeroKey(keyB);1128 1129 1130 if (resultA.status === "corrupted") {1131 lastUnlockFailureReason = resultA.reason;1132 } else if (resultB.status === "corrupted") {1133 lastUnlockFailureReason = resultB.reason;1134 }1135 return false;1136}11371138113911401141114211431144const SESSION_KEY_STORAGE = "peach_session_dk";11451146async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1147 if (!chrome.storage.session) return;1148 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1149}11501151async function clearSessionKey(): Promise<void> {1152 if (!chrome.storage.session) return;1153 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1154}11551156115711581159116011611162export async function restoreFromSession(): Promise<boolean> {1163 if (derivedKey !== null) return true;1164 if (!chrome.storage.session) return false;1165 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1166 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1167 if (!entry?.keyHex) return false;11681169 const key = unhex(entry.keyHex);1170 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1171 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1172 const slot = await loadSlot(slotKey);1173 if (!slot) { await clearSessionKey(); return false; }11741175 const result = await tryDecryptSlotResult(slot, key, aadTag);1176 if (result.status !== "ok") { await clearSessionKey(); return false; }11771178 derivedKey = key;1179 vaultMode = entry.mode;1180 entries = result.payload.entries;1181 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1182 buildDomainIndexSync(entries);1183 await loadSettings();1184 touchAutoLock();11851186 if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1187 try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { }1188 }11891190 return true;1191}11921193export function lock(): void {1194 void clearSessionKey();1195 if (derivedKey) {1196 zeroKey(derivedKey);1197 derivedKey = null;1198 }1199 if (rootKey) {1200 rootKey.fill(0);1201 rootKey = null;1202 }12031204 entries = [];1205 domainIndex = new Map();1206 vaultMode = "real";1207 duressConfigured = false;1208 clearAutoLock();1209}12101211export function isUnlocked(): boolean {1212 return derivedKey !== null;1213}1214121512161217export async function unlockWithKey(1218 masterKeyHex: string,1219): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1220 try {1221 const masterKeyBytes = unhex(masterKeyHex);1222 const stored = await loadSlot(SLOT_A);1223 if (!stored) return { ok: false, error: "invalid_key" };1224 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1225 if (!payload) return { ok: false, error: "invalid_key" };12261227 derivedKey = masterKeyBytes;1228 vaultMode = "real";1229 entries = payload.entries;1230 duressConfigured = payload.duressConfigured;1231 buildDomainIndexSync(entries);1232 await loadSettings();1233 touchAutoLock();1234 return { ok: true, entries };1235 } catch {1236 return { ok: false, error: "invalid_key" };1237 }1238}12391240124112421243124412451246124712481249125012511252125312541255125612571258export async function unlockFromCodex(params: {1259 slotA: string; 1260 slotB: string | null; 1261 timestamp: number;1262 masterPassphrase: string;1263}): Promise<1264 | { ok: true; entryCount: number }1265 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1266> {1267 let parsedSlotA: StoredVault;1268 let parsedSlotB: StoredVault | null = null;1269 try {1270 const rawA = JSON.parse(params.slotA) as unknown;1271 if (!hasValidVaultSchema(rawA)) {1272 return { ok: false, reason: "invalid_slot_format" };1273 }1274 parsedSlotA = rawA;1275 if (params.slotB !== null) {1276 const rawB = JSON.parse(params.slotB) as unknown;1277 if (!hasValidVaultSchema(rawB)) {1278 return { ok: false, reason: "invalid_slot_format" };1279 }1280 parsedSlotB = rawB;1281 }1282 } catch {1283 return { ok: false, reason: "invalid_slot_format" };1284 }12851286 1287 1288 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1289 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1290 if (!trialPayload) {1291 zeroKey(trialKey);1292 return { ok: false, reason: "wrong_passphrase" };1293 }1294 1295 1296 zeroKey(trialKey);12971298 1299 1300 await takeVaultSnapshot("restore");13011302 await writeSlot(SLOT_A, parsedSlotA);1303 if (parsedSlotB) {1304 await writeSlot(SLOT_B, parsedSlotB);1305 } else {1306 1307 1308 1309 await writeSlot(1310 SLOT_B,1311 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1312 );1313 }1314 await chrome.storage.local.set({1315 peach_last_modified_at: String(params.timestamp),1316 });13171318 1319 1320 const ok = await unlock(params.masterPassphrase);1321 if (!ok) {1322 1323 1324 1325 return { ok: false, reason: "wrong_passphrase" };1326 }1327 return { ok: true, entryCount: getEntries().length };1328}1329133013311332export function getDerivedKeyHex(): string {1333 if (!derivedKey) throw new Error("vault_not_unlocked");1334 return hex(derivedKey);1335}13361337export function getEntries(): VaultEntry[] {1338 if (!derivedKey) throw new Error("vault_locked");1339 return entries.filter((entry) => !entry.deletedAt);1340}13411342const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;13431344export function getIdentities(): SavedIdentity[] {1345 if (!derivedKey) throw new Error("vault_locked");1346 const active = entries.filter((e) => !e.deletedAt && e.type === "login");13471348 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1349 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();13501351 for (const entry of active) {1352 const domain = extractDomain(entry.url ?? "") ?? "";1353 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;13541355 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1356 const existing = map.get(v);1357 if (!existing || modifiedAt > existing.lastUsed) {1358 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1359 } else {1360 map.set(v, { ...existing, count: existing.count + 1 });1361 }1362 }13631364 const uVal = entry.username?.trim();1365 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);13661367 1368 1369 const eVal = entry.email?.trim();1370 if (eVal) tally(emailMap, eVal);1371 }13721373 const toIdentity = (type: "email" | "username") =>1374 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1375 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1376 });13771378 return [1379 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1380 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1381 ];1382}13831384export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1385 if (!derivedKey) throw new Error("vault_locked");13861387 const now = Date.now();1388 const added: VaultEntry[] = [];1389 for (const draft of drafts) {1390 try {1391 const sanitized = sanitizeEntryDraft(draft);1392 const nextEntry: VaultEntry = {1393 ...sanitized,1394 id: crypto.randomUUID(),1395 createdAt: now,1396 modifiedAt: now,1397 };1398 entries.push(nextEntry);1399 added.push(nextEntry);1400 } catch {1401 1402 }1403 }14041405 if (added.length > 0) {1406 await persistEntries();1407 }14081409 return added;1410}141114121413141414151416141714181419export async function restoreEntries(1420 incoming: VaultEntry[],1421 mode: "replace" | "merge"1422): Promise<{ added: number; updated: number; skipped: number }> {1423 if (!derivedKey) throw new Error("vault_locked");14241425 1426 1427 1428 if (mode === "replace" && incoming.length === 0) {1429 throw new Error("refuse to replace with empty entry set");1430 }14311432 let added = 0;1433 let updated = 0;1434 let skipped = 0;14351436 if (mode === "replace") {1437 entries = [];1438 for (const entry of incoming) {1439 const normalized = sanitizeRestoredEntry(entry);1440 if (!normalized) {1441 skipped += 1;1442 continue;1443 }1444 entries.push(normalized);1445 added += 1;1446 }1447 } else {1448 const byId = new Map<string, VaultEntry>();1449 for (const e of entries) byId.set(e.id, e);14501451 for (const entry of incoming) {1452 const normalized = sanitizeRestoredEntry(entry);1453 if (!normalized) {1454 skipped += 1;1455 continue;1456 }1457 const existing = byId.get(normalized.id);1458 if (!existing) {1459 entries.push(normalized);1460 added += 1;1461 } else if (normalized.modifiedAt > existing.modifiedAt) {1462 const idx = entries.findIndex((e) => e.id === existing.id);1463 if (idx !== -1) {1464 entries[idx] = normalized;1465 updated += 1;1466 }1467 } else {1468 skipped += 1;1469 }1470 }1471 }14721473 await persistEntries("import");1474 return { added, updated, skipped };1475}1476147714781479148014811482function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1483 if (!entry || typeof entry !== "object") return null;1484 const title = typeof entry.title === "string" ? entry.title.trim() : "";1485 if (!title) return null;1486 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1487 const now = Date.now();1488 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1489 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;14901491 1492 1493 1494 const migrated: VaultEntry = migrateLegacyPasskey(1495 {1496 ...entry,1497 id,1498 title,1499 type: entry.type ?? "login",1500 createdAt,1501 modifiedAt,1502 },1503 entry1504 );15051506 1507 1508 if (1509 migrated.type === "login" &&1510 !migrated.passkey &&1511 typeof migrated.password !== "string"1512 ) {1513 return null;1514 }1515 if (migrated.passkey && !migrated.passkey.credentialId) {1516 return null;1517 }15181519 return migrated;1520}152115221523152415251526export async function clearVaultEntries(1527 confirmation: "REPLACE_ALL"1528): Promise<void> {1529 if (confirmation !== "REPLACE_ALL") {1530 throw new Error("clearVaultEntries requires explicit confirmation");1531 }1532 if (!derivedKey) throw new Error("vault_locked");1533 for (const entry of entries) {1534 if (!entry.deletedAt) {1535 entry.deletedAt = Date.now();1536 entry.modifiedAt = Date.now();1537 }1538 }1539 await persistEntries();1540}15411542export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1543 if (!derivedKey) throw new Error("vault_locked");15441545 const sanitized = sanitizeEntryDraft(entry);1546 const now = Date.now();1547 const nextEntry: VaultEntry = {1548 ...sanitized,1549 id: crypto.randomUUID(),1550 createdAt: now,1551 modifiedAt: now,1552 1553 1554 passwordChangedAt: sanitized.password ? now : undefined,1555 };15561557 1558 1559 1560 const rollback = entries;1561 entries = [...entries, nextEntry];1562 try {1563 await persistEntries();1564 } catch (err) {1565 entries = rollback;1566 throw err;1567 }15681569 1570 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1571 checkBreach(nextEntry.password).then(async (count) => {1572 if (count > 0) {1573 const idx = entries.findIndex((e) => e.id === nextEntry.id);1574 if (idx !== -1) {1575 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1576 await persistEntries();1577 }1578 }1579 });1580 }15811582 fireSyncTrigger("vault_change");15831584 return nextEntry;1585}15861587export async function updateEntry(1588 id: string,1589 patch: Partial<VaultEntry>1590): Promise<void> {1591 if (!derivedKey) throw new Error("vault_locked");15921593 const idx = entries.findIndex((entry) => entry.id === id);1594 if (idx === -1 || entries[idx].deletedAt) {1595 throw new Error("entry_not_found");1596 }15971598 const current = entries[idx];1599 const sanitizedPatch = sanitizeEntryPatch(patch);1600 1601 1602 1603 const passwordChanged =1604 "password" in patch &&1605 typeof sanitizedPatch.password === "string" &&1606 sanitizedPatch.password !== current.password;1607 entries[idx] = {1608 ...current,1609 ...sanitizedPatch,1610 modifiedAt: Date.now(),1611 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1612 };1613161416151616 1617 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1618 entries[idx] = current;1619 throw new Error("card number is required for card entries");1620 }16211622 await persistEntries();16231624 1625 1626 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1627 entries[idx] = { ...entries[idx], breach: undefined };1628 await persistEntries();1629 const password = entries[idx].password;1630 if (password) {1631 checkBreach(password).then(async (count) => {1632 const i = entries.findIndex((e) => e.id === id);1633 if (i !== -1 && count > 0) {1634 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1635 await persistEntries();1636 }1637 });1638 }1639 }16401641 fireSyncTrigger("vault_change");1642}16431644export async function deleteEntry(id: string): Promise<void> {1645 if (!derivedKey) throw new Error("vault_locked");16461647 const entry = entries.find((item) => item.id === id);1648 if (!entry || entry.deletedAt) {1649 throw new Error("entry_not_found");1650 }16511652 entry.deletedAt = Date.now();1653 entry.modifiedAt = Date.now();1654 await persistEntries();1655 fireSyncTrigger("vault_change");1656}16571658export function getDeletedEntries(): VaultEntry[] {1659 if (!derivedKey) return [];1660 return entries.filter((e) => !!e.deletedAt);1661}1662166316641665166616671668export async function getVaultVersion(): Promise<number> {1669 try {1670 const raw = await chrome.storage.local.get("peach_last_modified_at");1671 const v = (raw as Record<string, unknown>).peach_last_modified_at;1672 if (typeof v === "number") return v;1673 if (typeof v === "string" && v !== "") {1674 const n = Number.parseInt(v, 10);1675 return Number.isFinite(n) ? n : 0;1676 }1677 return 0;1678 } catch {1679 return 0;1680 }1681}168216831684168516861687168816891690169116921693export async function addEntryDirect(entry: VaultEntry): Promise<void> {1694 if (!derivedKey) throw new Error("vault_locked");1695 entries = [...entries, entry];1696 await persistEntries("sync_received");1697}16981699export async function updateEntryDirect(patch: VaultEntry): Promise<void> {1700 if (!derivedKey) throw new Error("vault_locked");1701 const idx = entries.findIndex((e) => e.id === patch.id);1702 if (idx === -1) throw new Error("entry_not_found");1703 entries[idx] = { ...entries[idx], ...patch };1704 await persistEntries("sync_received");1705}17061707export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {1708 if (!derivedKey) throw new Error("vault_locked");1709 const idx = entries.findIndex((e) => e.id === id);1710 if (idx === -1) throw new Error("entry_not_found");1711 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };1712 await persistEntries("sync_received");1713}1714171517161717171817191720172117221723172417251726export async function applyMergedRemoteEntries(1727 remoteEntries: VaultEntry[],1728 remoteTombstones: Record<string, number>,1729): Promise<{ received: number; applied: number; conflicts: number }> {1730 if (!derivedKey) throw new Error("vault_locked");17311732 const received = remoteEntries.length + Object.keys(remoteTombstones).length;1733 let applied = 0;1734 let conflicts = 0;17351736 const byId = new Map<string, VaultEntry>();1737 for (const e of entries) byId.set(e.id, e);17381739 for (const remote of remoteEntries) {1740 const sanitized = sanitizeRestoredEntry(remote);1741 if (!sanitized) continue;1742 const tombstoneTs = remoteTombstones[sanitized.id];1743 if (tombstoneTs !== undefined && tombstoneTs >= sanitized.modifiedAt) {1744 continue;1745 }1746 const local = byId.get(sanitized.id);1747 if (!local) {1748 entries.push(sanitized);1749 byId.set(sanitized.id, sanitized);1750 applied += 1;1751 } else if (sanitized.modifiedAt > local.modifiedAt) {1752 const idx = entries.findIndex((e) => e.id === local.id);1753 if (idx !== -1) {1754 entries[idx] = sanitized;1755 byId.set(sanitized.id, sanitized);1756 applied += 1;1757 if (!local.deletedAt) conflicts += 1;1758 }1759 }1760 }17611762 for (const [id, deletedAt] of Object.entries(remoteTombstones)) {1763 if (typeof deletedAt !== "number" || !Number.isFinite(deletedAt)) continue;1764 const local = byId.get(id);1765 if (!local) continue;1766 if (local.deletedAt && local.deletedAt >= deletedAt) continue;1767 if (deletedAt < local.modifiedAt) continue;1768 const idx = entries.findIndex((e) => e.id === id);1769 if (idx !== -1) {1770 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };1771 applied += 1;1772 }1773 }17741775 if (applied > 0) {1776 await persistEntries("sync_received");1777 buildDomainIndexSync(entries);1778 }1779 return { received, applied, conflicts };1780}17811782export async function restoreEntry(id: string): Promise<void> {1783 if (!derivedKey) throw new Error("vault_locked");1784 const entry = entries.find((e) => e.id === id && e.deletedAt);1785 if (!entry) throw new Error("deleted entry not found");1786 delete entry.deletedAt;1787 entry.modifiedAt = Date.now();1788 await persistEntries();1789 buildDomainIndexSync(entries);1790}17911792export async function purgeEntry(id: string): Promise<void> {1793 if (!derivedKey) throw new Error("vault_locked");1794 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1795 if (idx === -1) throw new Error("deleted entry not found");1796 entries.splice(idx, 1);1797 await persistEntries();1798}179918001801export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1802 if (!derivedKey || retentionMs === 0) return 0;1803 const cutoff = Date.now() - retentionMs;1804 const before = entries.length;1805 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1806 const purged = before - entries.length;1807 if (purged > 0) await persistEntries();1808 return purged;1809}18101811export function queryDomain(domain: string): VaultEntry[] {1812 return domainIndex.get(domain) ?? [];1813}18141815export function getEntryById(id: string): VaultEntry | undefined {1816 return entries.find((entry) => entry.id === id && !entry.deletedAt);1817}18181819async function persistSlotA(1820 reason: VaultSnapshot["reason"] = "local_save",1821): Promise<StoredVault> {1822 if (!derivedKey) throw new Error("vault_locked");18231824 const slotA = await loadSlot(SLOT_A);1825 if (!slotA) throw new Error("missing slot A");18261827 1828 1829 await takeVaultSnapshot(reason);18301831 const plaintext = encodePayload(entries, duressConfigured);1832 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");18331834 1835 const nextSlotA: StoredVault = {1836 ...slotA,1837 nonce,1838 ciphertext,1839 tag,1840 1841 };1842 await writeSlot(SLOT_A, nextSlotA);18431844 1845 1846 1847 if (!duressConfigured) {1848 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1849 }1850 1851 1852 1853 1854 1855 if (reason !== "sync_received") {1856 await stampLocalVaultMutation();1857 }18581859 return nextSlotA;1860}18611862async function persistSlotB(): Promise<void> {1863 if (!derivedKey) throw new Error("vault_locked");18641865 const slotB = await loadSlot(SLOT_B);1866 if (!slotB) throw new Error("missing slot B");18671868 1869 1870 const plaintext = encodePayload(entries, false);1871 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");18721873 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1874 await stampLocalVaultMutation();1875}18761877async function persistEntries(1878 reason: VaultSnapshot["reason"] = "local_save",1879): Promise<void> {1880 if (!derivedKey) throw new Error("vault_locked");18811882 if (vaultMode === "decoy") {1883 await persistSlotB();1884 } else {1885 await persistSlotA(reason);1886 }1887 buildDomainIndexSync(entries);1888}18891890function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1891 if (!Array.isArray(values)) return undefined;1892 const seen = new Set<string>();1893 const cleaned: string[] = [];1894 for (const value of values) {1895 const trimmed = typeof value === "string" ? value.trim() : "";1896 if (!trimmed) continue;1897 const key = trimmed.toLowerCase();1898 if (seen.has(key)) continue;1899 seen.add(key);1900 cleaned.push(trimmed);1901 }1902 return cleaned.length > 0 ? cleaned : undefined;1903}19041905function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1906 const title = entry.title.trim();1907 1908 1909 1910 let type: VaultEntry["type"] = entry.type ?? "login";1911 if (type === "passkey") type = "login";19121913 if (!title) throw new Error("title is required");19141915 if (entry.passkey) {1916 const p = entry.passkey;1917 if (1918 !p.rpId ||1919 !p.credentialId ||1920 !p.publicKeyCose ||1921 !p.userHandle ||1922 !p.userName ||1923 (p.algorithm !== -7 && p.algorithm !== -8)1924 ) {1925 throw new Error("invalid passkey credential");1926 }1927 }1928 if (type === "card") {1929 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1930 throw new Error("cardholder name and card number are required");1931 }1932 }19331934 return {1935 type,1936 title,1937 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1938 email: type === "login" ? entry.email?.trim() || undefined : undefined,1939 password: type === "login" ? entry.password : undefined,1940 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1941 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1942 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1943 notes: entry.notes?.trim() || undefined,1944 breach: type === "login" ? entry.breach : undefined,1945 canary: type === "login" ? entry.canary : undefined,1946 tags: entry.tags,1947 passkey: entry.passkey,1948 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1949 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1950 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1951 expYear: type === "card" ? entry.expYear?.trim() : undefined,1952 cvc: type === "card" ? entry.cvc?.trim() : undefined,1953 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1954 network: type === "card" ? entry.network : undefined,1955 last4: type === "card" ? entry.last4?.trim() : undefined,1956 };1957}19581959function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1960 const nextPatch: Partial<VaultEntry> = {};19611962 if ("type" in patch) {1963 nextPatch.type = patch.type;1964 }19651966 if ("title" in patch) {1967 const title = patch.title?.trim() ?? "";1968 if (!title) throw new Error("title is required");1969 nextPatch.title = title;1970 }19711972 if ("url" in patch) {1973 nextPatch.url = patch.url?.trim() ?? "";1974 }19751976 if ("additionalUrls" in patch) {1977 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1978 const seen = new Set<string>();1979 const cleaned: string[] = [];1980 for (const value of incoming) {1981 const trimmed = typeof value === "string" ? value.trim() : "";1982 if (!trimmed) continue;1983 const key = trimmed.toLowerCase();1984 if (seen.has(key)) continue;1985 seen.add(key);1986 cleaned.push(trimmed);1987 }1988 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1989 }19901991 if ("username" in patch) {1992 nextPatch.username = patch.username?.trim() ?? "";1993 }19941995 if ("email" in patch) {1996 nextPatch.email = patch.email?.trim() || undefined;1997 }19981999 if ("password" in patch) {2000 const password = patch.password ?? "";2001 2002 nextPatch.password = password || undefined;2003 }20042005 if ("totp" in patch) {2006 nextPatch.totp = patch.totp?.trim() || undefined;2007 }20082009 if ("notes" in patch) {2010 nextPatch.notes = patch.notes?.trim() || undefined;2011 }20122013 if ("breach" in patch) {2014 nextPatch.breach = patch.breach;2015 }20162017 if ("canary" in patch) {2018 nextPatch.canary = patch.canary;2019 }20202021 if ("passkey" in patch) {2022 2023 2024 2025 nextPatch.passkey = patch.passkey;2026 }20272028 if ("tags" in patch) {2029 nextPatch.tags = patch.tags;2030 }20312032 if ("cardholderName" in patch) {2033 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2034 }2035 if ("cardNumber" in patch) {2036 const n = patch.cardNumber ?? "";2037 nextPatch.cardNumber = n || undefined;2038 }2039 if ("expMonth" in patch) {2040 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2041 }2042 if ("expYear" in patch) {2043 nextPatch.expYear = patch.expYear?.trim() || undefined;2044 }2045 if ("cvc" in patch) {2046 nextPatch.cvc = patch.cvc?.trim() || undefined;2047 }2048 if ("cardNickname" in patch) {2049 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2050 }2051 if ("network" in patch) {2052 nextPatch.network = patch.network;2053 }2054 if ("last4" in patch) {2055 nextPatch.last4 = patch.last4?.trim() || undefined;2056 }20572058 return nextPatch;2059}20602061function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2062 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2063 const modifiedAt =2064 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;20652066 const base: VaultEntry = {2067 id: entry.id,2068 type: "type" in entry ? entry.type : "login",2069 title: entry.title,2070 username: entry.username,2071 email: "email" in entry ? entry.email : undefined,2072 password: entry.password,2073 url: entry.url,2074 additionalUrls:2075 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2076 ? entry.additionalUrls2077 : undefined,2078 totp: entry.totp,2079 notes: entry.notes,2080 breach: entry.breach,2081 2082 2083 2084 passwordChangedAt:2085 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2086 canary: "canary" in entry ? entry.canary : undefined,2087 passkey: "passkey" in entry ? entry.passkey : undefined,2088 createdAt,2089 modifiedAt,2090 deletedAt: entry.deletedAt,2091 tags: "tags" in entry ? entry.tags : undefined,2092 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2093 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2094 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2095 expYear: "expYear" in entry ? entry.expYear : undefined,2096 cvc: "cvc" in entry ? entry.cvc : undefined,2097 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2098 network: "network" in entry ? entry.network : undefined,2099 last4: "last4" in entry ? entry.last4 : undefined,2100 };21012102 return migrateLegacyPasskey(base, entry);2103}210421052106210721082109211021112112function migrateLegacyPasskey(2113 base: VaultEntry,2114 raw: VaultEntry | LegacyVaultEntry2115): VaultEntry {2116 if (base.passkey) return base;2117 const flat = raw as Partial<VaultEntry>;2118 const isPasskeyType = base.type === "passkey";2119 const hasFlatPasskeyFields =2120 typeof flat.rpId === "string" &&2121 typeof flat.credentialId === "string" &&2122 typeof flat.publicKeyCose === "string" &&2123 typeof flat.userHandle === "string" &&2124 typeof flat.userName === "string";2125 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2126 if (!hasFlatPasskeyFields) return base;21272128 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2129 base.passkey = {2130 rpId: flat.rpId!,2131 rpName: flat.rpName ?? flat.rpId!,2132 credentialId: flat.credentialId!,2133 publicKeyCose: flat.publicKeyCose!,2134 privateKeyJwk: flat.privateKeyJwk,2135 userHandle: flat.userHandle!,2136 userName: flat.userName!,2137 userDisplayName: flat.userDisplayName ?? flat.userName!,2138 algorithm,2139 signCount: flat.signCount ?? 0,2140 aaguid: flat.aaguid ?? "",2141 backupEligible: flat.backupEligible === true,2142 backupState: flat.backupState === true,2143 lastUsedAt: flat.lastUsedAt,2144 browser: flat.browser,2145 transports: flat.transports,2146 createdAt: base.createdAt,2147 };2148 2149 2150 2151 if (isPasskeyType) base.type = "login";2152 if (!base.username) base.username = flat.userName;2153 if (!base.url) base.url = `https://${flat.rpId}`;2154 return base;2155}2156215721582159216021612162function entryDupeKey(e: VaultEntry): string {2163 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2164 2165 2166 2167 2168 if (e.passkey) {2169 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2170 }2171 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2172}217321742175export function countDuplicates(): number {2176 const best = new Map<string, VaultEntry>();2177 let dupeCount = 0;2178 for (const e of entries.filter((e) => !e.deletedAt)) {2179 const key = entryDupeKey(e);2180 const existing = best.get(key);2181 if (!existing) {2182 best.set(key, e);2183 } else {2184 dupeCount++;2185 2186 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2187 best.set(key, e);2188 }2189 }2190 }2191 return dupeCount;2192}219321942195219621972198export async function deduplicateEntries(): Promise<number> {2199 if (!derivedKey) throw new Error("vault_locked");2200 2201 2202 if (vaultMode === "decoy") {2203 throw new Error("deduplicate disabled in decoy mode");2204 }22052206 const best = new Map<string, VaultEntry>();2207 for (const e of entries.filter((en) => !en.deletedAt)) {2208 const key = entryDupeKey(e);2209 const existing = best.get(key);2210 if (!existing) {2211 best.set(key, e);2212 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2213 best.set(key, e);2214 }2215 }22162217 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2218 const now = Date.now();2219 let removed = 0;2220 entries = entries.map((e) => {2221 if (!e.deletedAt && !keepIds.has(e.id)) {2222 removed++;2223 return { ...e, deletedAt: now };2224 }2225 return e;2226 });22272228 if (removed > 0) await persistEntries();2229 return removed;2230}223122322233223422352236223722382239const HISTORY_KEY = "peach_vault_history";2240const HISTORY_MAX = 10;22412242224322442245224622472248export async function takeVaultSnapshot(2249 reason: VaultSnapshot["reason"],2250): Promise<void> {2251 try {2252 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2253 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2254 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];22552256 2257 2258 2259 if (!slotA) return;22602261 const vault_a = JSON.stringify(slotA);2262 const vault_b = JSON.stringify(slotB ?? slotA);22632264 2265 2266 2267 let entryCount: number | null = null;2268 if (derivedKey && vaultMode === "real") {2269 try {2270 entryCount = entries.filter((e) => !e.deletedAt).length;2271 } catch {2272 entryCount = null;2273 }2274 }22752276 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2277 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2278 ? existingRaw2279 : { snapshots: [] };22802281 2282 2283 2284 const latest = history.snapshots[0];2285 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2286 return;2287 }22882289 const snapshot: VaultSnapshot = {2290 id: crypto.randomUUID(),2291 savedAt: Date.now(),2292 reason,2293 vault_a,2294 vault_b,2295 entryCount,2296 };22972298 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2299 await chrome.storage.local.set({ [HISTORY_KEY]: history });2300 } catch (err) {2301 2302 2303 2304 2305 console.warn('[Peach] takeVaultSnapshot failed:', err);2306 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2307 try {2308 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2309 } catch {2310 2311 }2312 }2313 }2314}231523162317export async function clearVaultHistory(): Promise<void> {2318 try {2319 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2320 } catch {2321 2322 }2323}232423252326export async function getVaultHistory(): Promise<VaultSnapshot[]> {2327 const raw = await chrome.storage.local.get(HISTORY_KEY);2328 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2329 if (!history || !Array.isArray(history.snapshots)) return [];2330 return history.snapshots;2331}23322333233423352336233723382339export async function restoreVaultSnapshot(2340 snapshotId: string,2341): Promise<{ ok: true } | { ok: false; error: string }> {2342 const history = await getVaultHistory();2343 const snapshot = history.find((s) => s.id === snapshotId);2344 if (!snapshot) return { ok: false, error: "snapshot_not_found" };23452346 2347 2348 2349 await takeVaultSnapshot("restore");23502351 let restoredA: StoredVault;2352 let restoredB: StoredVault;2353 try {2354 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2355 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2356 } catch {2357 return { ok: false, error: "snapshot_corrupt" };2358 }23592360 await writeSlot(SLOT_A, restoredA);2361 await writeSlot(SLOT_B, restoredB);2362 await stampLocalVaultMutation();23632364 2365 2366 2367 lock();23682369 return { ok: true };2370}23712372237323742375237623772378export async function changePassword(2379 currentPassword: string,2380 newPassword: string2381): Promise<boolean> {2382 if (!derivedKey || vaultMode !== "real") return false;23832384 2385 const valid = await verifyMainPassword(currentPassword);2386 if (!valid) return false;23872388 const slotA = await loadSlot(SLOT_A);2389 if (!slotA) return false;2390 if (!rootKey) return false;23912392 2393 const newSalt = generateSalt();2394 const newKey = await deriveKey(newPassword, newSalt);23952396 2397 const plaintext = encodePayload(entries, duressConfigured);2398 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");23992400 2401 2402 2403 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);24042405 const finalSlotA: StoredVault = {2406 ...slotA,2407 salt: hex(newSalt),2408 nonce,2409 ciphertext,2410 tag,2411 ...pwRoot,2412 };24132414 2415 2416 2417 await takeVaultSnapshot("local_save");24182419 2420 2421 2422 2423 await writeSlot(SLOT_A, finalSlotA);24242425 2426 2427 2428 zeroKey(derivedKey);2429 derivedKey = newKey;24302431 2432 2433 2434 if (!duressConfigured) {2435 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2436 }2437 await stampLocalVaultMutation();24382439 return true;2440}