123456789101112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import { clampSyncTimestamp } from "../shared/sync-limits";15import { computeVaultId } from "../shared/sync-identity";161718import { triggerSyncIfReachable } from "./sync-trigger";19import { clearPairedDevices } from "./paired-devices";20import {21 rootKeyToWords,22 wordsToRootKeyMaterial,23 deriveRecoveryKey,24} from "../shared/recovery-words";25import type {26 AccentColorName,27 Canary,28 ManualSummonRecord,29 PasskeyData,30 SavedIdentity,31 StoredVault,32 VaultEntry,33 VaultHistory,34 VaultSnapshot,35} from "../shared/types";36import { decrypt, encrypt, zeroKey } from "./aes";37import { clearAutoLock, touchAutoLock } from "./alarms";38import { setRecoveryConfirmPending } from "./recovery-pending";3940type EntryDraft = Omit<41 VaultEntry,42 "id" | "createdAt" | "modifiedAt" | "deletedAt"43>;444546474849const SLOT_A = "peach_vault_a";50const SLOT_B = "peach_vault_b";51525354const PAD_BLOCK = 1024;5556575859const PASSWORD_HISTORY_MAX = 50;6061626364656667686970const DURESS_SLOT_SIZE = 262_144;7172let derivedKey: Uint8Array | null = null;7374757677let rootKey: Uint8Array | null = null;78let entries: VaultEntry[] = [];79808182let tombstones = new Map<string, number>();83let domainIndex: Map<string, VaultEntry[]> = new Map();84let vaultMode: "real" | "decoy" = "real";85868788let duressConfigured = false;8990919293949596979899let lastUnlockFailureReason: string | null = null;100101export function consumeLastUnlockFailureReason(): string | null {102 const r = lastUnlockFailureReason;103 lastUnlockFailureReason = null;104 return r;105}106107108109110111export type TextSize = "small" | "medium" | "large";112113export interface VaultSettings {114 accentColor?: AccentColorName;115 theme?: "dark" | "light";116 autoLockMinutes?: number;117 clipboardClearSeconds?: number;118 lockOnClose?: boolean;119 breachMonitoring?: boolean;120 passkeyProviderEnabled?: boolean;121 trashRetentionDays?: number;122 manualSummonDomains?: Record<string, ManualSummonRecord>;123 declinedCardFingerprints?: string[];124 125126127128129 textSize?: TextSize;130 131132133134135 reduceMotion?: boolean;136 137 pinEnabled?: boolean;138 139 pinPreferred?: boolean;140 141142143144145146147 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;148}149150interface StoredPinWrap {151 version: 1;152 salt: string;153 nonce: string;154 ciphertext: string;155 tag: string;156}157158const PIN_WRAP_STORAGE = "peach_pin_wrap";159const PIN_MIN_LENGTH = 4;160const PIN_MAX_LENGTH = 12;161162let settings: VaultSettings = {};163164export function getSettings(): VaultSettings {165 return settings;166}167168export async function ensureSettingsLoaded(): Promise<VaultSettings> {169 await loadSettings();170 return settings;171}172173export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {174 await loadSettings();175 176 177 178 179 const safe = Object.fromEntries(180 Object.entries(patch).filter(181 ([k]) => !["__proto__", "constructor", "prototype"].includes(k),182 ),183 ) as Partial<VaultSettings>;184 settings = { ...settings, ...safe };185 await persistSettings();186}187188function isValidPin(pin: string): boolean {189 return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);190}191192193export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {194 if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;195 if (!(await verifyMainPassword(currentPassword))) return false;196197 const salt = generateSalt();198 const pinKey = await deriveKey(pin, salt);199 try {200 const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");201 await chrome.storage.local.set({202 [PIN_WRAP_STORAGE]: { version: 1, salt: hex(salt), ...wrapped } satisfies StoredPinWrap,203 });204 await updateSettings({ pinEnabled: true });205 return true;206 } finally {207 zeroKey(pinKey);208 }209}210211async function clearPinUnlockStorage(): Promise<void> {212 await chrome.storage.local.remove(PIN_WRAP_STORAGE);213 await updateSettings({ pinEnabled: false });214}215216217export async function disablePinUnlock(currentPassword: string): Promise<boolean> {218 if (!derivedKey || vaultMode !== "real") return false;219 if (!(await verifyMainPassword(currentPassword))) return false;220 await clearPinUnlockStorage();221 return true;222}223224225export async function unlockWithPin(pin: string): Promise<boolean> {226 if (!isValidPin(pin)) return false;227 await loadSettings();228 if (!settings.pinEnabled) return false;229230 const stored = (await chrome.storage.local.get(PIN_WRAP_STORAGE))[PIN_WRAP_STORAGE] as231 | StoredPinWrap232 | undefined;233 if (234 !stored ||235 stored.version !== 1 ||236 !stored.salt ||237 !stored.nonce ||238 !stored.ciphertext ||239 !stored.tag240 ) return false;241242 const pinKey = await deriveKey(pin, unhex(stored.salt));243 let unwrappedKey: Uint8Array | null = null;244 try {245 const masterKeyHex = await decrypt(246 pinKey,247 stored.nonce,248 stored.ciphertext,249 stored.tag,250 "pin_wrap_v1",251 );252 if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;253 unwrappedKey = unhex(masterKeyHex);254 const result = await unlockWithKey(masterKeyHex);255 if (!result.ok) return false;256 await saveSessionKey(unwrappedKey, "real");257 return true;258 } catch {259 return false;260 } finally {261 zeroKey(pinKey);262 if (unwrappedKey) zeroKey(unwrappedKey);263 }264}265266async function persistSettings(): Promise<void> {267 try {268 await chrome.storage.local.set({ peach_settings: settings });269 } catch (err) {270 throw mapStorageError(err);271 }272}273274function mapStorageError(err: unknown): Error {275 const message = err instanceof Error ? err.message : String(err);276 if (/QUOTA_BYTES|quota/i.test(message)) {277 return new Error("STORAGE_QUOTA_EXCEEDED");278 }279 return err instanceof Error ? err : new Error(message);280}281282async function loadSettings(): Promise<void> {283 const result = await chrome.storage.local.get("peach_settings");284 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};285}286287288289function fireSyncTrigger(reason: "vault_change"): void {290 void Promise.resolve()291 .then(() => triggerSyncIfReachable({ reason }))292 .catch(() => {293 294 });295}296297let lockedSyncRefreshHook: (() => Promise<void>) | null = null;298let lockedSyncUnlockHook: (() => Promise<void>) | null = null;299300export function setLockedSyncHooks(hooks: {301 refresh: () => Promise<void>;302 afterUnlock: () => Promise<void>;303}): void {304 lockedSyncRefreshHook = hooks.refresh;305 lockedSyncUnlockHook = hooks.afterUnlock;306}307308async function refreshLockedSyncState(): Promise<void> {309 if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;310 await lockedSyncRefreshHook().catch((error) => {311 console.warn("[Peach sync] locked spool refresh failed:", error);312 });313}314315316317318319export async function checkBreach(password: string): Promise<number> {320 321 322 const encoder = new TextEncoder();323 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));324 const hashHex = Array.from(new Uint8Array(hashBuffer))325 .map((b) => b.toString(16).padStart(2, "0"))326 .join("")327 .toUpperCase();328329 const prefix = hashHex.slice(0, 5);330 const suffix = hashHex.slice(5);331332 try {333 334 335 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {336 headers: { "Add-Padding": "true" },337 });338 if (!res.ok) return 0;339 const text = await res.text();340 const match = text.split("\r\n").find((l) => l.startsWith(suffix));341 return match ? parseInt(match.split(":")[1], 10) : 0;342 } catch {343 return 0; 344 }345}346347export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {348 const fingerprint = await cardFingerprint(last4, expMonth, expYear);349 await loadSettings();350 settings = {351 ...settings,352 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],353 };354 await persistSettings();355}356357export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {358 await loadSettings();359 const fingerprint = await cardFingerprint(last4, expMonth, expYear);360 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);361}362363async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {364 const encoder = new TextEncoder();365 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));366 return Array.from(new Uint8Array(buf).slice(0, 8))367 .map((b) => b.toString(16).padStart(2, "0"))368 .join("");369}370371function hex(buf: Uint8Array): string {372 return Array.from(buf)373 .map((b) => b.toString(16).padStart(2, "0"))374 .join("");375}376377function unhex(s: string): Uint8Array {378 const out = new Uint8Array(s.length / 2);379 for (let i = 0; i < s.length; i += 2) {380 out[i / 2] = parseInt(s.slice(i, i + 2), 16);381 }382 return out;383}384385386387388389390391392393394395396397398399400401402403404async function generateAndWrapRootKey(): Promise<{405 rawRootKey: string; 406 recoverySalt: string;407 wrappedRootKey: string;408 wrappedNonce: string;409 wrappedTag: string;410}> {411 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));412413 414 const words = await rootKeyToWords(rootKeyBytes);415 const wordMaterial = await wordsToRootKeyMaterial(words);416 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));417 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);418419 420 421 const nonce = crypto.getRandomValues(new Uint8Array(12));422 const encoded = new TextEncoder().encode(hex(rootKeyBytes));423 const raw = await crypto.subtle.encrypt(424 {425 name: "AES-GCM",426 iv: nonce,427 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,428 tagLength: 128,429 },430 recoveryKey,431 encoded432 );433 434 const rawU8 = new Uint8Array(raw);435 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));436 const tag = hex(rawU8.slice(rawU8.length - 16));437438 return {439 rawRootKey: hex(rootKeyBytes),440 recoverySalt: hex(recoverySalt),441 wrappedRootKey: ciphertext,442 wrappedNonce: hex(nonce),443 wrappedTag: tag,444 };445}446447async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{448 recoverySalt: string;449 wrappedRootKey: string;450 wrappedNonce: string;451 wrappedTag: string;452}> {453 if (rootKeyBytes.length !== 32) {454 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");455 }456457 const words = await rootKeyToWords(rootKeyBytes);458 const wordMaterial = await wordsToRootKeyMaterial(words);459 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));460 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);461 const nonce = crypto.getRandomValues(new Uint8Array(12));462 const encoded = new TextEncoder().encode(hex(rootKeyBytes));463 const raw = await crypto.subtle.encrypt(464 {465 name: "AES-GCM",466 iv: nonce,467 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,468 tagLength: 128,469 },470 recoveryKey,471 encoded,472 );473 const rawU8 = new Uint8Array(raw);474 return {475 recoverySalt: hex(recoverySalt),476 wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),477 wrappedNonce: hex(nonce),478 wrappedTag: hex(rawU8.slice(rawU8.length - 16)),479 };480}481482483484485486487export async function unwrapRootKeyForRecovery(488 wrappedRootKey: string,489 wrappedNonce: string,490 wrappedTag: string,491 recoverySaltHex: string,492 words: string[]493): Promise<void> {494 const wordMaterial = await wordsToRootKeyMaterial(words);495 const recoverySalt = unhex(recoverySaltHex);496 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);497498 499 500 501 502 503 const nonceRaw = unhex(wrappedNonce);504 const ciphertextRaw = unhex(wrappedRootKey);505 const tagRaw = unhex(wrappedTag);506 507 508 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;509 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);510 combined.set(ciphertextRaw, 0);511 combined.set(tagRaw, ciphertextRaw.length);512 const raw = await crypto.subtle.decrypt(513 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },514 recoveryKey,515 combined516 );517 const decryptedHex = new TextDecoder().decode(raw);518 rootKey = unhex(decryptedHex);519}520521522523524525526527528529530export async function rewrapRootKeyForPassword(531 newDerivedKey: Uint8Array,532 _newSalt: Uint8Array533): Promise<void> {534 if (!rootKey) throw new Error("root key not set");535 const slotA = await loadSlot(SLOT_A);536 if (!slotA) throw new Error("missing slot A");537 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);538 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });539}540541542543544545546async function reencryptVaultForNewKey(547 newKey: Uint8Array,548 slotA: StoredVault549): Promise<void> {550 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);551 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");552 await writeSlot(SLOT_A, {553 ...slotA,554 nonce,555 ciphertext,556 tag,557 });558}559560561562563564565export async function changePasswordWithNewKey(566 newSalt: Uint8Array,567 newKey: Uint8Array568): Promise<void> {569 const slotA = await loadSlot(SLOT_A);570 if (!slotA) throw new Error("missing slot A");571 572 573 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });574 if (derivedKey) zeroKey(derivedKey);575 derivedKey = newKey;576 577 578 579 void saveSessionKey(newKey, "real");580 581 582 583 584 if (rootKey) {585 const slotAfter = await loadSlot(SLOT_A);586 if (slotAfter) {587 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);588 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });589 }590 }591 if (!duressConfigured) {592 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));593 }594 await clearPinUnlockStorage();595 await stampLocalVaultMutation();596}597598599600601602603export { deriveKey, generateSalt };604605export async function loadSlotA(): Promise<StoredVault | null> {606 return loadSlot(SLOT_A);607}608609610611612613614615616617618async function deriveRootKeyFromDerivedKey(619 derivedKeyBytes: Uint8Array,620 slotA: StoredVault,621): Promise<Uint8Array | null> {622 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {623 return null;624 }625 try {626 const rootHex = await decrypt(627 derivedKeyBytes,628 slotA.pw_root_nonce,629 slotA.pw_root_ciphertext,630 slotA.pw_root_tag,631 "pw_root_v1",632 );633 return unhex(rootHex);634 } catch {635 return null;636 }637}638639640641async function wrapRootKeyForDerivedKey(642 derivedKeyBytes: Uint8Array,643 rootKeyBytes: Uint8Array,644): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {645 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");646 return {647 pw_root_ciphertext: ciphertext,648 pw_root_nonce: nonce,649 pw_root_tag: tag,650 };651}652653654export async function getRecoveryWords(): Promise<string[]> {655 if (!rootKey) {656 throw new Error("recovery not set up");657 }658 return rootKeyToWords(rootKey);659}660661async function loadSlot(key: string): Promise<StoredVault | null> {662 const result = await chrome.storage.local.get(key);663 return (result as Record<string, StoredVault | undefined>)[key] ?? null;664}665666async function writeSlot(key: string, vault: StoredVault): Promise<void> {667 try {668 await chrome.storage.local.set({ [key]: vault });669 } catch (err) {670 throw mapStorageError(err);671 }672}673674675676677678679680681682683export async function rollbackAdoptedVault(): Promise<void> {684 lock();685 try {686 await chrome.storage.local.remove([687 SLOT_A,688 SLOT_B,689 "peach_last_modified_at",690 ]);691 } catch (err) {692 throw mapStorageError(err);693 }694}695696async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {697 const raw = await chrome.storage.local.get("peach_last_modified_at");698 const previous = Number(699 (raw as Record<string, unknown>).peach_last_modified_at,700 );701 const next = Math.max(702 Date.now(),703 Number.isFinite(previous) ? previous + 1 : 0,704 );705 try {706 await chrome.storage.local.set({707 [SLOT_A]: slotA,708 [SLOT_B]: slotB,709 peach_last_modified_at: String(next),710 });711 } catch (err) {712 throw mapStorageError(err);713 }714}715716717718719720721722723let lastEntryTimestamp = 0;724function monotonicEntryTimestamp(currentModifiedAt?: number): number {725 const base = Math.max(726 Date.now(),727 (currentModifiedAt ?? 0) + 1,728 lastEntryTimestamp + 1,729 );730 lastEntryTimestamp = base;731 return base;732}733734async function stampLocalVaultMutation(): Promise<void> {735 const raw = await chrome.storage.local.get("peach_last_modified_at");736 const previous = Number(737 (raw as Record<string, unknown>).peach_last_modified_at,738 );739 const next = Math.max(740 Date.now(),741 Number.isFinite(previous) ? previous + 1 : 0,742 );743 await chrome.storage.local.set({744 peach_last_modified_at: String(next),745 });746}747748749750751752753754755756757758function padPlaintext(json: string, minBytes = 0): string {759 const encoded = new TextEncoder().encode(json);760 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);761 762 const target = Math.max(bucket, minBytes);763 const padLen = target - encoded.length;764 return json + " ".repeat(padLen);765}766767768769770771772773774775776777const CSPRNG_CHUNK = 65_536;778779function fillRandom(buf: Uint8Array): Uint8Array {780 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {781 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);782 const chunk = new Uint8Array(end - offset);783 crypto.getRandomValues(chunk);784 buf.set(chunk, offset);785 }786 return buf;787}788789function generateNoiseBlob(ciphertextBytes: number): StoredVault {790 const salt = crypto.getRandomValues(new Uint8Array(16));791 const nonce = crypto.getRandomValues(new Uint8Array(12));792 const cipher = fillRandom(new Uint8Array(ciphertextBytes));793 const tag = crypto.getRandomValues(new Uint8Array(16));794 return {795 version: 1,796 salt: hex(salt),797 nonce: hex(nonce),798 ciphertext: hex(cipher),799 tag: hex(tag),800 };801}802803804805806807type VaultPayload = {808 entries: VaultEntry[];809 tombstones?: Record<string, number>;810 duressConfigured?: boolean;811};812813type DecryptedPayload = {814 entries: VaultEntry[];815 tombstones: Map<string, number>;816 duressConfigured: boolean;817};818819function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {820 const envelope: VaultPayload = {821 entries,822 tombstones: Object.fromEntries(tombstones),823 duressConfigured: duress,824 };825 return padPlaintext(JSON.stringify(envelope), minBytes);826}827828function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {829 const result = new Map<string, number>();830 if (raw && typeof raw === "object" && !Array.isArray(raw)) {831 for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {832 if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {833 result.set(id, timestamp);834 }835 }836 }837 838 839 for (const entry of allEntries) {840 if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {841 continue;842 }843 if ((result.get(entry.id) ?? 0) < entry.deletedAt) {844 result.set(entry.id, entry.deletedAt);845 }846 }847 return result;848}849850function reconcileTombstones(): void {851 const byId = new Map(entries.map((entry) => [entry.id, entry]));852 for (const [id] of tombstones) {853 if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);854 }855 for (const entry of entries) {856 if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {857 if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {858 tombstones.set(entry.id, entry.deletedAt);859 }860 }861 }862}863864function decodePayload(plain: string): DecryptedPayload {865 const parsed = JSON.parse(plain) as VaultPayload;866 if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {867 throw new Error("invalid vault payload");868 }869 const normalized = parsed.entries.map(normalizeEntry);870 return {871 entries: normalized,872 tombstones: decodeTombstones(parsed.tombstones, normalized),873 duressConfigured: parsed.duressConfigured === true,874 };875}876877878879880881882883884885886887888889890891892893894895896897function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {898 let merged = false;899 const now = Date.now();900901 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);902 const byRpId = new Map<string, VaultEntry[]>();903 for (const e of candidates) {904 const rpId = e.passkey!.rpId;905 const list = byRpId.get(rpId);906 if (list) list.push(e);907 else byRpId.set(rpId, [e]);908 }909910 for (const [rpId, group] of byRpId) {911 if (group.length === 0) continue;912 group.sort((a, b) => {913 const aT =914 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;915 const bT =916 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;917 return bT - aT;918 });919 const winner = group[0]!;920921 922 923 924 const loginTarget = entries.find((e) => {925 if (e.deletedAt || e.passkey || e.type !== "login") return false;926 if (e.url && extractDomain(e.url) === rpId) return true;927 for (const extra of e.additionalUrls ?? []) {928 if (extractDomain(extra) === rpId) return true;929 }930 return false;931 });932933 if (loginTarget) {934 loginTarget.passkey = winner.passkey;935 936 937 938 if (!loginTarget.totp && winner.totp) {939 loginTarget.totp = winner.totp;940 loginTarget.totpAlgorithm = winner.totpAlgorithm;941 loginTarget.totpDigits = winner.totpDigits;942 loginTarget.totpPeriod = winner.totpPeriod;943 }944 loginTarget.modifiedAt = now;945 for (const dup of group) {946 if (!dup.deletedAt) {947 dup.deletedAt = now;948 dup.modifiedAt = now;949 }950 }951 merged = true;952 } else if (group.length > 1) {953 for (let i = 1; i < group.length; i += 1) {954 const dup = group[i]!;955 if (!dup.deletedAt) {956 dup.deletedAt = now;957 dup.modifiedAt = now;958 }959 }960 merged = true;961 }962 }963964 return { merged };965}966967968969970971972973function hasValidVaultSchema(v: unknown): v is StoredVault {974 if (typeof v !== "object" || v === null) return false;975 const r = v as Record<string, unknown>;976 return (977 r.version === 1 &&978 typeof r.salt === "string" &&979 typeof r.nonce === "string" &&980 typeof r.ciphertext === "string" &&981 typeof r.tag === "string"982 );983}984985export type TryDecryptResult =986 | { status: "ok"; payload: DecryptedPayload }987 | { status: "wrong_key" }988 | { status: "corrupted"; reason: string };989990991992993994995export async function tryDecryptSlot(996 vault: StoredVault,997 key: Uint8Array,998 aadTag?: string,999): Promise<DecryptedPayload | null> {1000 const res = await tryDecryptSlotResult(vault, key, aadTag);1001 return res.status === "ok" ? res.payload : null;1002}10031004export async function tryDecryptSlotResult(1005 vault: StoredVault,1006 key: Uint8Array,1007 aadTag?: string,1008): Promise<TryDecryptResult> {1009 if (!hasValidVaultSchema(vault)) {1010 return { status: "corrupted", reason: "invalid_slot_schema" };1011 }1012 let plain: string;1013 try {1014 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1015 } catch {1016 return { status: "wrong_key" };1017 }1018 try {1019 const parsed = JSON.parse(plain) as unknown;1020 if (typeof parsed !== "object" || parsed === null) {1021 return { status: "corrupted", reason: "invalid_payload_shape" };1022 }1023 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1024 return { status: "corrupted", reason: "invalid_payload_shape" };1025 }1026 return { status: "ok", payload: decodePayload(plain) };1027 } catch {1028 return { status: "corrupted", reason: "invalid_payload_json" };1029 }1030}103110321033103410351036function buildDomainIndexSync(all: VaultEntry[]): void {1037 domainIndex = new Map();10381039 for (const entry of all) {1040 if (entry.deletedAt) continue;1041 if (entry.type === "note" || entry.type === "card") continue;10421043 1044 1045 1046 1047 const domains = new Set<string>();1048 const primary = extractDomain(entry.url ?? "");1049 if (primary) domains.add(primary);1050 for (const extra of entry.additionalUrls ?? []) {1051 const d = extractDomain(extra);1052 if (d) domains.add(d);1053 }1054 if (domains.size === 0) continue;10551056 for (const domain of domains) {1057 const bucket = domainIndex.get(domain) ?? [];1058 bucket.push(entry);1059 domainIndex.set(domain, bucket);1060 }1061 }1062}10631064export async function createVault(password: string): Promise<void> {1065 const salt = generateSalt();1066 derivedKey = await deriveKey(password, salt);1067 entries = [];1068 tombstones = new Map();1069 vaultMode = "real";1070 duressConfigured = false;1071 buildDomainIndexSync(entries);10721073 1074 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1075 await generateAndWrapRootKey();1076 rootKey = unhex(rawRootKey);10771078 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1079 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");10801081 1082 1083 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);10841085 const slotA: StoredVault = {1086 version: 1,1087 salt: hex(salt),1088 nonce,1089 ciphertext,1090 tag,1091 recovery_salt: recoverySalt,1092 recovery_wrapped_root_key: wrappedRootKey,1093 recovery_nonce: wrappedNonce,1094 recovery_tag: wrappedTag,1095 ...pwRoot,1096 };1097 await writeSlot(SLOT_A, slotA);10981099 1100 1101 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1102 await stampLocalVaultMutation();11031104 touchAutoLock();1105}1106110711081109111011111112export async function createVaultFromRootKey(1113 password: string,1114 rootKeyHex: string,1115): Promise<{ vaultId: string }> {1116 if (typeof password !== "string" || password.length === 0) {1117 throw new Error("ADOPT_INVALID_PASSWORD");1118 }1119 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1120 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1121 }11221123 const rootKeyBytes = unhex(rootKeyHex);1124 if (rootKeyBytes.length !== 32) {1125 rootKeyBytes.fill(0);1126 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1127 }11281129 let nextDerivedKey: Uint8Array | null = null;1130 let sessionRootKey: Uint8Array | null = null;1131 try {1132 if ((await loadSlot(SLOT_A)) !== null) {1133 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1134 }11351136 const nextEntries: VaultEntry[] = [];1137 const nextDuressConfigured = false;1138 const vaultId = await computeVaultId(rootKeyBytes);1139 const salt = generateSalt();1140 nextDerivedKey = await deriveKey(password, salt);1141 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);11421143 sessionRootKey = new Uint8Array(rootKeyBytes);1144 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1145 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1146 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1147 const slotA: StoredVault = {1148 version: 1,1149 salt: hex(salt),1150 nonce,1151 ciphertext,1152 tag,1153 recovery_salt: recovery.recoverySalt,1154 recovery_wrapped_root_key: recovery.wrappedRootKey,1155 recovery_nonce: recovery.wrappedNonce,1156 recovery_tag: recovery.wrappedTag,1157 ...pwRoot,1158 };1159 const slotB = generateNoiseBlob(ciphertext.length / 2);1160 await commitCreatedVaultSlots(slotA, slotB);11611162 const previousDerivedKey = derivedKey;1163 const previousRootKey = rootKey;1164 derivedKey = nextDerivedKey;1165 rootKey = sessionRootKey;1166 nextDerivedKey = null;1167 sessionRootKey = null;1168 entries = nextEntries;1169 tombstones = new Map();1170 vaultMode = "real";1171 duressConfigured = nextDuressConfigured;1172 buildDomainIndexSync(entries);1173 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1174 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1175 touchAutoLock();11761177 return { vaultId };1178 } finally {1179 rootKeyBytes.fill(0);1180 if (nextDerivedKey) zeroKey(nextDerivedKey);1181 if (sessionRootKey) sessionRootKey.fill(0);1182 }1183}11841185export async function hasVault(): Promise<boolean> {1186 return (await loadSlot(SLOT_A)) !== null;1187}1188118911901191119211931194export async function isDuressEnabled(): Promise<boolean> {1195 return vaultMode === "real" && duressConfigured;1196}11971198export async function setupDuress(duressPassword: string): Promise<void> {1199 if (!derivedKey || vaultMode !== "real") {1200 throw new Error("vault must be unlocked in real mode");1201 }1202 1203 1204 1205 const slotA = await loadSlot(SLOT_A);1206 if (!slotA) throw new Error("missing slot A");12071208 const decoySalt = generateSalt();1209 const decoyKey = await deriveKey(duressPassword, decoySalt);1210 try {1211 1212 1213 1214 1215 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1216 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1217 await writeSlot(SLOT_B, {1218 version: 1,1219 salt: hex(decoySalt),1220 nonce,1221 ciphertext,1222 tag,1223 });1224 } finally {1225 zeroKey(decoyKey);1226 }12271228 1229 1230 duressConfigured = true;1231 await persistSlotA();1232}12331234export async function disableDuress(): Promise<void> {1235 if (!derivedKey || vaultMode !== "real") {1236 throw new Error("vault must be unlocked in real mode");1237 }1238 1239 1240 1241 const slotA = await loadSlot(SLOT_A);1242 if (slotA) {1243 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1244 }12451246 duressConfigured = false;1247 await persistSlotA();1248}12491250125112521253125412551256export async function verifyMainPassword(password: string): Promise<boolean> {1257 const slotA = await loadSlot(SLOT_A);1258 if (!slotA) return false;1259 const key = await deriveKey(password, unhex(slotA.salt));1260 try {1261 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1262 } finally {1263 zeroKey(key);1264 }1265}12661267export async function unlock(password: string): Promise<boolean> {1268 return attemptUnlockWithDerive(password, deriveKey);1269}12701271async function attemptUnlockWithDerive(1272 password: string,1273 derive: typeof deriveKey,1274): Promise<boolean> {1275 1276 1277 1278 1279 1280 1281 const [slotA, slotB] = await Promise.all([1282 loadSlot(SLOT_A),1283 loadSlot(SLOT_B),1284 ]);12851286 1287 const [keyA, keyB] = await Promise.all([1288 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1289 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1290 ]);12911292 1293 1294 1295 const [resultA, resultB] = await Promise.all([1296 keyA && slotA1297 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1298 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1299 keyB && slotB1300 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1301 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1302 ]);1303 const payloadA = resultA.status === "ok" ? resultA.payload : null;1304 const payloadB = resultB.status === "ok" ? resultB.payload : null;13051306 if (payloadA && keyA) {1307 derivedKey = keyA;1308 void saveSessionKey(keyA, "real");1309 vaultMode = "real";1310 entries = payloadA.entries;1311 tombstones = payloadA.tombstones;1312 duressConfigured = payloadA.duressConfigured;1313 buildDomainIndexSync(entries);1314 await loadSettings();1315 touchAutoLock();1316 if (keyB) zeroKey(keyB);13171318 1319 1320 rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1321 if (!rootKey) {1322 lock();1323 return false;1324 }13251326 await lockedSyncUnlockHook?.().catch((error) => {1327 console.warn("[Peach sync] locked inbox drain failed:", error);1328 });1329 return true;1330 }13311332 if (payloadB && keyB) {1333 derivedKey = keyB;1334 void saveSessionKey(keyB, "decoy");1335 vaultMode = "decoy";1336 entries = payloadB.entries;1337 tombstones = payloadB.tombstones;1338 duressConfigured = false;1339 buildDomainIndexSync(entries);1340 await loadSettings();1341 touchAutoLock();1342 if (keyA) zeroKey(keyA);1343 return true;1344 }13451346 if (keyA) zeroKey(keyA);1347 if (keyB) zeroKey(keyB);1348 1349 1350 if (resultA.status === "corrupted") {1351 lastUnlockFailureReason = resultA.reason;1352 } else if (resultB.status === "corrupted") {1353 lastUnlockFailureReason = resultB.reason;1354 }1355 return false;1356}13571358135913601361136213631364const SESSION_KEY_STORAGE = "peach_session_dk";13651366async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1367 if (!chrome.storage.session) return;1368 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1369}13701371async function clearSessionKey(): Promise<void> {1372 if (!chrome.storage.session) return;1373 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1374}137513761377137813791380138113821383export async function restoreFromSession(): Promise<boolean> {1384 if (derivedKey !== null) return true;1385 if (!chrome.storage.session) return false;1386 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1387 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1388 if (!entry?.keyHex) return false;13891390 const key = unhex(entry.keyHex);1391 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1392 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1393 const slot = await loadSlot(slotKey);1394 if (!slot) { await clearSessionKey(); return false; }13951396 const result = await tryDecryptSlotResult(slot, key, aadTag);1397 if (result.status !== "ok") { await clearSessionKey(); return false; }13981399 derivedKey = key;1400 vaultMode = entry.mode;1401 entries = result.payload.entries;1402 tombstones = result.payload.tombstones;1403 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1404 buildDomainIndexSync(entries);1405 await loadSettings();1406 touchAutoLock();14071408 1409 1410 if (entry.mode === "real") {1411 rootKey = await deriveRootKeyFromDerivedKey(key, slot);1412 if (!rootKey) {1413 lock();1414 return false;1415 }1416 }14171418 await lockedSyncUnlockHook?.().catch((error) => {1419 console.warn("[Peach sync] locked inbox drain failed:", error);1420 });14211422 return true;1423}14241425export function lock(): void {1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 void clearSessionKey();1436 if (derivedKey) {1437 zeroKey(derivedKey);1438 derivedKey = null;1439 }1440 if (rootKey) {1441 rootKey.fill(0);1442 rootKey = null;1443 }14441445 entries = [];1446 tombstones = new Map();1447 domainIndex = new Map();1448 vaultMode = "real";1449 duressConfigured = false;1450 clearAutoLock();1451}14521453export function isUnlocked(): boolean {1454 return derivedKey !== null;1455}14561457145814591460export async function unlockWithKey(1461 masterKeyHex: string,1462): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1463 try {1464 const masterKeyBytes = unhex(masterKeyHex);1465 const stored = await loadSlot(SLOT_A);1466 if (!stored) return { ok: false, error: "invalid_key" };1467 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1468 if (!payload) return { ok: false, error: "invalid_key" };14691470 derivedKey = masterKeyBytes;1471 vaultMode = "real";1472 entries = payload.entries;1473 tombstones = payload.tombstones;1474 duressConfigured = payload.duressConfigured;1475 buildDomainIndexSync(entries);1476 await loadSettings();1477 touchAutoLock();14781479 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1480 if (!rootKey) {1481 lock();1482 return { ok: false, error: "invalid_key" };1483 }1484 return { ok: true, entries };1485 } catch {1486 return { ok: false, error: "invalid_key" };1487 }1488}148914901491149214931494149514961497export async function exportDuressSlotForAdoption(): Promise<string | null> {1498 if (!(await isDuressEnabled())) return null;1499 const slotB = await loadSlot(SLOT_B);1500 if (!slotB) return null;1501 return JSON.stringify(slotB);1502}15031504150515061507150815091510export async function installAdoptedDuressSlot(json: string): Promise<void> {1511 let parsed: StoredVault;1512 try {1513 const raw = JSON.parse(json) as unknown;1514 if (!hasValidVaultSchema(raw)) {1515 throw new Error("invalid_duress_slot_shape");1516 }1517 parsed = raw;1518 } catch (err) {1519 if (err instanceof SyntaxError) {1520 throw new Error("invalid_duress_slot_shape");1521 }1522 throw err;1523 }1524 await writeSlot(SLOT_B, parsed);1525}15261527152815291530153115321533153415351536153715381539154015411542154315441545export async function unlockFromCodex(params: {1546 slotA: string; 1547 slotB: string | null; 1548 timestamp: number;1549 masterPassphrase: string;1550}): Promise<1551 | { ok: true; entryCount: number }1552 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1553> {1554 let parsedSlotA: StoredVault;1555 let parsedSlotB: StoredVault | null = null;1556 try {1557 const rawA = JSON.parse(params.slotA) as unknown;1558 if (!hasValidVaultSchema(rawA)) {1559 return { ok: false, reason: "invalid_slot_format" };1560 }1561 parsedSlotA = rawA;1562 if (params.slotB !== null) {1563 const rawB = JSON.parse(params.slotB) as unknown;1564 if (!hasValidVaultSchema(rawB)) {1565 return { ok: false, reason: "invalid_slot_format" };1566 }1567 parsedSlotB = rawB;1568 }1569 } catch {1570 return { ok: false, reason: "invalid_slot_format" };1571 }15721573 1574 1575 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1576 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1577 if (!trialPayload) {1578 zeroKey(trialKey);1579 return { ok: false, reason: "wrong_passphrase" };1580 }1581 1582 1583 zeroKey(trialKey);15841585 1586 1587 await takeVaultSnapshot("restore");15881589 await writeSlot(SLOT_A, parsedSlotA);1590 if (parsedSlotB) {1591 await writeSlot(SLOT_B, parsedSlotB);1592 } else {1593 1594 1595 1596 await writeSlot(1597 SLOT_B,1598 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1599 );1600 }1601 await chrome.storage.local.set({1602 peach_last_modified_at: String(params.timestamp),1603 });16041605 1606 1607 const ok = await unlock(params.masterPassphrase);1608 if (!ok) {1609 1610 1611 1612 return { ok: false, reason: "wrong_passphrase" };1613 }1614 return { ok: true, entryCount: getEntries().length };1615}16161617161816191620export function getDerivedKeyHex(): string {1621 if (!derivedKey) throw new Error("vault_not_unlocked");1622 return hex(derivedKey);1623}16241625162616271628162916301631163216331634163516361637163816391640export function getRootKeyHex(): string | null {1641 return rootKey ? hex(rootKey) : null;1642}16431644export function getEntries(): VaultEntry[] {1645 if (!derivedKey) throw new Error("vault_locked");1646 return entries.filter((entry) => !entry.deletedAt);1647}16481649const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;16501651export function getIdentities(): SavedIdentity[] {1652 if (!derivedKey) throw new Error("vault_locked");1653 const active = entries.filter((e) => !e.deletedAt && e.type === "login");16541655 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1656 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();16571658 for (const entry of active) {1659 const domain = extractDomain(entry.url ?? "") ?? "";1660 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;16611662 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1663 const existing = map.get(v);1664 if (!existing || modifiedAt > existing.lastUsed) {1665 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1666 } else {1667 map.set(v, { ...existing, count: existing.count + 1 });1668 }1669 }16701671 const uVal = entry.username?.trim();1672 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);16731674 1675 1676 const eVal = entry.email?.trim();1677 if (eVal) tally(emailMap, eVal);1678 }16791680 const toIdentity = (type: "email" | "username") =>1681 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1682 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1683 });16841685 return [1686 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1687 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1688 ];1689}16901691export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1692 if (!derivedKey) throw new Error("vault_locked");16931694 const now = Date.now();1695 const added: VaultEntry[] = [];1696 for (const draft of drafts) {1697 try {1698 const sanitized = sanitizeEntryDraft(draft);1699 const nextEntry: VaultEntry = {1700 ...sanitized,1701 id: crypto.randomUUID(),1702 createdAt: now,1703 modifiedAt: monotonicEntryTimestamp(),1704 };1705 entries.push(nextEntry);1706 added.push(nextEntry);1707 } catch {1708 1709 }1710 }17111712 if (added.length > 0) {1713 await persistEntries();1714 }17151716 return added;1717}171817191720172117221723172417251726export async function restoreEntries(1727 incoming: VaultEntry[],1728 mode: "replace" | "merge"1729): Promise<{ added: number; updated: number; skipped: number }> {1730 if (!derivedKey) throw new Error("vault_locked");17311732 1733 1734 1735 if (mode === "replace" && incoming.length === 0) {1736 throw new Error("refuse to replace with empty entry set");1737 }17381739 let added = 0;1740 let updated = 0;1741 let skipped = 0;17421743 if (mode === "replace") {1744 entries = [];1745 tombstones = new Map();1746 for (const entry of incoming) {1747 const normalized = sanitizeRestoredEntry(entry);1748 if (!normalized) {1749 skipped += 1;1750 continue;1751 }1752 entries.push(normalized);1753 if (normalized.deletedAt) {1754 tombstones.set(normalized.id, normalized.deletedAt);1755 }1756 added += 1;1757 }1758 } else {1759 const byId = new Map<string, VaultEntry>();1760 for (const e of entries) byId.set(e.id, e);17611762 for (const entry of incoming) {1763 const normalized = sanitizeRestoredEntry(entry);1764 if (!normalized) {1765 skipped += 1;1766 continue;1767 }1768 const existing = byId.get(normalized.id);1769 if (!existing) {1770 entries.push(normalized);1771 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1772 else tombstones.delete(normalized.id);1773 added += 1;1774 } else if (normalized.modifiedAt > existing.modifiedAt) {1775 const idx = entries.findIndex((e) => e.id === existing.id);1776 if (idx !== -1) {1777 entries[idx] = normalized;1778 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1779 else tombstones.delete(normalized.id);1780 updated += 1;1781 }1782 } else {1783 skipped += 1;1784 }1785 }1786 }17871788 await persistEntries("import");1789 return { added, updated, skipped };1790}179117921793179417951796179717981799function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1800 if (!entry || typeof entry !== "object") return null;1801 const title = typeof entry.title === "string" ? entry.title.trim() : "";1802 if (!title) return null;1803 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1804 const now = Date.now();1805 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1806 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;18071808 1809 1810 const normalized = normalizeEntry({1811 ...entry,1812 id,1813 title,1814 type: entry.type ?? "login",1815 createdAt,1816 modifiedAt,1817 });18181819 if (normalized.passkey) {1820 normalized.passkey = sanitizePasskey(normalized.passkey);1821 }1822 if (normalized.canary) {1823 normalized.canary = sanitizeCanary(normalized.canary);1824 }1825 1826 normalized.tags = sanitizeTags(normalized.tags);18271828 1829 1830 if (1831 normalized.type === "login" &&1832 !normalized.passkey &&1833 typeof normalized.password !== "string"1834 ) {1835 return null;1836 }1837 if (entry.passkey && !normalized.passkey) {1838 1839 1840 return null;1841 }18421843 return normalized;1844}1845184618471848184918501851function sanitizePasskey(pk: unknown): PasskeyData | undefined {1852 if (!pk || typeof pk !== "object") return undefined;1853 const r = pk as Record<string, unknown>;1854 if (1855 typeof r.rpId !== "string" || !r.rpId ||1856 typeof r.credentialId !== "string" || !r.credentialId ||1857 typeof r.publicKeyCose !== "string" ||1858 typeof r.userHandle !== "string" ||1859 typeof r.userName !== "string"1860 ) {1861 return undefined;1862 }1863 return {1864 rpId: r.rpId,1865 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1866 credentialId: r.credentialId,1867 publicKeyCose: r.publicKeyCose,1868 privateKeyJwk:1869 r.privateKeyJwk && typeof r.privateKeyJwk === "object"1870 ? (r.privateKeyJwk as JsonWebKey)1871 : undefined,1872 userHandle: r.userHandle,1873 userName: r.userName,1874 userDisplayName:1875 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1876 algorithm: r.algorithm === -8 ? -8 : -7,1877 signCount: typeof r.signCount === "number" ? r.signCount : 0,1878 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1879 backupEligible: r.backupEligible === true,1880 backupState: r.backupState === true,1881 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1882 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1883 transports: Array.isArray(r.transports)1884 ? r.transports.filter((t): t is string => typeof t === "string")1885 : undefined,1886 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1887 };1888}18891890189118921893189418951896189718981899function sanitizeCanary(c: unknown): Canary | undefined {1900 if (!c || typeof c !== "object") return undefined;1901 const r = c as Record<string, unknown>;1902 if (typeof r.webhookUrl !== "string") return undefined;1903 let url: URL;1904 try {1905 url = new URL(r.webhookUrl);1906 } catch {1907 return undefined;1908 }1909 if (url.protocol !== "https:") return undefined;1910 return {1911 webhookUrl: url.toString(),1912 label: typeof r.label === "string" ? r.label : undefined,1913 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,1914 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,1915 };1916}19171918191919201921192219231924function sanitizeTags(tags: unknown): string[] | undefined {1925 if (!Array.isArray(tags)) return undefined;1926 const out: string[] = [];1927 for (const t of tags) {1928 if (typeof t !== "string") continue;1929 const trimmed = t.trim();1930 if (!trimmed) continue;1931 out.push(trimmed.slice(0, 256));1932 if (out.length >= 255) break;1933 }1934 return out.length > 0 ? out : undefined;1935}193619371938193919401941export async function clearVaultEntries(1942 confirmation: "REPLACE_ALL"1943): Promise<void> {1944 if (confirmation !== "REPLACE_ALL") {1945 throw new Error("clearVaultEntries requires explicit confirmation");1946 }1947 if (!derivedKey) throw new Error("vault_locked");1948 for (const entry of entries) {1949 if (!entry.deletedAt) {1950 const stamp = monotonicEntryTimestamp(Math.max(1951 entry.modifiedAt,1952 tombstones.get(entry.id) ?? 0,1953 ));1954 entry.deletedAt = stamp;1955 entry.modifiedAt = stamp;1956 tombstones.set(entry.id, stamp);1957 }1958 }1959 await persistEntries();1960}19611962export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1963 if (!derivedKey) throw new Error("vault_locked");19641965 const sanitized = sanitizeEntryDraft(entry);1966 const now = Date.now();1967 const stamp = monotonicEntryTimestamp();1968 const nextEntry: VaultEntry = {1969 ...sanitized,1970 id: crypto.randomUUID(),1971 createdAt: now,1972 modifiedAt: stamp,1973 1974 1975 passwordChangedAt: sanitized.password ? stamp : undefined,1976 };19771978 1979 1980 1981 const rollback = entries;1982 entries = [...entries, nextEntry];1983 try {1984 await persistEntries();1985 } catch (err) {1986 entries = rollback;1987 throw err;1988 }19891990 1991 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1992 checkBreach(nextEntry.password).then(async (count) => {1993 if (count > 0) {1994 const idx = entries.findIndex((e) => e.id === nextEntry.id);1995 if (idx !== -1) {1996 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };1997 await persistEntries();1998 }1999 }2000 });2001 }20022003 fireSyncTrigger("vault_change");20042005 return nextEntry;2006}20072008200920102011201220132014export async function consolidatePasskeyEntries(): Promise<void> {2015 if (!derivedKey) return;2016 const { merged } = mergePasskeyOnlyIntoLogins(entries);2017 if (merged) {2018 await persistEntries();2019 }2020}20212022export async function updateEntry(2023 id: string,2024 patch: Partial<VaultEntry>2025): Promise<void> {2026 if (!derivedKey) throw new Error("vault_locked");20272028 const idx = entries.findIndex((entry) => entry.id === id);2029 if (idx === -1 || entries[idx].deletedAt) {2030 throw new Error("entry_not_found");2031 }20322033 const current = entries[idx];2034 const sanitizedPatch = sanitizeEntryPatch(patch);2035 2036 2037 2038 const passwordChanged =2039 "password" in patch &&2040 typeof sanitizedPatch.password === "string" &&2041 sanitizedPatch.password !== current.password;2042 const stamp = monotonicEntryTimestamp(current.modifiedAt);2043 2044 2045 2046 2047 2048 2049 2050 const priorPassword = current.password;2051 const shouldCapture =2052 passwordChanged &&2053 typeof priorPassword === "string" &&2054 priorPassword.length > 0 &&2055 current.passwordHistory?.[0]?.password !== priorPassword;2056 const nextHistory = shouldCapture2057 ? [2058 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2059 ...(current.passwordHistory ?? []),2060 ].slice(0, PASSWORD_HISTORY_MAX)2061 : current.passwordHistory;2062 entries[idx] = {2063 ...current,2064 ...sanitizedPatch,2065 modifiedAt: stamp,2066 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2067 };2068206920702071 2072 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2073 entries[idx] = current;2074 throw new Error("card number is required for card entries");2075 }20762077 2078 2079 2080 2081 try {2082 await persistEntries();2083 } catch (err) {2084 entries[idx] = current;2085 throw err;2086 }20872088 2089 2090 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2091 entries[idx] = { ...entries[idx], breach: undefined };2092 await persistEntries();2093 const password = entries[idx].password;2094 if (password) {2095 checkBreach(password).then(async (count) => {2096 const i = entries.findIndex((e) => e.id === id);2097 if (i !== -1 && count > 0) {2098 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2099 await persistEntries();2100 }2101 });2102 }2103 }21042105 fireSyncTrigger("vault_change");2106}21072108export async function deleteEntry(id: string): Promise<void> {2109 if (!derivedKey) throw new Error("vault_locked");21102111 const entry = entries.find((item) => item.id === id);2112 if (!entry || entry.deletedAt) {2113 throw new Error("entry_not_found");2114 }21152116 2117 2118 2119 const previousModifiedAt = entry.modifiedAt;2120 const previousTombstone = tombstones.get(id);2121 const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2122 entry.deletedAt = stamp;2123 entry.modifiedAt = stamp;2124 tombstones.set(id, stamp);2125 2126 2127 try {2128 await persistEntries();2129 } catch (err) {2130 delete entry.deletedAt;2131 entry.modifiedAt = previousModifiedAt;2132 if (previousTombstone === undefined) tombstones.delete(id);2133 else tombstones.set(id, previousTombstone);2134 throw err;2135 }2136 fireSyncTrigger("vault_change");2137}21382139export function getDeletedEntries(): VaultEntry[] {2140 if (!derivedKey) return [];2141 return entries.filter((e) => !!e.deletedAt);2142}214321442145export function getTombstoneManifest(): Record<string, number> {2146 if (!derivedKey) return {};2147 return Object.fromEntries(tombstones);2148}2149215021512152215321542155export async function getVaultVersion(): Promise<number> {2156 try {2157 const raw = await chrome.storage.local.get("peach_last_modified_at");2158 const v = (raw as Record<string, unknown>).peach_last_modified_at;2159 if (typeof v === "number") return v;2160 if (typeof v === "string" && v !== "") {2161 const n = Number.parseInt(v, 10);2162 return Number.isFinite(n) ? n : 0;2163 }2164 return 0;2165 } catch {2166 return 0;2167 }2168}2169217021712172217321742175217621772178217921802181export async function applyMergedRemoteEntries(2182 remoteEntries: VaultEntry[],2183 remoteTombstones: Record<string, number>,2184 _context: { deviceName?: string } = {},2185): Promise<{ received: number; applied: number; conflicts: number }> {2186 2187 2188 2189 2190 2191 2192 if (!derivedKey) throw new Error("vault_locked");21932194 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2195 let applied = 0;2196 let conflicts = 0;21972198 const byId = new Map<string, VaultEntry>();2199 for (const e of entries) byId.set(e.id, e);22002201 const mergeNow = Date.now();2202 const remoteTombstoneTimes = new Map<string, number>();2203 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2204 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2205 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2206 }2207 }22082209 for (const remote of remoteEntries) {2210 const sanitized = sanitizeRestoredEntry(remote);2211 if (!sanitized) continue;2212 2213 2214 2215 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2216 if (typeof sanitized.deletedAt === "number") {2217 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2218 }2219 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2220 const localTombstoneTs = tombstones.get(sanitized.id);2221 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2222 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2223 continue;2224 }2225 const local = byId.get(sanitized.id);2226 if (!local) {2227 entries.push(sanitized);2228 byId.set(sanitized.id, sanitized);2229 tombstones.delete(sanitized.id);2230 applied += 1;2231 } else if (sanitized.modifiedAt > local.modifiedAt) {2232 const idx = entries.findIndex((e) => e.id === local.id);2233 if (idx !== -1) {2234 entries[idx] = sanitized;2235 byId.set(sanitized.id, sanitized);2236 tombstones.delete(sanitized.id);2237 applied += 1;2238 if (!local.deletedAt) conflicts += 1;2239 }2240 }2241 }22422243 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2244 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2245 2246 const deletedAt = remoteTombstoneTimes.get(id)!;2247 const knownTombstone = tombstones.get(id);2248 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2249 const local = byId.get(id);2250 if (!local) {2251 tombstones.set(id, deletedAt);2252 applied += 1;2253 continue;2254 }2255 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2256 if (deletedAt < local.modifiedAt) continue;2257 const idx = entries.findIndex((e) => e.id === id);2258 if (idx !== -1) {2259 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2260 tombstones.set(id, deletedAt);2261 applied += 1;2262 }2263 }22642265 if (applied > 0) {2266 await persistEntries("sync_received");2267 buildDomainIndexSync(entries);2268 }2269 return { received, applied, conflicts };2270}22712272export async function restoreEntry(id: string): Promise<void> {2273 if (!derivedKey) throw new Error("vault_locked");2274 const entry = entries.find((e) => e.id === id && e.deletedAt);2275 if (!entry) throw new Error("deleted entry not found");2276 const previousDeletedAt = entry.deletedAt;2277 const previousModifiedAt = entry.modifiedAt;2278 const previousTombstone = tombstones.get(id);2279 delete entry.deletedAt;2280 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2281 entry.modifiedAt,2282 previousTombstone ?? 0,2283 ));2284 tombstones.delete(id);2285 try {2286 await persistEntries();2287 } catch (err) {2288 entry.deletedAt = previousDeletedAt;2289 entry.modifiedAt = previousModifiedAt;2290 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2291 throw err;2292 }2293 buildDomainIndexSync(entries);2294}22952296export async function purgeEntry(id: string): Promise<void> {2297 if (!derivedKey) throw new Error("vault_locked");2298 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2299 if (idx === -1) throw new Error("deleted entry not found");2300 const entry = entries[idx]!;2301 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2302 entries.splice(idx, 1);2303 await persistEntries();2304}230523062307export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2308 if (!derivedKey || retentionMs === 0) return 0;2309 const cutoff = Date.now() - retentionMs;2310 const before = entries.length;2311 for (const entry of entries) {2312 if (entry.deletedAt && entry.deletedAt < cutoff) {2313 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2314 }2315 }2316 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2317 const purged = before - entries.length;2318 if (purged > 0) await persistEntries();2319 return purged;2320}23212322export function queryDomain(domain: string): VaultEntry[] {2323 return domainIndex.get(domain) ?? [];2324}23252326export function getEntryById(id: string): VaultEntry | undefined {2327 return entries.find((entry) => entry.id === id && !entry.deletedAt);2328}23292330async function persistSlotA(2331 reason: VaultSnapshot["reason"] = "local_save",2332): Promise<StoredVault> {2333 if (!derivedKey) throw new Error("vault_locked");23342335 const slotA = await loadSlot(SLOT_A);2336 if (!slotA) throw new Error("missing slot A");23372338 2339 2340 await takeVaultSnapshot(reason);23412342 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2343 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");23442345 2346 const nextSlotA: StoredVault = {2347 ...slotA,2348 nonce,2349 ciphertext,2350 tag,2351 2352 };2353 await writeSlot(SLOT_A, nextSlotA);23542355 2356 2357 2358 2359 2360 2361 try {2362 2363 2364 2365 if (!duressConfigured) {2366 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2367 }2368 2369 2370 2371 2372 2373 if (reason !== "sync_received") {2374 await stampLocalVaultMutation();2375 }2376 } catch (err) {2377 2378 2379 await writeSlot(SLOT_A, slotA).catch(() => {});2380 throw err;2381 }23822383 return nextSlotA;2384}23852386async function persistSlotB(): Promise<void> {2387 if (!derivedKey) throw new Error("vault_locked");23882389 const slotB = await loadSlot(SLOT_B);2390 if (!slotB) throw new Error("missing slot B");23912392 2393 2394 2395 2396 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2397 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");23982399 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2400 2401 2402 2403 try {2404 await stampLocalVaultMutation();2405 } catch (err) {2406 await writeSlot(SLOT_B, slotB).catch(() => {});2407 throw err;2408 }2409}24102411async function persistEntries(2412 reason: VaultSnapshot["reason"] = "local_save",2413): Promise<void> {2414 if (!derivedKey) throw new Error("vault_locked");24152416 2417 2418 const previousTombstones = tombstones;2419 tombstones = new Map(tombstones);2420 reconcileTombstones();2421 try {2422 if (vaultMode === "decoy") {2423 await persistSlotB();2424 } else {2425 await persistSlotA(reason);2426 }2427 } catch (err) {2428 tombstones = previousTombstones;2429 throw err;2430 }2431 buildDomainIndexSync(entries);2432 await refreshLockedSyncState();2433}24342435function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2436 if (!Array.isArray(values)) return undefined;2437 const seen = new Set<string>();2438 const cleaned: string[] = [];2439 for (const value of values) {2440 const trimmed = typeof value === "string" ? value.trim() : "";2441 if (!trimmed) continue;2442 const key = trimmed.toLowerCase();2443 if (seen.has(key)) continue;2444 seen.add(key);2445 cleaned.push(trimmed);2446 }2447 return cleaned.length > 0 ? cleaned : undefined;2448}24492450function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2451 const title = entry.title.trim();2452 const type: VaultEntry["type"] = entry.type ?? "login";24532454 if (!title) throw new Error("title is required");24552456 if (entry.passkey) {2457 const p = entry.passkey;2458 if (2459 !p.rpId ||2460 !p.credentialId ||2461 !p.publicKeyCose ||2462 !p.userHandle ||2463 !p.userName ||2464 (p.algorithm !== -7 && p.algorithm !== -8)2465 ) {2466 throw new Error("invalid passkey credential");2467 }2468 }2469 if (type === "card") {2470 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2471 throw new Error("cardholder name and card number are required");2472 }2473 }24742475 return {2476 type,2477 title,2478 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2479 email: type === "login" ? entry.email?.trim() || undefined : undefined,2480 password: type === "login" ? entry.password : undefined,2481 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2482 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2483 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2484 2485 2486 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2487 totpDigits: type === "login" ? entry.totpDigits : undefined,2488 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2489 notes: entry.notes?.trim() || undefined,2490 breach: type === "login" ? entry.breach : undefined,2491 2492 2493 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2494 tags: sanitizeTags(entry.tags),2495 passkey: entry.passkey,2496 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2497 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2498 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2499 expYear: type === "card" ? entry.expYear?.trim() : undefined,2500 cvc: type === "card" ? entry.cvc?.trim() : undefined,2501 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2502 network: type === "card" ? entry.network : undefined,2503 last4: type === "card" ? entry.last4?.trim() : undefined,2504 };2505}25062507function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2508 const nextPatch: Partial<VaultEntry> = {};25092510 if ("type" in patch) {2511 nextPatch.type = patch.type;2512 }25132514 if ("title" in patch) {2515 const title = patch.title?.trim() ?? "";2516 if (!title) throw new Error("title is required");2517 nextPatch.title = title;2518 }25192520 if ("url" in patch) {2521 nextPatch.url = patch.url?.trim() ?? "";2522 }25232524 if ("additionalUrls" in patch) {2525 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2526 const seen = new Set<string>();2527 const cleaned: string[] = [];2528 for (const value of incoming) {2529 const trimmed = typeof value === "string" ? value.trim() : "";2530 if (!trimmed) continue;2531 const key = trimmed.toLowerCase();2532 if (seen.has(key)) continue;2533 seen.add(key);2534 cleaned.push(trimmed);2535 }2536 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2537 }25382539 if ("username" in patch) {2540 nextPatch.username = patch.username?.trim() ?? "";2541 }25422543 if ("email" in patch) {2544 nextPatch.email = patch.email?.trim() || undefined;2545 }25462547 if ("password" in patch) {2548 const password = patch.password ?? "";2549 2550 nextPatch.password = password || undefined;2551 }25522553 if ("totp" in patch) {2554 nextPatch.totp = patch.totp?.trim() || undefined;2555 }25562557 if ("notes" in patch) {2558 nextPatch.notes = patch.notes?.trim() || undefined;2559 }25602561 if ("breach" in patch) {2562 nextPatch.breach = patch.breach;2563 }25642565 if ("canary" in patch) {2566 2567 2568 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2569 }25702571 if ("passkey" in patch) {2572 2573 2574 if (patch.passkey === undefined || patch.passkey === null) {2575 nextPatch.passkey = undefined;2576 } else {2577 const sanitized = sanitizePasskey(patch.passkey);2578 if (!sanitized) throw new Error("invalid passkey credential");2579 nextPatch.passkey = sanitized;2580 }2581 }25822583 if ("tags" in patch) {2584 2585 nextPatch.tags = sanitizeTags(patch.tags);2586 }25872588 if ("cardholderName" in patch) {2589 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2590 }2591 if ("cardNumber" in patch) {2592 const n = patch.cardNumber ?? "";2593 nextPatch.cardNumber = n || undefined;2594 }2595 if ("expMonth" in patch) {2596 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2597 }2598 if ("expYear" in patch) {2599 nextPatch.expYear = patch.expYear?.trim() || undefined;2600 }2601 if ("cvc" in patch) {2602 nextPatch.cvc = patch.cvc?.trim() || undefined;2603 }2604 if ("cardNickname" in patch) {2605 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2606 }2607 if ("network" in patch) {2608 nextPatch.network = patch.network;2609 }2610 if ("last4" in patch) {2611 nextPatch.last4 = patch.last4?.trim() || undefined;2612 }26132614 return nextPatch;2615}26162617function normalizeEntry(entry: VaultEntry): VaultEntry {2618 const base: VaultEntry = {2619 id: entry.id,2620 type: entry.type,2621 title: entry.title,2622 username: entry.username,2623 email: entry.email,2624 password: entry.password,2625 url: entry.url,2626 additionalUrls:2627 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2628 ? entry.additionalUrls2629 : undefined,2630 totp: entry.totp,2631 2632 totpAlgorithm: entry.totpAlgorithm,2633 totpDigits: entry.totpDigits,2634 totpPeriod: entry.totpPeriod,2635 notes: entry.notes,2636 breach: entry.breach,2637 2638 2639 2640 passwordChangedAt:2641 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2642 2643 2644 2645 passwordHistory:2646 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2647 ? entry.passwordHistory2648 : undefined,2649 canary: entry.canary,2650 passkey: entry.passkey,2651 createdAt: entry.createdAt,2652 modifiedAt: entry.modifiedAt,2653 deletedAt: entry.deletedAt,2654 tags: entry.tags,2655 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2656 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2657 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2658 expYear: "expYear" in entry ? entry.expYear : undefined,2659 cvc: "cvc" in entry ? entry.cvc : undefined,2660 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2661 network: "network" in entry ? entry.network : undefined,2662 last4: "last4" in entry ? entry.last4 : undefined,2663 };26642665 return base;2666}2667266826692670267126722673function entryDupeKey(e: VaultEntry): string {2674 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2675 2676 2677 2678 2679 if (e.passkey) {2680 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2681 }2682 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2683}268426852686export function countDuplicates(): number {2687 const best = new Map<string, VaultEntry>();2688 let dupeCount = 0;2689 for (const e of entries.filter((e) => !e.deletedAt)) {2690 const key = entryDupeKey(e);2691 const existing = best.get(key);2692 if (!existing) {2693 best.set(key, e);2694 } else {2695 dupeCount++;2696 2697 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2698 best.set(key, e);2699 }2700 }2701 }2702 return dupeCount;2703}270427052706270727082709export async function deduplicateEntries(): Promise<number> {2710 if (!derivedKey) throw new Error("vault_locked");2711 2712 2713 if (vaultMode === "decoy") {2714 throw new Error("deduplicate disabled in decoy mode");2715 }27162717 const best = new Map<string, VaultEntry>();2718 for (const e of entries.filter((en) => !en.deletedAt)) {2719 const key = entryDupeKey(e);2720 const existing = best.get(key);2721 if (!existing) {2722 best.set(key, e);2723 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2724 best.set(key, e);2725 }2726 }27272728 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2729 let removed = 0;2730 entries = entries.map((e) => {2731 if (!e.deletedAt && !keepIds.has(e.id)) {2732 removed++;2733 const deletedAt = monotonicEntryTimestamp(Math.max(2734 e.modifiedAt,2735 tombstones.get(e.id) ?? 0,2736 ));2737 tombstones.set(e.id, deletedAt);2738 return { ...e, deletedAt };2739 }2740 return e;2741 });27422743 if (removed > 0) await persistEntries();2744 return removed;2745}274627472748274927502751275227532754const HISTORY_KEY = "peach_vault_history";2755const HISTORY_MAX = 5;275627572758275927602761function bucketEntryCount(n: number): number {2762 if (n <= 0) return 0;2763 if (n <= 10) return 10;2764 if (n <= 25) return 25;2765 if (n <= 50) return 50;2766 if (n <= 100) return 100;2767 if (n <= 250) return 250;2768 if (n <= 500) return 500;2769 return Math.ceil(n / 500) * 500;2770}27712772277327742775277627772778export async function takeVaultSnapshot(2779 reason: VaultSnapshot["reason"],2780): Promise<void> {2781 try {2782 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2783 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2784 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];27852786 2787 2788 2789 if (!slotA) return;27902791 const vault_a = JSON.stringify(slotA);2792 const vault_b = JSON.stringify(slotB ?? slotA);27932794 2795 2796 2797 2798 2799 2800 let entryCount: number | null = null;2801 if (derivedKey && vaultMode === "real") {2802 try {2803 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);2804 } catch {2805 entryCount = null;2806 }2807 }28082809 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2810 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2811 ? existingRaw2812 : { snapshots: [] };28132814 2815 2816 2817 const latest = history.snapshots[0];2818 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2819 return;2820 }28212822 const snapshot: VaultSnapshot = {2823 id: crypto.randomUUID(),2824 savedAt: Date.now(),2825 reason,2826 vault_a,2827 vault_b,2828 entryCount,2829 };28302831 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2832 await chrome.storage.local.set({ [HISTORY_KEY]: history });2833 } catch (err) {2834 2835 2836 2837 2838 console.warn('[Peach] takeVaultSnapshot failed:', err);2839 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2840 try {2841 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2842 } catch {2843 2844 }2845 }2846 }2847}284828492850export async function clearVaultHistory(): Promise<void> {2851 try {2852 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2853 } catch {2854 2855 }2856}285728582859export async function getVaultHistory(): Promise<VaultSnapshot[]> {2860 const raw = await chrome.storage.local.get(HISTORY_KEY);2861 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2862 if (!history || !Array.isArray(history.snapshots)) return [];2863 return history.snapshots;2864}28652866286728682869287028712872export async function restoreVaultSnapshot(2873 snapshotId: string,2874): Promise<{ ok: true } | { ok: false; error: string }> {2875 const history = await getVaultHistory();2876 const snapshot = history.find((s) => s.id === snapshotId);2877 if (!snapshot) return { ok: false, error: "snapshot_not_found" };28782879 2880 2881 2882 await takeVaultSnapshot("restore");28832884 let restoredA: StoredVault;2885 let restoredB: StoredVault;2886 try {2887 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2888 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2889 } catch {2890 return { ok: false, error: "snapshot_corrupt" };2891 }28922893 await writeSlot(SLOT_A, restoredA);2894 await writeSlot(SLOT_B, restoredB);2895 await stampLocalVaultMutation();28962897 2898 2899 2900 lock();29012902 return { ok: true };2903}290429052906290729082909291029112912291329142915291629172918export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {2919 if (!derivedKey || vaultMode !== "real") {2920 return { ok: false, error: "vault_locked" };2921 }2922 const slotA = await loadSlot(SLOT_A);2923 if (!slotA) return { ok: false, error: "missing_slot_a" };29242925 2926 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =2927 await generateAndWrapRootKey();29282929 2930 const newRootKey = unhex(rawRootKey);2931 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);29322933 2934 2935 2936 2937 await takeVaultSnapshot("local_save");2938 const nextSlotA: StoredVault = {2939 ...slotA,2940 recovery_salt: recoverySalt,2941 recovery_wrapped_root_key: wrappedRootKey,2942 recovery_nonce: wrappedNonce,2943 recovery_tag: wrappedTag,2944 ...pwRoot,2945 };2946 await writeSlot(SLOT_A, nextSlotA);29472948 2949 if (rootKey) zeroKey(rootKey);2950 rootKey = newRootKey;29512952 2953 await clearPairedDevices();29542955 2956 await setRecoveryConfirmPending(); 29572958 await stampLocalVaultMutation();2959 return { ok: true };2960}29612962296329642965296629672968export async function changePassword(2969 currentPassword: string,2970 newPassword: string2971): Promise<boolean> {2972 if (!derivedKey || vaultMode !== "real") return false;29732974 2975 const valid = await verifyMainPassword(currentPassword);2976 if (!valid) return false;29772978 const slotA = await loadSlot(SLOT_A);2979 if (!slotA) return false;2980 if (!rootKey) return false;29812982 2983 const newSalt = generateSalt();2984 const newKey = await deriveKey(newPassword, newSalt);29852986 2987 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2988 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");29892990 2991 2992 2993 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);29942995 const finalSlotA: StoredVault = {2996 ...slotA,2997 salt: hex(newSalt),2998 nonce,2999 ciphertext,3000 tag,3001 ...pwRoot,3002 };30033004 3005 3006 3007 await takeVaultSnapshot("local_save");30083009 3010 3011 3012 3013 await writeSlot(SLOT_A, finalSlotA);30143015 3016 3017 3018 zeroKey(derivedKey);3019 derivedKey = newKey;30203021 3022 3023 3024 3025 void saveSessionKey(newKey, "real");30263027 3028 3029 3030 if (!duressConfigured) {3031 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3032 }3033 await clearPinUnlockStorage();3034 await stampLocalVaultMutation();30353036 return true;3037}