123456789101112import { deriveKey, deriveKeyRaw, 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 LegacyVaultEntry,29 ManualSummonRecord,30 PasskeyData,31 SavedIdentity,32 StoredVault,33 VaultEntry,34 VaultHistory,35 VaultSnapshot,36} from "../shared/types";37import { decrypt, encrypt, zeroKey } from "./aes";38import { clearAutoLock, touchAutoLock } from "./alarms";39import { setRecoveryConfirmPending } from "./recovery-pending";4041type EntryDraft = Omit<42 VaultEntry,43 "id" | "createdAt" | "modifiedAt" | "deletedAt"44>;454647484950const SLOT_A = "peach_vault_a";51const SLOT_B = "peach_vault_b";525354const LEGACY_MAIN = "peach_vault";55const LEGACY_DECOY = "peach_decoy_vault";56const LEGACY_DURESS_FLAG = "peach_duress";57585960const PAD_BLOCK = 1024;6162636465const PASSWORD_HISTORY_MAX = 50;6667686970717273747576const DURESS_SLOT_SIZE = 262_144;7778let derivedKey: Uint8Array | null = null;7980818283let rootKey: Uint8Array | null = null;84let entries: VaultEntry[] = [];85let domainIndex: Map<string, VaultEntry[]> = new Map();86let vaultMode: "real" | "decoy" = "real";87888990let duressConfigured = false;9192let migrationRan = false;93949596979899100101102103let lastUnlockFailureReason: string | null = null;104105export function consumeLastUnlockFailureReason(): string | null {106 const r = lastUnlockFailureReason;107 lastUnlockFailureReason = null;108 return r;109}110111112113114115export type TextSize = "small" | "medium" | "large";116117export interface VaultSettings {118 accentColor?: AccentColorName;119 theme?: "dark" | "light";120 autoLockMinutes?: number;121 clipboardClearSeconds?: number;122 lockOnClose?: boolean;123 breachMonitoring?: boolean;124 passkeyProviderEnabled?: boolean;125 trashRetentionDays?: number;126 manualSummonDomains?: Record<string, ManualSummonRecord>;127 declinedCardFingerprints?: string[];128 129130131132133 textSize?: TextSize;134 135136137138139 reduceMotion?: boolean;140 141142143144145 biometricsEnabled?: boolean;146 147148149150151152153 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;154}155156let settings: VaultSettings = {};157158export function getSettings(): VaultSettings {159 return settings;160}161162export async function ensureSettingsLoaded(): Promise<VaultSettings> {163 await loadSettings();164 return settings;165}166167export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {168 await loadSettings();169 170 171 172 173 const safe = Object.fromEntries(174 Object.entries(patch).filter(175 ([k]) => !["__proto__", "constructor", "prototype"].includes(k),176 ),177 ) as Partial<VaultSettings>;178 settings = { ...settings, ...safe };179 await persistSettings();180}181182async function persistSettings(): Promise<void> {183 try {184 await chrome.storage.local.set({ peach_settings: settings });185 } catch (err) {186 throw mapStorageError(err);187 }188}189190function mapStorageError(err: unknown): Error {191 const message = err instanceof Error ? err.message : String(err);192 if (/QUOTA_BYTES|quota/i.test(message)) {193 return new Error("STORAGE_QUOTA_EXCEEDED");194 }195 return err instanceof Error ? err : new Error(message);196}197198async function loadSettings(): Promise<void> {199 const result = await chrome.storage.local.get("peach_settings");200 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};201}202203204205function fireSyncTrigger(reason: "vault_change"): void {206 void Promise.resolve()207 .then(() => triggerSyncIfReachable({ reason }))208 .catch(() => {209 210 });211}212213let lockedSyncRefreshHook: (() => Promise<void>) | null = null;214let lockedSyncUnlockHook: (() => Promise<void>) | null = null;215216export function setLockedSyncHooks(hooks: {217 refresh: () => Promise<void>;218 afterUnlock: () => Promise<void>;219}): void {220 lockedSyncRefreshHook = hooks.refresh;221 lockedSyncUnlockHook = hooks.afterUnlock;222}223224async function refreshLockedSyncState(): Promise<void> {225 if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;226 await lockedSyncRefreshHook().catch((error) => {227 console.warn("[Peach sync] locked spool refresh failed:", error);228 });229}230231232233234235export async function checkBreach(password: string): Promise<number> {236 237 238 const encoder = new TextEncoder();239 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));240 const hashHex = Array.from(new Uint8Array(hashBuffer))241 .map((b) => b.toString(16).padStart(2, "0"))242 .join("")243 .toUpperCase();244245 const prefix = hashHex.slice(0, 5);246 const suffix = hashHex.slice(5);247248 try {249 250 251 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {252 headers: { "Add-Padding": "true" },253 });254 if (!res.ok) return 0;255 const text = await res.text();256 const match = text.split("\r\n").find((l) => l.startsWith(suffix));257 return match ? parseInt(match.split(":")[1], 10) : 0;258 } catch {259 return 0; 260 }261}262263export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {264 const fingerprint = await cardFingerprint(last4, expMonth, expYear);265 await loadSettings();266 settings = {267 ...settings,268 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],269 };270 await persistSettings();271}272273export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {274 await loadSettings();275 const fingerprint = await cardFingerprint(last4, expMonth, expYear);276 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);277}278279async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {280 const encoder = new TextEncoder();281 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));282 return Array.from(new Uint8Array(buf).slice(0, 8))283 .map((b) => b.toString(16).padStart(2, "0"))284 .join("");285}286287function hex(buf: Uint8Array): string {288 return Array.from(buf)289 .map((b) => b.toString(16).padStart(2, "0"))290 .join("");291}292293function unhex(s: string): Uint8Array {294 const out = new Uint8Array(s.length / 2);295 for (let i = 0; i < s.length; i += 2) {296 out[i / 2] = parseInt(s.slice(i, i + 2), 16);297 }298 return out;299}300301302303304305306307308309310311312313314315316317318319320async function generateAndWrapRootKey(): Promise<{321 rawRootKey: string; 322 recoverySalt: string;323 wrappedRootKey: string;324 wrappedNonce: string;325 wrappedTag: string;326}> {327 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));328329 330 const words = await rootKeyToWords(rootKeyBytes);331 const wordMaterial = await wordsToRootKeyMaterial(words);332 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));333 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);334335 336 337 const nonce = crypto.getRandomValues(new Uint8Array(12));338 const encoded = new TextEncoder().encode(hex(rootKeyBytes));339 const raw = await crypto.subtle.encrypt(340 {341 name: "AES-GCM",342 iv: nonce,343 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,344 tagLength: 128,345 },346 recoveryKey,347 encoded348 );349 350 const rawU8 = new Uint8Array(raw);351 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));352 const tag = hex(rawU8.slice(rawU8.length - 16));353354 return {355 rawRootKey: hex(rootKeyBytes),356 recoverySalt: hex(recoverySalt),357 wrappedRootKey: ciphertext,358 wrappedNonce: hex(nonce),359 wrappedTag: tag,360 };361}362363async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{364 recoverySalt: string;365 wrappedRootKey: string;366 wrappedNonce: string;367 wrappedTag: string;368}> {369 if (rootKeyBytes.length !== 32) {370 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");371 }372373 const words = await rootKeyToWords(rootKeyBytes);374 const wordMaterial = await wordsToRootKeyMaterial(words);375 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));376 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);377 const nonce = crypto.getRandomValues(new Uint8Array(12));378 const encoded = new TextEncoder().encode(hex(rootKeyBytes));379 const raw = await crypto.subtle.encrypt(380 {381 name: "AES-GCM",382 iv: nonce,383 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,384 tagLength: 128,385 },386 recoveryKey,387 encoded,388 );389 const rawU8 = new Uint8Array(raw);390 return {391 recoverySalt: hex(recoverySalt),392 wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),393 wrappedNonce: hex(nonce),394 wrappedTag: hex(rawU8.slice(rawU8.length - 16)),395 };396}397398399400401402403export async function unwrapRootKeyForRecovery(404 wrappedRootKey: string,405 wrappedNonce: string,406 wrappedTag: string,407 recoverySaltHex: string,408 words: string[]409): Promise<void> {410 const wordMaterial = await wordsToRootKeyMaterial(words);411 const recoverySalt = unhex(recoverySaltHex);412 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);413414 415 416 417 418 419 const nonceRaw = unhex(wrappedNonce);420 const ciphertextRaw = unhex(wrappedRootKey);421 const tagRaw = unhex(wrappedTag);422 423 424 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;425 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);426 combined.set(ciphertextRaw, 0);427 combined.set(tagRaw, ciphertextRaw.length);428 const raw = await crypto.subtle.decrypt(429 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },430 recoveryKey,431 combined432 );433 const decryptedHex = new TextDecoder().decode(raw);434 rootKey = unhex(decryptedHex);435}436437438439440441442443444445446export async function rewrapRootKeyForPassword(447 newDerivedKey: Uint8Array,448 _newSalt: Uint8Array449): Promise<void> {450 if (!rootKey) throw new Error("root key not set");451 const slotA = await loadSlot(SLOT_A);452 if (!slotA) throw new Error("missing slot A");453 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);454 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });455}456457458459460461462async function reencryptVaultForNewKey(463 newKey: Uint8Array,464 slotA: StoredVault465): Promise<void> {466 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);467 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");468 await writeSlot(SLOT_A, {469 ...slotA,470 nonce,471 ciphertext,472 tag,473 });474}475476477478479480481export async function changePasswordWithNewKey(482 newSalt: Uint8Array,483 newKey: Uint8Array484): Promise<void> {485 const slotA = await loadSlot(SLOT_A);486 if (!slotA) throw new Error("missing slot A");487 488 489 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });490 if (derivedKey) zeroKey(derivedKey);491 derivedKey = newKey;492 493 494 495 void saveSessionKey(newKey, "real");496 497 498 499 500 if (rootKey) {501 const slotAfter = await loadSlot(SLOT_A);502 if (slotAfter) {503 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);504 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });505 }506 }507 if (!duressConfigured) {508 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));509 }510 await stampLocalVaultMutation();511}512513514515516517518export { deriveKey, generateSalt };519520export async function loadSlotA(): Promise<StoredVault | null> {521 return loadSlot(SLOT_A);522}523524525526527528529530531532533534535536async function deriveRootKeyFromDerivedKey(537 derivedKeyBytes: Uint8Array,538 slotA: StoredVault,539): Promise<Uint8Array | null> {540 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {541 return null;542 }543 try {544 const rootHex = await decrypt(545 derivedKeyBytes,546 slotA.pw_root_nonce,547 slotA.pw_root_ciphertext,548 slotA.pw_root_tag,549 "pw_root_v1",550 );551 return unhex(rootHex);552 } catch {553 return null;554 }555}556557558559async function wrapRootKeyForDerivedKey(560 derivedKeyBytes: Uint8Array,561 rootKeyBytes: Uint8Array,562): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {563 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");564 return {565 pw_root_ciphertext: ciphertext,566 pw_root_nonce: nonce,567 pw_root_tag: tag,568 };569}570571572573574575576577578export async function migrateToRecovery(): Promise<void> {579 if (!derivedKey || vaultMode !== "real") {580 throw new Error("vault must be unlocked in real mode");581 }582 const slotA = await loadSlot(SLOT_A);583 if (!slotA) throw new Error("missing slot A");584 const hasRecoveryEscrow =585 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;586 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;587 if (hasRecoveryEscrow && hasPwRoot) return; 588589 590 591 592 593 594 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =595 await generateAndWrapRootKey();596 rootKey = unhex(rawRootKey);597598 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);599 const nextSlotA: StoredVault = {600 ...slotA,601 recovery_salt: recoverySalt,602 recovery_wrapped_root_key: wrappedRootKey,603 recovery_nonce: wrappedNonce,604 recovery_tag: wrappedTag,605 ...pwRoot,606 };607 await writeSlot(SLOT_A, nextSlotA);608 await stampLocalVaultMutation();609}610611612613614615616export async function getRecoveryWords(): Promise<string[]> {617 if (!rootKey) {618 throw new Error("recovery not set up — vault may need migration");619 }620 return rootKeyToWords(rootKey);621}622623async function loadSlot(key: string): Promise<StoredVault | null> {624 const result = await chrome.storage.local.get(key);625 return (result as Record<string, StoredVault | undefined>)[key] ?? null;626}627628async function writeSlot(key: string, vault: StoredVault): Promise<void> {629 try {630 await chrome.storage.local.set({ [key]: vault });631 } catch (err) {632 throw mapStorageError(err);633 }634}635636637638639640641642643644645export async function rollbackAdoptedVault(): Promise<void> {646 lock();647 try {648 await chrome.storage.local.remove([649 SLOT_A,650 SLOT_B,651 "peach_last_modified_at",652 ]);653 } catch (err) {654 throw mapStorageError(err);655 }656}657658async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {659 const raw = await chrome.storage.local.get("peach_last_modified_at");660 const previous = Number(661 (raw as Record<string, unknown>).peach_last_modified_at,662 );663 const next = Math.max(664 Date.now(),665 Number.isFinite(previous) ? previous + 1 : 0,666 );667 try {668 await chrome.storage.local.set({669 [SLOT_A]: slotA,670 [SLOT_B]: slotB,671 peach_last_modified_at: String(next),672 });673 } catch (err) {674 throw mapStorageError(err);675 }676}677678679680681682683684685let lastEntryTimestamp = 0;686function monotonicEntryTimestamp(currentModifiedAt?: number): number {687 const base = Math.max(688 Date.now(),689 (currentModifiedAt ?? 0) + 1,690 lastEntryTimestamp + 1,691 );692 lastEntryTimestamp = base;693 return base;694}695696async function stampLocalVaultMutation(): 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 await chrome.storage.local.set({706 peach_last_modified_at: String(next),707 });708}709710711712713714715async function ensureMigrated(): Promise<void> {716 if (migrationRan) return;717 migrationRan = true;718719 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];720 const raw = await chrome.storage.local.get(keys);721 const slotAExists = raw[SLOT_A] !== undefined;722 const slotBExists = raw[SLOT_B] !== undefined;723 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;724 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;725726 727 if (!slotAExists && !legacyMain) return;728729 730 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;731732 let didMigrate = false;733 if (legacyMain && !slotAExists) {734 await writeSlot(SLOT_A, legacyMain);735 didMigrate = true;736 }737738 739 if (!slotBExists) {740 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;741 if (legacyDecoy) {742 await writeSlot(SLOT_B, legacyDecoy);743 didMigrate = true;744 } else if (target) {745 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));746 didMigrate = true;747 }748 }749750 const toRemove: string[] = [];751 if (legacyMain) toRemove.push(LEGACY_MAIN);752 if (legacyDecoy) toRemove.push(LEGACY_DECOY);753 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);754 if (toRemove.length > 0) {755 await chrome.storage.local.remove(toRemove);756 didMigrate = true;757 }758 if (didMigrate) {759 await stampLocalVaultMutation();760 }761}762763764765766767768769770771772773function padPlaintext(json: string, minBytes = 0): string {774 const encoded = new TextEncoder().encode(json);775 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);776 777 const target = Math.max(bucket, minBytes);778 const padLen = target - encoded.length;779 return json + " ".repeat(padLen);780}781782783784785786787788789790791792const CSPRNG_CHUNK = 65_536;793794function fillRandom(buf: Uint8Array): Uint8Array {795 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {796 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);797 const chunk = new Uint8Array(end - offset);798 crypto.getRandomValues(chunk);799 buf.set(chunk, offset);800 }801 return buf;802}803804function generateNoiseBlob(ciphertextBytes: number): StoredVault {805 const salt = crypto.getRandomValues(new Uint8Array(16));806 const nonce = crypto.getRandomValues(new Uint8Array(12));807 const cipher = fillRandom(new Uint8Array(ciphertextBytes));808 const tag = crypto.getRandomValues(new Uint8Array(16));809 return {810 version: 1,811 salt: hex(salt),812 nonce: hex(nonce),813 ciphertext: hex(cipher),814 tag: hex(tag),815 };816}817818819820821822type VaultPayload = {823 entries: VaultEntry[];824 duressConfigured?: boolean;825};826827type DecryptedPayload = {828 entries: VaultEntry[];829 duressConfigured: boolean;830 831832833834835836837 migratedFromLegacy: boolean;838};839840function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {841 const envelope: VaultPayload = { entries, duressConfigured: duress };842 return padPlaintext(JSON.stringify(envelope), minBytes);843}844845function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {846 if ("created" in raw || "updated" in raw) return true;847 const flat = raw as Partial<VaultEntry>;848 if (flat.type === "passkey") return true;849 850 if (flat.rpId !== undefined && flat.passkey === undefined) return true;851 return false;852}853854function decodePayload(plain: string): DecryptedPayload {855 const parsed = JSON.parse(plain) as856 | Array<VaultEntry | LegacyVaultEntry>857 | VaultPayload;858859 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];860 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);861862 const normalized = rawEntries.map(normalizeEntry);863 const { merged } = mergePasskeyOnlyIntoLogins(normalized);864865 if (Array.isArray(parsed)) {866 return {867 entries: normalized,868 duressConfigured: false,869 migratedFromLegacy: true, 870 };871 }872873 return {874 entries: normalized,875 duressConfigured: parsed.duressConfigured === true,876 migratedFromLegacy: flatMigrationNeeded || merged,877 };878}879880881882883884885886887888889890891892893894895896897898899900901902903function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {904 let merged = false;905 const now = Date.now();906907 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);908 const byRpId = new Map<string, VaultEntry[]>();909 for (const e of candidates) {910 const rpId = e.passkey!.rpId;911 const list = byRpId.get(rpId);912 if (list) list.push(e);913 else byRpId.set(rpId, [e]);914 }915916 for (const [rpId, group] of byRpId) {917 if (group.length === 0) continue;918 group.sort((a, b) => {919 const aT =920 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;921 const bT =922 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;923 return bT - aT;924 });925 const winner = group[0]!;926927 928 929 930 const loginTarget = entries.find((e) => {931 if (e.deletedAt || e.passkey || e.type !== "login") return false;932 if (e.url && extractDomain(e.url) === rpId) return true;933 for (const extra of e.additionalUrls ?? []) {934 if (extractDomain(extra) === rpId) return true;935 }936 return false;937 });938939 if (loginTarget) {940 loginTarget.passkey = winner.passkey;941 942 943 944 if (!loginTarget.totp && winner.totp) {945 loginTarget.totp = winner.totp;946 loginTarget.totpAlgorithm = winner.totpAlgorithm;947 loginTarget.totpDigits = winner.totpDigits;948 loginTarget.totpPeriod = winner.totpPeriod;949 }950 loginTarget.modifiedAt = now;951 for (const dup of group) {952 if (!dup.deletedAt) {953 dup.deletedAt = now;954 dup.modifiedAt = now;955 }956 }957 merged = true;958 } else if (group.length > 1) {959 for (let i = 1; i < group.length; i += 1) {960 const dup = group[i]!;961 if (!dup.deletedAt) {962 dup.deletedAt = now;963 dup.modifiedAt = now;964 }965 }966 merged = true;967 }968 }969970 return { merged };971}972973974975976977978979function hasValidVaultSchema(v: unknown): v is StoredVault {980 if (typeof v !== "object" || v === null) return false;981 const r = v as Record<string, unknown>;982 return (983 r.version === 1 &&984 typeof r.salt === "string" &&985 typeof r.nonce === "string" &&986 typeof r.ciphertext === "string" &&987 typeof r.tag === "string"988 );989}990991export type TryDecryptResult =992 | { status: "ok"; payload: DecryptedPayload }993 | { status: "wrong_key" }994 | { status: "corrupted"; reason: string };99599699799899910001001export async function tryDecryptSlot(1002 vault: StoredVault,1003 key: Uint8Array,1004 aadTag?: string,1005): Promise<DecryptedPayload | null> {1006 const res = await tryDecryptSlotResult(vault, key, aadTag);1007 return res.status === "ok" ? res.payload : null;1008}10091010export async function tryDecryptSlotResult(1011 vault: StoredVault,1012 key: Uint8Array,1013 aadTag?: string,1014): Promise<TryDecryptResult> {1015 if (!hasValidVaultSchema(vault)) {1016 return { status: "corrupted", reason: "invalid_slot_schema" };1017 }1018 let plain: string;1019 try {1020 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1021 } catch {1022 return { status: "wrong_key" };1023 }1024 try {1025 const parsed = JSON.parse(plain) as unknown;1026 if (typeof parsed !== "object" || parsed === null) {1027 return { status: "corrupted", reason: "invalid_payload_shape" };1028 }1029 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1030 return { status: "corrupted", reason: "invalid_payload_shape" };1031 }1032 return { status: "ok", payload: decodePayload(plain) };1033 } catch {1034 return { status: "corrupted", reason: "invalid_payload_json" };1035 }1036}103710381039104010411042function buildDomainIndexSync(all: VaultEntry[]): void {1043 domainIndex = new Map();10441045 for (const entry of all) {1046 if (entry.deletedAt) continue;1047 if (entry.type === "note" || entry.type === "card") continue;10481049 1050 1051 1052 1053 const domains = new Set<string>();1054 const primary = extractDomain(entry.url ?? "");1055 if (primary) domains.add(primary);1056 for (const extra of entry.additionalUrls ?? []) {1057 const d = extractDomain(extra);1058 if (d) domains.add(d);1059 }1060 if (domains.size === 0) continue;10611062 for (const domain of domains) {1063 const bucket = domainIndex.get(domain) ?? [];1064 bucket.push(entry);1065 domainIndex.set(domain, bucket);1066 }1067 }1068}10691070export async function createVault(password: string): Promise<void> {1071 await ensureMigrated();10721073 const salt = generateSalt();1074 derivedKey = await deriveKey(password, salt);1075 entries = [];1076 vaultMode = "real";1077 duressConfigured = false;1078 buildDomainIndexSync(entries);10791080 1081 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1082 await generateAndWrapRootKey();1083 rootKey = unhex(rawRootKey);10841085 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1086 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");10871088 1089 1090 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);10911092 const slotA: StoredVault = {1093 version: 1,1094 salt: hex(salt),1095 nonce,1096 ciphertext,1097 tag,1098 recovery_salt: recoverySalt,1099 recovery_wrapped_root_key: wrappedRootKey,1100 recovery_nonce: wrappedNonce,1101 recovery_tag: wrappedTag,1102 ...pwRoot,1103 };1104 await writeSlot(SLOT_A, slotA);11051106 1107 1108 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1109 await stampLocalVaultMutation();11101111 touchAutoLock();1112}1113111411151116111711181119export async function createVaultFromRootKey(1120 password: string,1121 rootKeyHex: string,1122): Promise<{ vaultId: string }> {1123 if (typeof password !== "string" || password.length === 0) {1124 throw new Error("ADOPT_INVALID_PASSWORD");1125 }1126 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1127 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1128 }11291130 const rootKeyBytes = unhex(rootKeyHex);1131 if (rootKeyBytes.length !== 32) {1132 rootKeyBytes.fill(0);1133 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1134 }11351136 let nextDerivedKey: Uint8Array | null = null;1137 let sessionRootKey: Uint8Array | null = null;1138 try {1139 await ensureMigrated();1140 if ((await loadSlot(SLOT_A)) !== null) {1141 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1142 }11431144 const nextEntries: VaultEntry[] = [];1145 const nextDuressConfigured = false;1146 const vaultId = await computeVaultId(rootKeyBytes);1147 const salt = generateSalt();1148 nextDerivedKey = await deriveKey(password, salt);1149 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);11501151 sessionRootKey = new Uint8Array(rootKeyBytes);1152 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1153 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1154 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1155 const slotA: StoredVault = {1156 version: 1,1157 salt: hex(salt),1158 nonce,1159 ciphertext,1160 tag,1161 recovery_salt: recovery.recoverySalt,1162 recovery_wrapped_root_key: recovery.wrappedRootKey,1163 recovery_nonce: recovery.wrappedNonce,1164 recovery_tag: recovery.wrappedTag,1165 ...pwRoot,1166 };1167 const slotB = generateNoiseBlob(ciphertext.length / 2);1168 await commitCreatedVaultSlots(slotA, slotB);11691170 const previousDerivedKey = derivedKey;1171 const previousRootKey = rootKey;1172 derivedKey = nextDerivedKey;1173 rootKey = sessionRootKey;1174 nextDerivedKey = null;1175 sessionRootKey = null;1176 entries = nextEntries;1177 vaultMode = "real";1178 duressConfigured = nextDuressConfigured;1179 buildDomainIndexSync(entries);1180 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1181 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1182 touchAutoLock();11831184 return { vaultId };1185 } finally {1186 rootKeyBytes.fill(0);1187 if (nextDerivedKey) zeroKey(nextDerivedKey);1188 if (sessionRootKey) sessionRootKey.fill(0);1189 }1190}11911192export async function hasVault(): Promise<boolean> {1193 await ensureMigrated();1194 return (await loadSlot(SLOT_A)) !== null;1195}1196119711981199120012011202export async function isDuressEnabled(): Promise<boolean> {1203 return vaultMode === "real" && duressConfigured;1204}12051206export async function setupDuress(duressPassword: string): Promise<void> {1207 if (!derivedKey || vaultMode !== "real") {1208 throw new Error("vault must be unlocked in real mode");1209 }1210 await ensureMigrated();12111212 1213 1214 1215 const slotA = await loadSlot(SLOT_A);1216 if (!slotA) throw new Error("missing slot A");12171218 const decoySalt = generateSalt();1219 const decoyKey = await deriveKey(duressPassword, decoySalt);1220 try {1221 1222 1223 1224 1225 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1226 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1227 await writeSlot(SLOT_B, {1228 version: 1,1229 salt: hex(decoySalt),1230 nonce,1231 ciphertext,1232 tag,1233 });1234 } finally {1235 zeroKey(decoyKey);1236 }12371238 1239 1240 duressConfigured = true;1241 await persistSlotA();1242}12431244export async function disableDuress(): Promise<void> {1245 if (!derivedKey || vaultMode !== "real") {1246 throw new Error("vault must be unlocked in real mode");1247 }1248 await ensureMigrated();12491250 1251 1252 1253 const slotA = await loadSlot(SLOT_A);1254 if (slotA) {1255 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1256 }12571258 duressConfigured = false;1259 await persistSlotA();1260}12611262126312641265126612671268export async function verifyMainPassword(password: string): Promise<boolean> {1269 await ensureMigrated();1270 const slotA = await loadSlot(SLOT_A);1271 if (!slotA) return false;1272 const key = await deriveKey(password, unhex(slotA.salt));1273 try {1274 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1275 } finally {1276 zeroKey(key);1277 }1278}12791280export async function unlock(password: string): Promise<boolean> {1281 1282 1283 1284 if (await attemptUnlockWithDerive(password, deriveKey)) return true;12851286 const nfc = password.normalize("NFC");1287 if (password === nfc) return false; 12881289 if (await attemptUnlockWithDerive(password, deriveKeyRaw)) {1290 1291 1292 1293 if (vaultMode === "real") {1294 try {1295 const newSalt = generateSalt();1296 const newKey = await deriveKey(password, newSalt);1297 await changePasswordWithNewKey(newSalt, newKey);1298 } catch (healErr) {1299 console.warn("[Peach] LV-1 NFC heal failed:", healErr);1300 }1301 }1302 return true;1303 }1304 return false;1305}13061307async function attemptUnlockWithDerive(1308 password: string,1309 derive: typeof deriveKey,1310): Promise<boolean> {1311 await ensureMigrated();13121313 1314 1315 1316 1317 1318 const [slotA, slotB] = await Promise.all([1319 loadSlot(SLOT_A),1320 loadSlot(SLOT_B),1321 ]);13221323 1324 const [keyA, keyB] = await Promise.all([1325 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1326 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1327 ]);13281329 1330 1331 1332 const [resultA, resultB] = await Promise.all([1333 keyA && slotA1334 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1335 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1336 keyB && slotB1337 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1338 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1339 ]);1340 const payloadA = resultA.status === "ok" ? resultA.payload : null;1341 const payloadB = resultB.status === "ok" ? resultB.payload : null;13421343 if (payloadA && keyA) {1344 derivedKey = keyA;1345 void saveSessionKey(keyA, "real");1346 vaultMode = "real";1347 entries = payloadA.entries;1348 duressConfigured = payloadA.duressConfigured;1349 buildDomainIndexSync(entries);1350 await loadSettings();1351 touchAutoLock();1352 if (keyB) zeroKey(keyB);13531354 1355 if (slotA) {1356 const needsRecoverySetup =1357 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1358 if (!needsRecoverySetup) {1359 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1360 }1361 1362 1363 1364 1365 if (needsRecoverySetup || !rootKey) {1366 try {1367 await migrateToRecovery();1368 if (needsRecoverySetup) {1369 1370 1371 1372 await chrome.storage.local.set({ peach_recovery_pending: '1' });1373 }1374 } catch (migrationErr) {1375 console.warn('[Peach] recovery migration failed:', migrationErr);1376 }1377 }1378 }13791380 1381 1382 1383 if (payloadA.migratedFromLegacy) {1384 await persistSlotA();1385 }1386 await lockedSyncUnlockHook?.().catch((error) => {1387 console.warn("[Peach sync] locked inbox drain failed:", error);1388 });1389 return true;1390 }13911392 if (payloadB && keyB) {1393 derivedKey = keyB;1394 void saveSessionKey(keyB, "decoy");1395 vaultMode = "decoy";1396 entries = payloadB.entries;1397 duressConfigured = false;1398 buildDomainIndexSync(entries);1399 await loadSettings();1400 touchAutoLock();1401 if (keyA) zeroKey(keyA);1402 return true;1403 }14041405 if (keyA) zeroKey(keyA);1406 if (keyB) zeroKey(keyB);1407 1408 1409 if (resultA.status === "corrupted") {1410 lastUnlockFailureReason = resultA.reason;1411 } else if (resultB.status === "corrupted") {1412 lastUnlockFailureReason = resultB.reason;1413 }1414 return false;1415}14161417141814191420142114221423const SESSION_KEY_STORAGE = "peach_session_dk";14241425async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1426 if (!chrome.storage.session) return;1427 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1428}14291430async function clearSessionKey(): Promise<void> {1431 if (!chrome.storage.session) return;1432 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1433}143414351436143714381439144014411442export async function restoreFromSession(): Promise<boolean> {1443 if (derivedKey !== null) return true;1444 if (!chrome.storage.session) return false;1445 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1446 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1447 if (!entry?.keyHex) return false;14481449 const key = unhex(entry.keyHex);1450 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1451 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1452 const slot = await loadSlot(slotKey);1453 if (!slot) { await clearSessionKey(); return false; }14541455 const result = await tryDecryptSlotResult(slot, key, aadTag);1456 if (result.status !== "ok") { await clearSessionKey(); return false; }14571458 derivedKey = key;1459 vaultMode = entry.mode;1460 entries = result.payload.entries;1461 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1462 buildDomainIndexSync(entries);1463 await loadSettings();1464 touchAutoLock();14651466 1467 1468 1469 1470 1471 1472 1473 1474 1475 if (entry.mode === "real") {1476 try {1477 rootKey = await deriveRootKeyFromDerivedKey(key, slot);1478 } catch {1479 1480 }1481 }14821483 await lockedSyncUnlockHook?.().catch((error) => {1484 console.warn("[Peach sync] locked inbox drain failed:", error);1485 });14861487 return true;1488}14891490export function lock(): void {1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 void clearSessionKey();1501 if (derivedKey) {1502 zeroKey(derivedKey);1503 derivedKey = null;1504 }1505 if (rootKey) {1506 rootKey.fill(0);1507 rootKey = null;1508 }15091510 entries = [];1511 domainIndex = new Map();1512 vaultMode = "real";1513 duressConfigured = false;1514 clearAutoLock();1515}15161517export function isUnlocked(): boolean {1518 return derivedKey !== null;1519}15201521152215231524export async function unlockWithKey(1525 masterKeyHex: string,1526): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1527 try {1528 const masterKeyBytes = unhex(masterKeyHex);1529 const stored = await loadSlot(SLOT_A);1530 if (!stored) return { ok: false, error: "invalid_key" };1531 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1532 if (!payload) return { ok: false, error: "invalid_key" };15331534 derivedKey = masterKeyBytes;1535 vaultMode = "real";1536 entries = payload.entries;1537 duressConfigured = payload.duressConfigured;1538 buildDomainIndexSync(entries);1539 await loadSettings();1540 touchAutoLock();15411542 1543 1544 1545 1546 1547 if (stored.recovery_salt && stored.recovery_wrapped_root_key) {1548 try {1549 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1550 } catch { }1551 }1552 return { ok: true, entries };1553 } catch {1554 return { ok: false, error: "invalid_key" };1555 }1556}155715581559156015611562156315641565export async function exportDuressSlotForAdoption(): Promise<string | null> {1566 if (!(await isDuressEnabled())) return null;1567 await ensureMigrated();1568 const slotB = await loadSlot(SLOT_B);1569 if (!slotB) return null;1570 return JSON.stringify(slotB);1571}15721573157415751576157715781579export async function installAdoptedDuressSlot(json: string): Promise<void> {1580 let parsed: StoredVault;1581 try {1582 const raw = JSON.parse(json) as unknown;1583 if (!hasValidVaultSchema(raw)) {1584 throw new Error("invalid_duress_slot_shape");1585 }1586 parsed = raw;1587 } catch (err) {1588 if (err instanceof SyntaxError) {1589 throw new Error("invalid_duress_slot_shape");1590 }1591 throw err;1592 }1593 await writeSlot(SLOT_B, parsed);1594}15951596159715981599160016011602160316041605160616071608160916101611161216131614export async function unlockFromCodex(params: {1615 slotA: string; 1616 slotB: string | null; 1617 timestamp: number;1618 masterPassphrase: string;1619}): Promise<1620 | { ok: true; entryCount: number }1621 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1622> {1623 let parsedSlotA: StoredVault;1624 let parsedSlotB: StoredVault | null = null;1625 try {1626 const rawA = JSON.parse(params.slotA) as unknown;1627 if (!hasValidVaultSchema(rawA)) {1628 return { ok: false, reason: "invalid_slot_format" };1629 }1630 parsedSlotA = rawA;1631 if (params.slotB !== null) {1632 const rawB = JSON.parse(params.slotB) as unknown;1633 if (!hasValidVaultSchema(rawB)) {1634 return { ok: false, reason: "invalid_slot_format" };1635 }1636 parsedSlotB = rawB;1637 }1638 } catch {1639 return { ok: false, reason: "invalid_slot_format" };1640 }16411642 1643 1644 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1645 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1646 if (!trialPayload) {1647 zeroKey(trialKey);1648 return { ok: false, reason: "wrong_passphrase" };1649 }1650 1651 1652 zeroKey(trialKey);16531654 1655 1656 await takeVaultSnapshot("restore");16571658 await writeSlot(SLOT_A, parsedSlotA);1659 if (parsedSlotB) {1660 await writeSlot(SLOT_B, parsedSlotB);1661 } else {1662 1663 1664 1665 await writeSlot(1666 SLOT_B,1667 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1668 );1669 }1670 await chrome.storage.local.set({1671 peach_last_modified_at: String(params.timestamp),1672 });16731674 1675 1676 const ok = await unlock(params.masterPassphrase);1677 if (!ok) {1678 1679 1680 1681 return { ok: false, reason: "wrong_passphrase" };1682 }1683 return { ok: true, entryCount: getEntries().length };1684}1685168616871688export function getDerivedKeyHex(): string {1689 if (!derivedKey) throw new Error("vault_not_unlocked");1690 return hex(derivedKey);1691}16921693169416951696169716981699170017011702170317041705170617071708export function getRootKeyHex(): string | null {1709 return rootKey ? hex(rootKey) : null;1710}17111712export function getEntries(): VaultEntry[] {1713 if (!derivedKey) throw new Error("vault_locked");1714 return entries.filter((entry) => !entry.deletedAt);1715}17161717const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;17181719export function getIdentities(): SavedIdentity[] {1720 if (!derivedKey) throw new Error("vault_locked");1721 const active = entries.filter((e) => !e.deletedAt && e.type === "login");17221723 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1724 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();17251726 for (const entry of active) {1727 const domain = extractDomain(entry.url ?? "") ?? "";1728 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;17291730 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1731 const existing = map.get(v);1732 if (!existing || modifiedAt > existing.lastUsed) {1733 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1734 } else {1735 map.set(v, { ...existing, count: existing.count + 1 });1736 }1737 }17381739 const uVal = entry.username?.trim();1740 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);17411742 1743 1744 const eVal = entry.email?.trim();1745 if (eVal) tally(emailMap, eVal);1746 }17471748 const toIdentity = (type: "email" | "username") =>1749 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1750 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1751 });17521753 return [1754 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1755 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1756 ];1757}17581759export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1760 if (!derivedKey) throw new Error("vault_locked");17611762 const now = Date.now();1763 const added: VaultEntry[] = [];1764 for (const draft of drafts) {1765 try {1766 const sanitized = sanitizeEntryDraft(draft);1767 const nextEntry: VaultEntry = {1768 ...sanitized,1769 id: crypto.randomUUID(),1770 createdAt: now,1771 modifiedAt: monotonicEntryTimestamp(),1772 };1773 entries.push(nextEntry);1774 added.push(nextEntry);1775 } catch {1776 1777 }1778 }17791780 if (added.length > 0) {1781 await persistEntries();1782 }17831784 return added;1785}178617871788178917901791179217931794export async function restoreEntries(1795 incoming: VaultEntry[],1796 mode: "replace" | "merge"1797): Promise<{ added: number; updated: number; skipped: number }> {1798 if (!derivedKey) throw new Error("vault_locked");17991800 1801 1802 1803 if (mode === "replace" && incoming.length === 0) {1804 throw new Error("refuse to replace with empty entry set");1805 }18061807 let added = 0;1808 let updated = 0;1809 let skipped = 0;18101811 if (mode === "replace") {1812 entries = [];1813 for (const entry of incoming) {1814 const normalized = sanitizeRestoredEntry(entry);1815 if (!normalized) {1816 skipped += 1;1817 continue;1818 }1819 entries.push(normalized);1820 added += 1;1821 }1822 } else {1823 const byId = new Map<string, VaultEntry>();1824 for (const e of entries) byId.set(e.id, e);18251826 for (const entry of incoming) {1827 const normalized = sanitizeRestoredEntry(entry);1828 if (!normalized) {1829 skipped += 1;1830 continue;1831 }1832 const existing = byId.get(normalized.id);1833 if (!existing) {1834 entries.push(normalized);1835 added += 1;1836 } else if (normalized.modifiedAt > existing.modifiedAt) {1837 const idx = entries.findIndex((e) => e.id === existing.id);1838 if (idx !== -1) {1839 entries[idx] = normalized;1840 updated += 1;1841 }1842 } else {1843 skipped += 1;1844 }1845 }1846 }18471848 await persistEntries("import");1849 return { added, updated, skipped };1850}185118521853185418551856185718581859function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1860 if (!entry || typeof entry !== "object") return null;1861 const title = typeof entry.title === "string" ? entry.title.trim() : "";1862 if (!title) return null;1863 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1864 const now = Date.now();1865 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1866 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;18671868 1869 1870 1871 1872 const normalized = normalizeEntry({1873 ...entry,1874 id,1875 title,1876 type: entry.type ?? "login",1877 createdAt,1878 modifiedAt,1879 });18801881 if (normalized.passkey) {1882 normalized.passkey = sanitizePasskey(normalized.passkey);1883 }1884 if (normalized.canary) {1885 normalized.canary = sanitizeCanary(normalized.canary);1886 }1887 1888 normalized.tags = sanitizeTags(normalized.tags);18891890 1891 1892 if (1893 normalized.type === "login" &&1894 !normalized.passkey &&1895 typeof normalized.password !== "string"1896 ) {1897 return null;1898 }1899 if (entry.passkey && !normalized.passkey) {1900 1901 1902 return null;1903 }19041905 return normalized;1906}1907190819091910191119121913function sanitizePasskey(pk: unknown): PasskeyData | undefined {1914 if (!pk || typeof pk !== "object") return undefined;1915 const r = pk as Record<string, unknown>;1916 if (1917 typeof r.rpId !== "string" || !r.rpId ||1918 typeof r.credentialId !== "string" || !r.credentialId ||1919 typeof r.publicKeyCose !== "string" ||1920 typeof r.userHandle !== "string" ||1921 typeof r.userName !== "string"1922 ) {1923 return undefined;1924 }1925 return {1926 rpId: r.rpId,1927 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1928 credentialId: r.credentialId,1929 publicKeyCose: r.publicKeyCose,1930 privateKeyJwk:1931 r.privateKeyJwk && typeof r.privateKeyJwk === "object"1932 ? (r.privateKeyJwk as JsonWebKey)1933 : undefined,1934 userHandle: r.userHandle,1935 userName: r.userName,1936 userDisplayName:1937 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1938 algorithm: r.algorithm === -8 ? -8 : -7,1939 signCount: typeof r.signCount === "number" ? r.signCount : 0,1940 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1941 backupEligible: r.backupEligible === true,1942 backupState: r.backupState === true,1943 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1944 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1945 transports: Array.isArray(r.transports)1946 ? r.transports.filter((t): t is string => typeof t === "string")1947 : undefined,1948 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1949 };1950}19511952195319541955195619571958195919601961function sanitizeCanary(c: unknown): Canary | undefined {1962 if (!c || typeof c !== "object") return undefined;1963 const r = c as Record<string, unknown>;1964 if (typeof r.webhookUrl !== "string") return undefined;1965 let url: URL;1966 try {1967 url = new URL(r.webhookUrl);1968 } catch {1969 return undefined;1970 }1971 if (url.protocol !== "https:") return undefined;1972 return {1973 webhookUrl: url.toString(),1974 label: typeof r.label === "string" ? r.label : undefined,1975 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,1976 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,1977 };1978}19791980198119821983198419851986function sanitizeTags(tags: unknown): string[] | undefined {1987 if (!Array.isArray(tags)) return undefined;1988 const out: string[] = [];1989 for (const t of tags) {1990 if (typeof t !== "string") continue;1991 const trimmed = t.trim();1992 if (!trimmed) continue;1993 out.push(trimmed.slice(0, 256));1994 if (out.length >= 255) break;1995 }1996 return out.length > 0 ? out : undefined;1997}199819992000200120022003export async function clearVaultEntries(2004 confirmation: "REPLACE_ALL"2005): Promise<void> {2006 if (confirmation !== "REPLACE_ALL") {2007 throw new Error("clearVaultEntries requires explicit confirmation");2008 }2009 if (!derivedKey) throw new Error("vault_locked");2010 for (const entry of entries) {2011 if (!entry.deletedAt) {2012 const stamp = monotonicEntryTimestamp(entry.modifiedAt);2013 entry.deletedAt = stamp;2014 entry.modifiedAt = stamp;2015 }2016 }2017 await persistEntries();2018}20192020export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2021 if (!derivedKey) throw new Error("vault_locked");20222023 const sanitized = sanitizeEntryDraft(entry);2024 const now = Date.now();2025 const stamp = monotonicEntryTimestamp();2026 const nextEntry: VaultEntry = {2027 ...sanitized,2028 id: crypto.randomUUID(),2029 createdAt: now,2030 modifiedAt: stamp,2031 2032 2033 passwordChangedAt: sanitized.password ? stamp : undefined,2034 };20352036 2037 2038 2039 const rollback = entries;2040 entries = [...entries, nextEntry];2041 try {2042 await persistEntries();2043 } catch (err) {2044 entries = rollback;2045 throw err;2046 }20472048 2049 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2050 checkBreach(nextEntry.password).then(async (count) => {2051 if (count > 0) {2052 const idx = entries.findIndex((e) => e.id === nextEntry.id);2053 if (idx !== -1) {2054 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2055 await persistEntries();2056 }2057 }2058 });2059 }20602061 fireSyncTrigger("vault_change");20622063 return nextEntry;2064}20652066206720682069207020712072export async function consolidatePasskeyEntries(): Promise<void> {2073 if (!derivedKey) return;2074 const { merged } = mergePasskeyOnlyIntoLogins(entries);2075 if (merged) {2076 await persistEntries();2077 }2078}20792080export async function updateEntry(2081 id: string,2082 patch: Partial<VaultEntry>2083): Promise<void> {2084 if (!derivedKey) throw new Error("vault_locked");20852086 const idx = entries.findIndex((entry) => entry.id === id);2087 if (idx === -1 || entries[idx].deletedAt) {2088 throw new Error("entry_not_found");2089 }20902091 const current = entries[idx];2092 const sanitizedPatch = sanitizeEntryPatch(patch);2093 2094 2095 2096 const passwordChanged =2097 "password" in patch &&2098 typeof sanitizedPatch.password === "string" &&2099 sanitizedPatch.password !== current.password;2100 const stamp = monotonicEntryTimestamp(current.modifiedAt);2101 2102 2103 2104 2105 2106 2107 2108 const priorPassword = current.password;2109 const shouldCapture =2110 passwordChanged &&2111 typeof priorPassword === "string" &&2112 priorPassword.length > 0 &&2113 current.passwordHistory?.[0]?.password !== priorPassword;2114 const nextHistory = shouldCapture2115 ? [2116 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2117 ...(current.passwordHistory ?? []),2118 ].slice(0, PASSWORD_HISTORY_MAX)2119 : current.passwordHistory;2120 entries[idx] = {2121 ...current,2122 ...sanitizedPatch,2123 modifiedAt: stamp,2124 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2125 };2126212721282129 2130 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2131 entries[idx] = current;2132 throw new Error("card number is required for card entries");2133 }21342135 2136 2137 2138 2139 try {2140 await persistEntries();2141 } catch (err) {2142 entries[idx] = current;2143 throw err;2144 }21452146 2147 2148 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2149 entries[idx] = { ...entries[idx], breach: undefined };2150 await persistEntries();2151 const password = entries[idx].password;2152 if (password) {2153 checkBreach(password).then(async (count) => {2154 const i = entries.findIndex((e) => e.id === id);2155 if (i !== -1 && count > 0) {2156 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2157 await persistEntries();2158 }2159 });2160 }2161 }21622163 fireSyncTrigger("vault_change");2164}21652166export async function deleteEntry(id: string): Promise<void> {2167 if (!derivedKey) throw new Error("vault_locked");21682169 const entry = entries.find((item) => item.id === id);2170 if (!entry || entry.deletedAt) {2171 throw new Error("entry_not_found");2172 }21732174 2175 2176 2177 const previousModifiedAt = entry.modifiedAt;2178 const stamp = monotonicEntryTimestamp(entry.modifiedAt);2179 entry.deletedAt = stamp;2180 entry.modifiedAt = stamp;2181 2182 2183 try {2184 await persistEntries();2185 } catch (err) {2186 delete entry.deletedAt;2187 entry.modifiedAt = previousModifiedAt;2188 throw err;2189 }2190 fireSyncTrigger("vault_change");2191}21922193export function getDeletedEntries(): VaultEntry[] {2194 if (!derivedKey) return [];2195 return entries.filter((e) => !!e.deletedAt);2196}2197219821992200220122022203export async function getVaultVersion(): Promise<number> {2204 try {2205 const raw = await chrome.storage.local.get("peach_last_modified_at");2206 const v = (raw as Record<string, unknown>).peach_last_modified_at;2207 if (typeof v === "number") return v;2208 if (typeof v === "string" && v !== "") {2209 const n = Number.parseInt(v, 10);2210 return Number.isFinite(n) ? n : 0;2211 }2212 return 0;2213 } catch {2214 return 0;2215 }2216}221722182219222022212222222322242225222622272228export async function addEntryDirect(entry: VaultEntry): Promise<void> {2229 if (!derivedKey) throw new Error("vault_locked");2230 entries = [...entries, entry];2231 await persistEntries("sync_received");2232}22332234export async function updateEntryDirect(patch: VaultEntry): Promise<void> {2235 if (!derivedKey) throw new Error("vault_locked");2236 const idx = entries.findIndex((e) => e.id === patch.id);2237 if (idx === -1) throw new Error("entry_not_found");2238 entries[idx] = { ...entries[idx], ...patch };2239 await persistEntries("sync_received");2240}22412242export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {2243 if (!derivedKey) throw new Error("vault_locked");2244 const idx = entries.findIndex((e) => e.id === id);2245 if (idx === -1) throw new Error("entry_not_found");2246 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };2247 await persistEntries("sync_received");2248}2249225022512252225322542255225622572258225922602261export async function applyMergedRemoteEntries(2262 remoteEntries: VaultEntry[],2263 remoteTombstones: Record<string, number>,2264 _context: { deviceName?: string } = {},2265): Promise<{ received: number; applied: number; conflicts: number }> {2266 2267 2268 2269 2270 2271 2272 if (!derivedKey) throw new Error("vault_locked");22732274 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2275 let applied = 0;2276 let conflicts = 0;22772278 const byId = new Map<string, VaultEntry>();2279 for (const e of entries) byId.set(e.id, e);22802281 const mergeNow = Date.now();22822283 for (const remote of remoteEntries) {2284 const sanitized = sanitizeRestoredEntry(remote);2285 if (!sanitized) continue;2286 2287 2288 2289 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2290 if (typeof sanitized.deletedAt === "number") {2291 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2292 }2293 const tombstoneTs = remoteTombstones[sanitized.id];2294 if (tombstoneTs !== undefined && clampSyncTimestamp(tombstoneTs, mergeNow) >= sanitized.modifiedAt) {2295 continue;2296 }2297 const local = byId.get(sanitized.id);2298 if (!local) {2299 entries.push(sanitized);2300 byId.set(sanitized.id, sanitized);2301 applied += 1;2302 } else if (sanitized.modifiedAt > local.modifiedAt) {2303 const idx = entries.findIndex((e) => e.id === local.id);2304 if (idx !== -1) {2305 entries[idx] = sanitized;2306 byId.set(sanitized.id, sanitized);2307 applied += 1;2308 if (!local.deletedAt) conflicts += 1;2309 }2310 }2311 }23122313 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2314 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt)) continue;2315 2316 const deletedAt = clampSyncTimestamp(rawDeletedAt, mergeNow);2317 const local = byId.get(id);2318 if (!local) continue;2319 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2320 if (deletedAt < local.modifiedAt) continue;2321 const idx = entries.findIndex((e) => e.id === id);2322 if (idx !== -1) {2323 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2324 applied += 1;2325 }2326 }23272328 if (applied > 0) {2329 await persistEntries("sync_received");2330 buildDomainIndexSync(entries);2331 }2332 return { received, applied, conflicts };2333}23342335export async function restoreEntry(id: string): Promise<void> {2336 if (!derivedKey) throw new Error("vault_locked");2337 const entry = entries.find((e) => e.id === id && e.deletedAt);2338 if (!entry) throw new Error("deleted entry not found");2339 delete entry.deletedAt;2340 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);2341 await persistEntries();2342 buildDomainIndexSync(entries);2343}23442345export async function purgeEntry(id: string): Promise<void> {2346 if (!derivedKey) throw new Error("vault_locked");2347 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2348 if (idx === -1) throw new Error("deleted entry not found");2349 entries.splice(idx, 1);2350 await persistEntries();2351}235223532354export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2355 if (!derivedKey || retentionMs === 0) return 0;2356 const cutoff = Date.now() - retentionMs;2357 const before = entries.length;2358 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2359 const purged = before - entries.length;2360 if (purged > 0) await persistEntries();2361 return purged;2362}23632364export function queryDomain(domain: string): VaultEntry[] {2365 return domainIndex.get(domain) ?? [];2366}23672368export function getEntryById(id: string): VaultEntry | undefined {2369 return entries.find((entry) => entry.id === id && !entry.deletedAt);2370}23712372async function persistSlotA(2373 reason: VaultSnapshot["reason"] = "local_save",2374): Promise<StoredVault> {2375 if (!derivedKey) throw new Error("vault_locked");23762377 const slotA = await loadSlot(SLOT_A);2378 if (!slotA) throw new Error("missing slot A");23792380 2381 2382 await takeVaultSnapshot(reason);23832384 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2385 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");23862387 2388 const nextSlotA: StoredVault = {2389 ...slotA,2390 nonce,2391 ciphertext,2392 tag,2393 2394 };2395 await writeSlot(SLOT_A, nextSlotA);23962397 2398 2399 2400 2401 2402 2403 try {2404 2405 2406 2407 if (!duressConfigured) {2408 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2409 }2410 2411 2412 2413 2414 2415 if (reason !== "sync_received") {2416 await stampLocalVaultMutation();2417 }2418 } catch (err) {2419 2420 2421 await writeSlot(SLOT_A, slotA).catch(() => {});2422 throw err;2423 }24242425 return nextSlotA;2426}24272428async function persistSlotB(): Promise<void> {2429 if (!derivedKey) throw new Error("vault_locked");24302431 const slotB = await loadSlot(SLOT_B);2432 if (!slotB) throw new Error("missing slot B");24332434 2435 2436 2437 2438 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2439 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");24402441 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2442 2443 2444 2445 try {2446 await stampLocalVaultMutation();2447 } catch (err) {2448 await writeSlot(SLOT_B, slotB).catch(() => {});2449 throw err;2450 }2451}24522453async function persistEntries(2454 reason: VaultSnapshot["reason"] = "local_save",2455): Promise<void> {2456 if (!derivedKey) throw new Error("vault_locked");24572458 if (vaultMode === "decoy") {2459 await persistSlotB();2460 } else {2461 await persistSlotA(reason);2462 }2463 buildDomainIndexSync(entries);2464 await refreshLockedSyncState();2465}24662467function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2468 if (!Array.isArray(values)) return undefined;2469 const seen = new Set<string>();2470 const cleaned: string[] = [];2471 for (const value of values) {2472 const trimmed = typeof value === "string" ? value.trim() : "";2473 if (!trimmed) continue;2474 const key = trimmed.toLowerCase();2475 if (seen.has(key)) continue;2476 seen.add(key);2477 cleaned.push(trimmed);2478 }2479 return cleaned.length > 0 ? cleaned : undefined;2480}24812482function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2483 const title = entry.title.trim();2484 2485 2486 2487 let type: VaultEntry["type"] = entry.type ?? "login";2488 if (type === "passkey") type = "login";24892490 if (!title) throw new Error("title is required");24912492 if (entry.passkey) {2493 const p = entry.passkey;2494 if (2495 !p.rpId ||2496 !p.credentialId ||2497 !p.publicKeyCose ||2498 !p.userHandle ||2499 !p.userName ||2500 (p.algorithm !== -7 && p.algorithm !== -8)2501 ) {2502 throw new Error("invalid passkey credential");2503 }2504 }2505 if (type === "card") {2506 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2507 throw new Error("cardholder name and card number are required");2508 }2509 }25102511 return {2512 type,2513 title,2514 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2515 email: type === "login" ? entry.email?.trim() || undefined : undefined,2516 password: type === "login" ? entry.password : undefined,2517 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2518 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2519 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2520 2521 2522 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2523 totpDigits: type === "login" ? entry.totpDigits : undefined,2524 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2525 notes: entry.notes?.trim() || undefined,2526 breach: type === "login" ? entry.breach : undefined,2527 2528 2529 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2530 tags: sanitizeTags(entry.tags),2531 passkey: entry.passkey,2532 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2533 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2534 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2535 expYear: type === "card" ? entry.expYear?.trim() : undefined,2536 cvc: type === "card" ? entry.cvc?.trim() : undefined,2537 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2538 network: type === "card" ? entry.network : undefined,2539 last4: type === "card" ? entry.last4?.trim() : undefined,2540 };2541}25422543function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2544 const nextPatch: Partial<VaultEntry> = {};25452546 if ("type" in patch) {2547 nextPatch.type = patch.type;2548 }25492550 if ("title" in patch) {2551 const title = patch.title?.trim() ?? "";2552 if (!title) throw new Error("title is required");2553 nextPatch.title = title;2554 }25552556 if ("url" in patch) {2557 nextPatch.url = patch.url?.trim() ?? "";2558 }25592560 if ("additionalUrls" in patch) {2561 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2562 const seen = new Set<string>();2563 const cleaned: string[] = [];2564 for (const value of incoming) {2565 const trimmed = typeof value === "string" ? value.trim() : "";2566 if (!trimmed) continue;2567 const key = trimmed.toLowerCase();2568 if (seen.has(key)) continue;2569 seen.add(key);2570 cleaned.push(trimmed);2571 }2572 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2573 }25742575 if ("username" in patch) {2576 nextPatch.username = patch.username?.trim() ?? "";2577 }25782579 if ("email" in patch) {2580 nextPatch.email = patch.email?.trim() || undefined;2581 }25822583 if ("password" in patch) {2584 const password = patch.password ?? "";2585 2586 nextPatch.password = password || undefined;2587 }25882589 if ("totp" in patch) {2590 nextPatch.totp = patch.totp?.trim() || undefined;2591 }25922593 if ("notes" in patch) {2594 nextPatch.notes = patch.notes?.trim() || undefined;2595 }25962597 if ("breach" in patch) {2598 nextPatch.breach = patch.breach;2599 }26002601 if ("canary" in patch) {2602 2603 2604 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2605 }26062607 if ("passkey" in patch) {2608 2609 2610 2611 if (patch.passkey === undefined || patch.passkey === null) {2612 nextPatch.passkey = undefined;2613 } else {2614 const sanitized = sanitizePasskey(patch.passkey);2615 if (!sanitized) throw new Error("invalid passkey credential");2616 nextPatch.passkey = sanitized;2617 }2618 }26192620 if ("tags" in patch) {2621 2622 nextPatch.tags = sanitizeTags(patch.tags);2623 }26242625 if ("cardholderName" in patch) {2626 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2627 }2628 if ("cardNumber" in patch) {2629 const n = patch.cardNumber ?? "";2630 nextPatch.cardNumber = n || undefined;2631 }2632 if ("expMonth" in patch) {2633 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2634 }2635 if ("expYear" in patch) {2636 nextPatch.expYear = patch.expYear?.trim() || undefined;2637 }2638 if ("cvc" in patch) {2639 nextPatch.cvc = patch.cvc?.trim() || undefined;2640 }2641 if ("cardNickname" in patch) {2642 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2643 }2644 if ("network" in patch) {2645 nextPatch.network = patch.network;2646 }2647 if ("last4" in patch) {2648 nextPatch.last4 = patch.last4?.trim() || undefined;2649 }26502651 return nextPatch;2652}26532654function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2655 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2656 const modifiedAt =2657 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;26582659 const base: VaultEntry = {2660 id: entry.id,2661 type: "type" in entry ? entry.type : "login",2662 title: entry.title,2663 username: entry.username,2664 email: "email" in entry ? entry.email : undefined,2665 password: entry.password,2666 url: entry.url,2667 additionalUrls:2668 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2669 ? entry.additionalUrls2670 : undefined,2671 totp: entry.totp,2672 2673 totpAlgorithm: entry.totpAlgorithm,2674 totpDigits: entry.totpDigits,2675 totpPeriod: entry.totpPeriod,2676 notes: entry.notes,2677 breach: entry.breach,2678 2679 2680 2681 passwordChangedAt:2682 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2683 2684 2685 2686 passwordHistory:2687 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2688 ? entry.passwordHistory2689 : undefined,2690 canary: "canary" in entry ? entry.canary : undefined,2691 passkey: "passkey" in entry ? entry.passkey : undefined,2692 createdAt,2693 modifiedAt,2694 deletedAt: entry.deletedAt,2695 tags: "tags" in entry ? entry.tags : undefined,2696 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2697 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2698 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2699 expYear: "expYear" in entry ? entry.expYear : undefined,2700 cvc: "cvc" in entry ? entry.cvc : undefined,2701 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2702 network: "network" in entry ? entry.network : undefined,2703 last4: "last4" in entry ? entry.last4 : undefined,2704 };27052706 return migrateLegacyPasskey(base, entry);2707}270827092710271127122713271427152716function migrateLegacyPasskey(2717 base: VaultEntry,2718 raw: VaultEntry | LegacyVaultEntry2719): VaultEntry {2720 if (base.passkey) return base;2721 const flat = raw as Partial<VaultEntry>;2722 const isPasskeyType = base.type === "passkey";2723 const hasFlatPasskeyFields =2724 typeof flat.rpId === "string" &&2725 typeof flat.credentialId === "string" &&2726 typeof flat.publicKeyCose === "string" &&2727 typeof flat.userHandle === "string" &&2728 typeof flat.userName === "string";2729 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2730 if (!hasFlatPasskeyFields) return base;27312732 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2733 base.passkey = {2734 rpId: flat.rpId!,2735 rpName: flat.rpName ?? flat.rpId!,2736 credentialId: flat.credentialId!,2737 publicKeyCose: flat.publicKeyCose!,2738 privateKeyJwk: flat.privateKeyJwk,2739 userHandle: flat.userHandle!,2740 userName: flat.userName!,2741 userDisplayName: flat.userDisplayName ?? flat.userName!,2742 algorithm,2743 signCount: flat.signCount ?? 0,2744 aaguid: flat.aaguid ?? "",2745 backupEligible: flat.backupEligible === true,2746 backupState: flat.backupState === true,2747 lastUsedAt: flat.lastUsedAt,2748 browser: flat.browser,2749 transports: flat.transports,2750 createdAt: base.createdAt,2751 };2752 2753 2754 2755 if (isPasskeyType) base.type = "login";2756 if (!base.username) base.username = flat.userName;2757 if (!base.url) base.url = `https://${flat.rpId}`;2758 return base;2759}2760276127622763276427652766function entryDupeKey(e: VaultEntry): string {2767 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2768 2769 2770 2771 2772 if (e.passkey) {2773 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2774 }2775 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2776}277727782779export function countDuplicates(): number {2780 const best = new Map<string, VaultEntry>();2781 let dupeCount = 0;2782 for (const e of entries.filter((e) => !e.deletedAt)) {2783 const key = entryDupeKey(e);2784 const existing = best.get(key);2785 if (!existing) {2786 best.set(key, e);2787 } else {2788 dupeCount++;2789 2790 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2791 best.set(key, e);2792 }2793 }2794 }2795 return dupeCount;2796}279727982799280028012802export async function deduplicateEntries(): Promise<number> {2803 if (!derivedKey) throw new Error("vault_locked");2804 2805 2806 if (vaultMode === "decoy") {2807 throw new Error("deduplicate disabled in decoy mode");2808 }28092810 const best = new Map<string, VaultEntry>();2811 for (const e of entries.filter((en) => !en.deletedAt)) {2812 const key = entryDupeKey(e);2813 const existing = best.get(key);2814 if (!existing) {2815 best.set(key, e);2816 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2817 best.set(key, e);2818 }2819 }28202821 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2822 let removed = 0;2823 entries = entries.map((e) => {2824 if (!e.deletedAt && !keepIds.has(e.id)) {2825 removed++;2826 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2827 }2828 return e;2829 });28302831 if (removed > 0) await persistEntries();2832 return removed;2833}283428352836283728382839284028412842const HISTORY_KEY = "peach_vault_history";2843const HISTORY_MAX = 5;284428452846284728482849function bucketEntryCount(n: number): number {2850 if (n <= 0) return 0;2851 if (n <= 10) return 10;2852 if (n <= 25) return 25;2853 if (n <= 50) return 50;2854 if (n <= 100) return 100;2855 if (n <= 250) return 250;2856 if (n <= 500) return 500;2857 return Math.ceil(n / 500) * 500;2858}28592860286128622863286428652866export async function takeVaultSnapshot(2867 reason: VaultSnapshot["reason"],2868): Promise<void> {2869 try {2870 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2871 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2872 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];28732874 2875 2876 2877 if (!slotA) return;28782879 const vault_a = JSON.stringify(slotA);2880 const vault_b = JSON.stringify(slotB ?? slotA);28812882 2883 2884 2885 2886 2887 2888 let entryCount: number | null = null;2889 if (derivedKey && vaultMode === "real") {2890 try {2891 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);2892 } catch {2893 entryCount = null;2894 }2895 }28962897 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2898 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2899 ? existingRaw2900 : { snapshots: [] };29012902 2903 2904 2905 const latest = history.snapshots[0];2906 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2907 return;2908 }29092910 const snapshot: VaultSnapshot = {2911 id: crypto.randomUUID(),2912 savedAt: Date.now(),2913 reason,2914 vault_a,2915 vault_b,2916 entryCount,2917 };29182919 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2920 await chrome.storage.local.set({ [HISTORY_KEY]: history });2921 } catch (err) {2922 2923 2924 2925 2926 console.warn('[Peach] takeVaultSnapshot failed:', err);2927 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2928 try {2929 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2930 } catch {2931 2932 }2933 }2934 }2935}293629372938export async function clearVaultHistory(): Promise<void> {2939 try {2940 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2941 } catch {2942 2943 }2944}294529462947export async function getVaultHistory(): Promise<VaultSnapshot[]> {2948 const raw = await chrome.storage.local.get(HISTORY_KEY);2949 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2950 if (!history || !Array.isArray(history.snapshots)) return [];2951 return history.snapshots;2952}29532954295529562957295829592960export async function restoreVaultSnapshot(2961 snapshotId: string,2962): Promise<{ ok: true } | { ok: false; error: string }> {2963 const history = await getVaultHistory();2964 const snapshot = history.find((s) => s.id === snapshotId);2965 if (!snapshot) return { ok: false, error: "snapshot_not_found" };29662967 2968 2969 2970 await takeVaultSnapshot("restore");29712972 let restoredA: StoredVault;2973 let restoredB: StoredVault;2974 try {2975 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2976 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2977 } catch {2978 return { ok: false, error: "snapshot_corrupt" };2979 }29802981 await writeSlot(SLOT_A, restoredA);2982 await writeSlot(SLOT_B, restoredB);2983 await stampLocalVaultMutation();29842985 2986 2987 2988 lock();29892990 return { ok: true };2991}299229932994299529962997299829993000300130023003300430053006export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3007 if (!derivedKey || vaultMode !== "real") {3008 return { ok: false, error: "vault_locked" };3009 }3010 const slotA = await loadSlot(SLOT_A);3011 if (!slotA) return { ok: false, error: "missing_slot_a" };30123013 3014 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3015 await generateAndWrapRootKey();30163017 3018 const newRootKey = unhex(rawRootKey);3019 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);30203021 3022 3023 3024 3025 await takeVaultSnapshot("local_save");3026 const nextSlotA: StoredVault = {3027 ...slotA,3028 recovery_salt: recoverySalt,3029 recovery_wrapped_root_key: wrappedRootKey,3030 recovery_nonce: wrappedNonce,3031 recovery_tag: wrappedTag,3032 ...pwRoot,3033 };3034 await writeSlot(SLOT_A, nextSlotA);30353036 3037 if (rootKey) zeroKey(rootKey);3038 rootKey = newRootKey;30393040 3041 await clearPairedDevices();30423043 3044 await setRecoveryConfirmPending(); 30453046 await stampLocalVaultMutation();3047 return { ok: true };3048}30493050305130523053305430553056export async function changePassword(3057 currentPassword: string,3058 newPassword: string3059): Promise<boolean> {3060 if (!derivedKey || vaultMode !== "real") return false;30613062 3063 const valid = await verifyMainPassword(currentPassword);3064 if (!valid) return false;30653066 const slotA = await loadSlot(SLOT_A);3067 if (!slotA) return false;3068 if (!rootKey) return false;30693070 3071 const newSalt = generateSalt();3072 const newKey = await deriveKey(newPassword, newSalt);30733074 3075 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3076 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");30773078 3079 3080 3081 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);30823083 const finalSlotA: StoredVault = {3084 ...slotA,3085 salt: hex(newSalt),3086 nonce,3087 ciphertext,3088 tag,3089 ...pwRoot,3090 };30913092 3093 3094 3095 await takeVaultSnapshot("local_save");30963097 3098 3099 3100 3101 await writeSlot(SLOT_A, finalSlotA);31023103 3104 3105 3106 zeroKey(derivedKey);3107 derivedKey = newKey;31083109 3110 3111 3112 3113 void saveSessionKey(newKey, "real");31143115 3116 3117 3118 if (!duressConfigured) {3119 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3120 }3121 await stampLocalVaultMutation();31223123 return true;3124}