123456789101112import { deriveKey, deriveKeyRaw, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import { clampSyncTimestamp } from "../shared/sync-limits";15import { computeVaultId } from "../shared/sync-identity";16import { recordSyncAlerts, type SyncAlert } from "./sync-alerts";17import {18 rootKeyToWords,19 wordsToRootKeyMaterial,20 deriveRecoveryKey,21} from "../shared/recovery-words";22import type {23 AccentColorName,24 Canary,25 LegacyVaultEntry,26 ManualSummonRecord,27 PasskeyData,28 SavedIdentity,29 StoredVault,30 VaultEntry,31 VaultHistory,32 VaultSnapshot,33} from "../shared/types";34import { decrypt, encrypt, zeroKey } from "./aes";35import { clearAutoLock, touchAutoLock } from "./alarms";36import { setRecoveryConfirmPending } from "./recovery-pending";3738type EntryDraft = Omit<39 VaultEntry,40 "id" | "createdAt" | "modifiedAt" | "deletedAt"41>;424344454647const SLOT_A = "peach_vault_a";48const SLOT_B = "peach_vault_b";495051const LEGACY_MAIN = "peach_vault";52const LEGACY_DECOY = "peach_decoy_vault";53const LEGACY_DURESS_FLAG = "peach_duress";54555657const PAD_BLOCK = 1024;5859606162const PASSWORD_HISTORY_MAX = 50;6364656667686970717273const DURESS_SLOT_SIZE = 262_144;7475let derivedKey: Uint8Array | null = null;7677787980let rootKey: Uint8Array | null = null;81let entries: VaultEntry[] = [];82let domainIndex: Map<string, VaultEntry[]> = new Map();83let vaultMode: "real" | "decoy" = "real";84858687let duressConfigured = false;8889let migrationRan = false;90919293949596979899100let lastUnlockFailureReason: string | null = null;101102export function consumeLastUnlockFailureReason(): string | null {103 const r = lastUnlockFailureReason;104 lastUnlockFailureReason = null;105 return r;106}107108109110111112export type TextSize = "small" | "medium" | "large";113114export interface VaultSettings {115 accentColor?: AccentColorName;116 theme?: "dark" | "light";117 autoLockMinutes?: number;118 clipboardClearSeconds?: number;119 lockOnClose?: boolean;120 breachMonitoring?: boolean;121 passkeyProviderEnabled?: boolean;122 trashRetentionDays?: number;123 manualSummonDomains?: Record<string, ManualSummonRecord>;124 declinedCardFingerprints?: string[];125 126127128129130 textSize?: TextSize;131 132133134135136 reduceMotion?: boolean;137 138139140141142 biometricsEnabled?: boolean;143 144145146147148149150 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;151}152153let settings: VaultSettings = {};154155export function getSettings(): VaultSettings {156 return settings;157}158159export async function ensureSettingsLoaded(): Promise<VaultSettings> {160 await loadSettings();161 return settings;162}163164export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {165 await loadSettings();166 167 168 169 170 const safe = Object.fromEntries(171 Object.entries(patch).filter(172 ([k]) => !["__proto__", "constructor", "prototype"].includes(k),173 ),174 ) as Partial<VaultSettings>;175 settings = { ...settings, ...safe };176 await persistSettings();177}178179async function persistSettings(): Promise<void> {180 try {181 await chrome.storage.local.set({ peach_settings: settings });182 } catch (err) {183 throw mapStorageError(err);184 }185}186187function mapStorageError(err: unknown): Error {188 const message = err instanceof Error ? err.message : String(err);189 if (/QUOTA_BYTES|quota/i.test(message)) {190 return new Error("STORAGE_QUOTA_EXCEEDED");191 }192 return err instanceof Error ? err : new Error(message);193}194195async function loadSettings(): Promise<void> {196 const result = await chrome.storage.local.get("peach_settings");197 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};198}199200201202203204205function fireSyncTrigger(reason: "vault_change"): void {206 void import("./sync-trigger")207 .then((mod) => mod.triggerSyncIfReachable({ reason }))208 .catch(() => {209 210 });211}212213214215216217export async function checkBreach(password: string): Promise<number> {218 219 220 const encoder = new TextEncoder();221 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));222 const hashHex = Array.from(new Uint8Array(hashBuffer))223 .map((b) => b.toString(16).padStart(2, "0"))224 .join("")225 .toUpperCase();226227 const prefix = hashHex.slice(0, 5);228 const suffix = hashHex.slice(5);229230 try {231 232 233 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {234 headers: { "Add-Padding": "true" },235 });236 if (!res.ok) return 0;237 const text = await res.text();238 const match = text.split("\r\n").find((l) => l.startsWith(suffix));239 return match ? parseInt(match.split(":")[1], 10) : 0;240 } catch {241 return 0; 242 }243}244245export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {246 const fingerprint = await cardFingerprint(last4, expMonth, expYear);247 await loadSettings();248 settings = {249 ...settings,250 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],251 };252 await persistSettings();253}254255export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {256 await loadSettings();257 const fingerprint = await cardFingerprint(last4, expMonth, expYear);258 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);259}260261async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {262 const encoder = new TextEncoder();263 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));264 return Array.from(new Uint8Array(buf).slice(0, 8))265 .map((b) => b.toString(16).padStart(2, "0"))266 .join("");267}268269function hex(buf: Uint8Array): string {270 return Array.from(buf)271 .map((b) => b.toString(16).padStart(2, "0"))272 .join("");273}274275function unhex(s: string): Uint8Array {276 const out = new Uint8Array(s.length / 2);277 for (let i = 0; i < s.length; i += 2) {278 out[i / 2] = parseInt(s.slice(i, i + 2), 16);279 }280 return out;281}282283284285286287288289290291292293294295296297298299300301302async function generateAndWrapRootKey(): Promise<{303 rawRootKey: string; 304 recoverySalt: string;305 wrappedRootKey: string;306 wrappedNonce: string;307 wrappedTag: string;308}> {309 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));310311 312 const words = await rootKeyToWords(rootKeyBytes);313 const wordMaterial = await wordsToRootKeyMaterial(words);314 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));315 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);316317 318 319 const nonce = crypto.getRandomValues(new Uint8Array(12));320 const encoded = new TextEncoder().encode(hex(rootKeyBytes));321 const raw = await crypto.subtle.encrypt(322 {323 name: "AES-GCM",324 iv: nonce,325 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,326 tagLength: 128,327 },328 recoveryKey,329 encoded330 );331 332 const rawU8 = new Uint8Array(raw);333 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));334 const tag = hex(rawU8.slice(rawU8.length - 16));335336 return {337 rawRootKey: hex(rootKeyBytes),338 recoverySalt: hex(recoverySalt),339 wrappedRootKey: ciphertext,340 wrappedNonce: hex(nonce),341 wrappedTag: tag,342 };343}344345async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{346 recoverySalt: string;347 wrappedRootKey: string;348 wrappedNonce: string;349 wrappedTag: string;350}> {351 if (rootKeyBytes.length !== 32) {352 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");353 }354355 const words = await rootKeyToWords(rootKeyBytes);356 const wordMaterial = await wordsToRootKeyMaterial(words);357 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));358 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);359 const nonce = crypto.getRandomValues(new Uint8Array(12));360 const encoded = new TextEncoder().encode(hex(rootKeyBytes));361 const raw = await crypto.subtle.encrypt(362 {363 name: "AES-GCM",364 iv: nonce,365 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,366 tagLength: 128,367 },368 recoveryKey,369 encoded,370 );371 const rawU8 = new Uint8Array(raw);372 return {373 recoverySalt: hex(recoverySalt),374 wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),375 wrappedNonce: hex(nonce),376 wrappedTag: hex(rawU8.slice(rawU8.length - 16)),377 };378}379380381382383384385export async function unwrapRootKeyForRecovery(386 wrappedRootKey: string,387 wrappedNonce: string,388 wrappedTag: string,389 recoverySaltHex: string,390 words: string[]391): Promise<void> {392 const wordMaterial = await wordsToRootKeyMaterial(words);393 const recoverySalt = unhex(recoverySaltHex);394 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);395396 397 398 399 400 401 const nonceRaw = unhex(wrappedNonce);402 const ciphertextRaw = unhex(wrappedRootKey);403 const tagRaw = unhex(wrappedTag);404 405 406 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;407 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);408 combined.set(ciphertextRaw, 0);409 combined.set(tagRaw, ciphertextRaw.length);410 const raw = await crypto.subtle.decrypt(411 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },412 recoveryKey,413 combined414 );415 const decryptedHex = new TextDecoder().decode(raw);416 rootKey = unhex(decryptedHex);417}418419420421422423424425426427428export async function rewrapRootKeyForPassword(429 newDerivedKey: Uint8Array,430 _newSalt: Uint8Array431): Promise<void> {432 if (!rootKey) throw new Error("root key not set");433 const slotA = await loadSlot(SLOT_A);434 if (!slotA) throw new Error("missing slot A");435 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);436 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });437}438439440441442443444async function reencryptVaultForNewKey(445 newKey: Uint8Array,446 slotA: StoredVault447): Promise<void> {448 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);449 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");450 await writeSlot(SLOT_A, {451 ...slotA,452 nonce,453 ciphertext,454 tag,455 });456}457458459460461462463export async function changePasswordWithNewKey(464 newSalt: Uint8Array,465 newKey: Uint8Array466): Promise<void> {467 const slotA = await loadSlot(SLOT_A);468 if (!slotA) throw new Error("missing slot A");469 470 471 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });472 if (derivedKey) zeroKey(derivedKey);473 derivedKey = newKey;474 475 476 477 void saveSessionKey(newKey, "real");478 479 480 481 482 if (rootKey) {483 const slotAfter = await loadSlot(SLOT_A);484 if (slotAfter) {485 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);486 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });487 }488 }489 if (!duressConfigured) {490 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));491 }492 await stampLocalVaultMutation();493}494495496497498499500export { deriveKey, generateSalt };501502export async function loadSlotA(): Promise<StoredVault | null> {503 return loadSlot(SLOT_A);504}505506507508509510511512513514515516517518async function deriveRootKeyFromDerivedKey(519 derivedKeyBytes: Uint8Array,520 slotA: StoredVault,521): Promise<Uint8Array | null> {522 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {523 return null;524 }525 try {526 const rootHex = await decrypt(527 derivedKeyBytes,528 slotA.pw_root_nonce,529 slotA.pw_root_ciphertext,530 slotA.pw_root_tag,531 "pw_root_v1",532 );533 return unhex(rootHex);534 } catch {535 return null;536 }537}538539540541async function wrapRootKeyForDerivedKey(542 derivedKeyBytes: Uint8Array,543 rootKeyBytes: Uint8Array,544): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {545 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");546 return {547 pw_root_ciphertext: ciphertext,548 pw_root_nonce: nonce,549 pw_root_tag: tag,550 };551}552553554555556557558559560export async function migrateToRecovery(): Promise<void> {561 if (!derivedKey || vaultMode !== "real") {562 throw new Error("vault must be unlocked in real mode");563 }564 const slotA = await loadSlot(SLOT_A);565 if (!slotA) throw new Error("missing slot A");566 const hasRecoveryEscrow =567 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;568 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;569 if (hasRecoveryEscrow && hasPwRoot) return; 570571 572 573 574 575 576 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =577 await generateAndWrapRootKey();578 rootKey = unhex(rawRootKey);579580 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);581 const nextSlotA: StoredVault = {582 ...slotA,583 recovery_salt: recoverySalt,584 recovery_wrapped_root_key: wrappedRootKey,585 recovery_nonce: wrappedNonce,586 recovery_tag: wrappedTag,587 ...pwRoot,588 };589 await writeSlot(SLOT_A, nextSlotA);590 await stampLocalVaultMutation();591}592593594595596597598export async function getRecoveryWords(): Promise<string[]> {599 if (!rootKey) {600 throw new Error("recovery not set up — vault may need migration");601 }602 return rootKeyToWords(rootKey);603}604605async function loadSlot(key: string): Promise<StoredVault | null> {606 const result = await chrome.storage.local.get(key);607 return (result as Record<string, StoredVault | undefined>)[key] ?? null;608}609610async function writeSlot(key: string, vault: StoredVault): Promise<void> {611 try {612 await chrome.storage.local.set({ [key]: vault });613 } catch (err) {614 throw mapStorageError(err);615 }616}617618async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {619 const raw = await chrome.storage.local.get("peach_last_modified_at");620 const previous = Number(621 (raw as Record<string, unknown>).peach_last_modified_at,622 );623 const next = Math.max(624 Date.now(),625 Number.isFinite(previous) ? previous + 1 : 0,626 );627 try {628 await chrome.storage.local.set({629 [SLOT_A]: slotA,630 [SLOT_B]: slotB,631 peach_last_modified_at: String(next),632 });633 } catch (err) {634 throw mapStorageError(err);635 }636}637638639640641642643644645let lastEntryTimestamp = 0;646function monotonicEntryTimestamp(currentModifiedAt?: number): number {647 const base = Math.max(648 Date.now(),649 (currentModifiedAt ?? 0) + 1,650 lastEntryTimestamp + 1,651 );652 lastEntryTimestamp = base;653 return base;654}655656async function stampLocalVaultMutation(): Promise<void> {657 const raw = await chrome.storage.local.get("peach_last_modified_at");658 const previous = Number(659 (raw as Record<string, unknown>).peach_last_modified_at,660 );661 const next = Math.max(662 Date.now(),663 Number.isFinite(previous) ? previous + 1 : 0,664 );665 await chrome.storage.local.set({666 peach_last_modified_at: String(next),667 });668}669670671672673674675async function ensureMigrated(): Promise<void> {676 if (migrationRan) return;677 migrationRan = true;678679 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];680 const raw = await chrome.storage.local.get(keys);681 const slotAExists = raw[SLOT_A] !== undefined;682 const slotBExists = raw[SLOT_B] !== undefined;683 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;684 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;685686 687 if (!slotAExists && !legacyMain) return;688689 690 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;691692 let didMigrate = false;693 if (legacyMain && !slotAExists) {694 await writeSlot(SLOT_A, legacyMain);695 didMigrate = true;696 }697698 699 if (!slotBExists) {700 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;701 if (legacyDecoy) {702 await writeSlot(SLOT_B, legacyDecoy);703 didMigrate = true;704 } else if (target) {705 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));706 didMigrate = true;707 }708 }709710 const toRemove: string[] = [];711 if (legacyMain) toRemove.push(LEGACY_MAIN);712 if (legacyDecoy) toRemove.push(LEGACY_DECOY);713 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);714 if (toRemove.length > 0) {715 await chrome.storage.local.remove(toRemove);716 didMigrate = true;717 }718 if (didMigrate) {719 await stampLocalVaultMutation();720 }721}722723724725726727728729730731732733function padPlaintext(json: string, minBytes = 0): string {734 const encoded = new TextEncoder().encode(json);735 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);736 737 const target = Math.max(bucket, minBytes);738 const padLen = target - encoded.length;739 return json + " ".repeat(padLen);740}741742743744745746747748749750751752const CSPRNG_CHUNK = 65_536;753754function fillRandom(buf: Uint8Array): Uint8Array {755 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {756 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);757 const chunk = new Uint8Array(end - offset);758 crypto.getRandomValues(chunk);759 buf.set(chunk, offset);760 }761 return buf;762}763764function generateNoiseBlob(ciphertextBytes: number): StoredVault {765 const salt = crypto.getRandomValues(new Uint8Array(16));766 const nonce = crypto.getRandomValues(new Uint8Array(12));767 const cipher = fillRandom(new Uint8Array(ciphertextBytes));768 const tag = crypto.getRandomValues(new Uint8Array(16));769 return {770 version: 1,771 salt: hex(salt),772 nonce: hex(nonce),773 ciphertext: hex(cipher),774 tag: hex(tag),775 };776}777778779780781782type VaultPayload = {783 entries: VaultEntry[];784 duressConfigured?: boolean;785};786787type DecryptedPayload = {788 entries: VaultEntry[];789 duressConfigured: boolean;790 791792793794795796797 migratedFromLegacy: boolean;798};799800function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {801 const envelope: VaultPayload = { entries, duressConfigured: duress };802 return padPlaintext(JSON.stringify(envelope), minBytes);803}804805function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {806 if ("created" in raw || "updated" in raw) return true;807 const flat = raw as Partial<VaultEntry>;808 if (flat.type === "passkey") return true;809 810 if (flat.rpId !== undefined && flat.passkey === undefined) return true;811 return false;812}813814function decodePayload(plain: string): DecryptedPayload {815 const parsed = JSON.parse(plain) as816 | Array<VaultEntry | LegacyVaultEntry>817 | VaultPayload;818819 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];820 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);821822 const normalized = rawEntries.map(normalizeEntry);823 const { merged } = mergePasskeyOnlyIntoLogins(normalized);824825 if (Array.isArray(parsed)) {826 return {827 entries: normalized,828 duressConfigured: false,829 migratedFromLegacy: true, 830 };831 }832833 return {834 entries: normalized,835 duressConfigured: parsed.duressConfigured === true,836 migratedFromLegacy: flatMigrationNeeded || merged,837 };838}839840841842843844845846847848849850851852853854855856857858859860861862863function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {864 let merged = false;865 const now = Date.now();866867 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);868 const byRpId = new Map<string, VaultEntry[]>();869 for (const e of candidates) {870 const rpId = e.passkey!.rpId;871 const list = byRpId.get(rpId);872 if (list) list.push(e);873 else byRpId.set(rpId, [e]);874 }875876 for (const [rpId, group] of byRpId) {877 if (group.length === 0) continue;878 group.sort((a, b) => {879 const aT =880 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;881 const bT =882 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;883 return bT - aT;884 });885 const winner = group[0]!;886887 888 889 890 const loginTarget = entries.find((e) => {891 if (e.deletedAt || e.passkey || e.type !== "login") return false;892 if (e.url && extractDomain(e.url) === rpId) return true;893 for (const extra of e.additionalUrls ?? []) {894 if (extractDomain(extra) === rpId) return true;895 }896 return false;897 });898899 if (loginTarget) {900 loginTarget.passkey = winner.passkey;901 loginTarget.modifiedAt = now;902 for (const dup of group) {903 if (!dup.deletedAt) {904 dup.deletedAt = now;905 dup.modifiedAt = now;906 }907 }908 merged = true;909 } else if (group.length > 1) {910 for (let i = 1; i < group.length; i += 1) {911 const dup = group[i]!;912 if (!dup.deletedAt) {913 dup.deletedAt = now;914 dup.modifiedAt = now;915 }916 }917 merged = true;918 }919 }920921 return { merged };922}923924925926927928929930function hasValidVaultSchema(v: unknown): v is StoredVault {931 if (typeof v !== "object" || v === null) return false;932 const r = v as Record<string, unknown>;933 return (934 r.version === 1 &&935 typeof r.salt === "string" &&936 typeof r.nonce === "string" &&937 typeof r.ciphertext === "string" &&938 typeof r.tag === "string"939 );940}941942export type TryDecryptResult =943 | { status: "ok"; payload: DecryptedPayload }944 | { status: "wrong_key" }945 | { status: "corrupted"; reason: string };946947948949950951952export async function tryDecryptSlot(953 vault: StoredVault,954 key: Uint8Array,955 aadTag?: string,956): Promise<DecryptedPayload | null> {957 const res = await tryDecryptSlotResult(vault, key, aadTag);958 return res.status === "ok" ? res.payload : null;959}960961export async function tryDecryptSlotResult(962 vault: StoredVault,963 key: Uint8Array,964 aadTag?: string,965): Promise<TryDecryptResult> {966 if (!hasValidVaultSchema(vault)) {967 return { status: "corrupted", reason: "invalid_slot_schema" };968 }969 let plain: string;970 try {971 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);972 } catch {973 return { status: "wrong_key" };974 }975 try {976 const parsed = JSON.parse(plain) as unknown;977 if (typeof parsed !== "object" || parsed === null) {978 return { status: "corrupted", reason: "invalid_payload_shape" };979 }980 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {981 return { status: "corrupted", reason: "invalid_payload_shape" };982 }983 return { status: "ok", payload: decodePayload(plain) };984 } catch {985 return { status: "corrupted", reason: "invalid_payload_json" };986 }987}988989990991992993function buildDomainIndexSync(all: VaultEntry[]): void {994 domainIndex = new Map();995996 for (const entry of all) {997 if (entry.deletedAt) continue;998 if (entry.type === "note" || entry.type === "card") continue;9991000 1001 1002 1003 1004 const domains = new Set<string>();1005 const primary = extractDomain(entry.url ?? "");1006 if (primary) domains.add(primary);1007 for (const extra of entry.additionalUrls ?? []) {1008 const d = extractDomain(extra);1009 if (d) domains.add(d);1010 }1011 if (domains.size === 0) continue;10121013 for (const domain of domains) {1014 const bucket = domainIndex.get(domain) ?? [];1015 bucket.push(entry);1016 domainIndex.set(domain, bucket);1017 }1018 }1019}10201021export async function createVault(password: string): Promise<void> {1022 await ensureMigrated();10231024 const salt = generateSalt();1025 derivedKey = await deriveKey(password, salt);1026 entries = [];1027 vaultMode = "real";1028 duressConfigured = false;1029 buildDomainIndexSync(entries);10301031 1032 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1033 await generateAndWrapRootKey();1034 rootKey = unhex(rawRootKey);10351036 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1037 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");10381039 1040 1041 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);10421043 const slotA: StoredVault = {1044 version: 1,1045 salt: hex(salt),1046 nonce,1047 ciphertext,1048 tag,1049 recovery_salt: recoverySalt,1050 recovery_wrapped_root_key: wrappedRootKey,1051 recovery_nonce: wrappedNonce,1052 recovery_tag: wrappedTag,1053 ...pwRoot,1054 };1055 await writeSlot(SLOT_A, slotA);10561057 1058 1059 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1060 await stampLocalVaultMutation();10611062 touchAutoLock();1063}1064106510661067106810691070export async function createVaultFromRootKey(1071 password: string,1072 rootKeyHex: string,1073): Promise<{ vaultId: string }> {1074 if (typeof password !== "string" || password.length === 0) {1075 throw new Error("ADOPT_INVALID_PASSWORD");1076 }1077 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1078 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1079 }10801081 const rootKeyBytes = unhex(rootKeyHex);1082 if (rootKeyBytes.length !== 32) {1083 rootKeyBytes.fill(0);1084 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1085 }10861087 let nextDerivedKey: Uint8Array | null = null;1088 let sessionRootKey: Uint8Array | null = null;1089 try {1090 await ensureMigrated();1091 if ((await loadSlot(SLOT_A)) !== null) {1092 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1093 }10941095 const nextEntries: VaultEntry[] = [];1096 const nextDuressConfigured = false;1097 const vaultId = await computeVaultId(rootKeyBytes);1098 const salt = generateSalt();1099 nextDerivedKey = await deriveKey(password, salt);1100 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);11011102 sessionRootKey = new Uint8Array(rootKeyBytes);1103 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1104 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1105 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1106 const slotA: StoredVault = {1107 version: 1,1108 salt: hex(salt),1109 nonce,1110 ciphertext,1111 tag,1112 recovery_salt: recovery.recoverySalt,1113 recovery_wrapped_root_key: recovery.wrappedRootKey,1114 recovery_nonce: recovery.wrappedNonce,1115 recovery_tag: recovery.wrappedTag,1116 ...pwRoot,1117 };1118 const slotB = generateNoiseBlob(ciphertext.length / 2);1119 await commitCreatedVaultSlots(slotA, slotB);11201121 const previousDerivedKey = derivedKey;1122 const previousRootKey = rootKey;1123 derivedKey = nextDerivedKey;1124 rootKey = sessionRootKey;1125 nextDerivedKey = null;1126 sessionRootKey = null;1127 entries = nextEntries;1128 vaultMode = "real";1129 duressConfigured = nextDuressConfigured;1130 buildDomainIndexSync(entries);1131 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1132 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1133 touchAutoLock();11341135 return { vaultId };1136 } finally {1137 rootKeyBytes.fill(0);1138 if (nextDerivedKey) zeroKey(nextDerivedKey);1139 if (sessionRootKey) sessionRootKey.fill(0);1140 }1141}11421143export async function hasVault(): Promise<boolean> {1144 await ensureMigrated();1145 return (await loadSlot(SLOT_A)) !== null;1146}1147114811491150115111521153export async function isDuressEnabled(): Promise<boolean> {1154 return vaultMode === "real" && duressConfigured;1155}11561157export async function setupDuress(duressPassword: string): Promise<void> {1158 if (!derivedKey || vaultMode !== "real") {1159 throw new Error("vault must be unlocked in real mode");1160 }1161 await ensureMigrated();11621163 1164 1165 1166 const slotA = await loadSlot(SLOT_A);1167 if (!slotA) throw new Error("missing slot A");11681169 const decoySalt = generateSalt();1170 const decoyKey = await deriveKey(duressPassword, decoySalt);1171 try {1172 1173 1174 1175 1176 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1177 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1178 await writeSlot(SLOT_B, {1179 version: 1,1180 salt: hex(decoySalt),1181 nonce,1182 ciphertext,1183 tag,1184 });1185 } finally {1186 zeroKey(decoyKey);1187 }11881189 1190 1191 duressConfigured = true;1192 await persistSlotA();1193}11941195export async function disableDuress(): Promise<void> {1196 if (!derivedKey || vaultMode !== "real") {1197 throw new Error("vault must be unlocked in real mode");1198 }1199 await ensureMigrated();12001201 1202 1203 1204 const slotA = await loadSlot(SLOT_A);1205 if (slotA) {1206 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1207 }12081209 duressConfigured = false;1210 await persistSlotA();1211}12121213121412151216121712181219export async function verifyMainPassword(password: string): Promise<boolean> {1220 await ensureMigrated();1221 const slotA = await loadSlot(SLOT_A);1222 if (!slotA) return false;1223 const key = await deriveKey(password, unhex(slotA.salt));1224 try {1225 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1226 } finally {1227 zeroKey(key);1228 }1229}12301231export async function unlock(password: string): Promise<boolean> {1232 1233 1234 1235 if (await attemptUnlockWithDerive(password, deriveKey)) return true;12361237 const nfc = password.normalize("NFC");1238 if (password === nfc) return false; 12391240 if (await attemptUnlockWithDerive(password, deriveKeyRaw)) {1241 1242 1243 1244 if (vaultMode === "real") {1245 try {1246 const newSalt = generateSalt();1247 const newKey = await deriveKey(password, newSalt);1248 await changePasswordWithNewKey(newSalt, newKey);1249 } catch (healErr) {1250 console.warn("[Peach] LV-1 NFC heal failed:", healErr);1251 }1252 }1253 return true;1254 }1255 return false;1256}12571258async function attemptUnlockWithDerive(1259 password: string,1260 derive: typeof deriveKey,1261): Promise<boolean> {1262 await ensureMigrated();12631264 1265 1266 1267 1268 1269 const [slotA, slotB] = await Promise.all([1270 loadSlot(SLOT_A),1271 loadSlot(SLOT_B),1272 ]);12731274 1275 const [keyA, keyB] = await Promise.all([1276 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1277 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1278 ]);12791280 1281 1282 1283 const [resultA, resultB] = await Promise.all([1284 keyA && slotA1285 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1286 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1287 keyB && slotB1288 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1289 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1290 ]);1291 const payloadA = resultA.status === "ok" ? resultA.payload : null;1292 const payloadB = resultB.status === "ok" ? resultB.payload : null;12931294 if (payloadA && keyA) {1295 derivedKey = keyA;1296 void saveSessionKey(keyA, "real");1297 vaultMode = "real";1298 entries = payloadA.entries;1299 duressConfigured = payloadA.duressConfigured;1300 buildDomainIndexSync(entries);1301 await loadSettings();1302 touchAutoLock();1303 if (keyB) zeroKey(keyB);13041305 1306 if (slotA) {1307 const needsRecoverySetup =1308 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1309 if (!needsRecoverySetup) {1310 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1311 }1312 1313 1314 1315 1316 if (needsRecoverySetup || !rootKey) {1317 try {1318 await migrateToRecovery();1319 if (needsRecoverySetup) {1320 1321 1322 1323 await chrome.storage.local.set({ peach_recovery_pending: '1' });1324 }1325 } catch (migrationErr) {1326 console.warn('[Peach] recovery migration failed:', migrationErr);1327 }1328 }1329 }13301331 1332 1333 1334 if (payloadA.migratedFromLegacy) {1335 await persistSlotA();1336 }1337 return true;1338 }13391340 if (payloadB && keyB) {1341 derivedKey = keyB;1342 void saveSessionKey(keyB, "decoy");1343 vaultMode = "decoy";1344 entries = payloadB.entries;1345 duressConfigured = false;1346 buildDomainIndexSync(entries);1347 await loadSettings();1348 touchAutoLock();1349 if (keyA) zeroKey(keyA);1350 return true;1351 }13521353 if (keyA) zeroKey(keyA);1354 if (keyB) zeroKey(keyB);1355 1356 1357 if (resultA.status === "corrupted") {1358 lastUnlockFailureReason = resultA.reason;1359 } else if (resultB.status === "corrupted") {1360 lastUnlockFailureReason = resultB.reason;1361 }1362 return false;1363}13641365136613671368136913701371const SESSION_KEY_STORAGE = "peach_session_dk";13721373async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1374 if (!chrome.storage.session) return;1375 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1376}13771378async function clearSessionKey(): Promise<void> {1379 if (!chrome.storage.session) return;1380 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1381}13821383138413851386138713881389export async function restoreFromSession(): Promise<boolean> {1390 if (derivedKey !== null) return true;1391 if (!chrome.storage.session) return false;1392 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1393 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1394 if (!entry?.keyHex) return false;13951396 const key = unhex(entry.keyHex);1397 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1398 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1399 const slot = await loadSlot(slotKey);1400 if (!slot) { await clearSessionKey(); return false; }14011402 const result = await tryDecryptSlotResult(slot, key, aadTag);1403 if (result.status !== "ok") { await clearSessionKey(); return false; }14041405 derivedKey = key;1406 vaultMode = entry.mode;1407 entries = result.payload.entries;1408 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1409 buildDomainIndexSync(entries);1410 await loadSettings();1411 touchAutoLock();14121413 if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1414 try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { }1415 }14161417 return true;1418}14191420export function lock(): void {1421 void clearSessionKey();1422 if (derivedKey) {1423 zeroKey(derivedKey);1424 derivedKey = null;1425 }1426 if (rootKey) {1427 rootKey.fill(0);1428 rootKey = null;1429 }14301431 entries = [];1432 domainIndex = new Map();1433 vaultMode = "real";1434 duressConfigured = false;1435 clearAutoLock();1436}14371438export function isUnlocked(): boolean {1439 return derivedKey !== null;1440}1441144214431444export async function unlockWithKey(1445 masterKeyHex: string,1446): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1447 try {1448 const masterKeyBytes = unhex(masterKeyHex);1449 const stored = await loadSlot(SLOT_A);1450 if (!stored) return { ok: false, error: "invalid_key" };1451 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1452 if (!payload) return { ok: false, error: "invalid_key" };14531454 derivedKey = masterKeyBytes;1455 vaultMode = "real";1456 entries = payload.entries;1457 duressConfigured = payload.duressConfigured;1458 buildDomainIndexSync(entries);1459 await loadSettings();1460 touchAutoLock();14611462 1463 1464 1465 1466 1467 if (stored.recovery_salt && stored.recovery_wrapped_root_key) {1468 try {1469 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1470 } catch { }1471 }1472 return { ok: true, entries };1473 } catch {1474 return { ok: false, error: "invalid_key" };1475 }1476}14771478147914801481148214831484148514861487148814891490149114921493149414951496export async function unlockFromCodex(params: {1497 slotA: string; 1498 slotB: string | null; 1499 timestamp: number;1500 masterPassphrase: string;1501}): Promise<1502 | { ok: true; entryCount: number }1503 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1504> {1505 let parsedSlotA: StoredVault;1506 let parsedSlotB: StoredVault | null = null;1507 try {1508 const rawA = JSON.parse(params.slotA) as unknown;1509 if (!hasValidVaultSchema(rawA)) {1510 return { ok: false, reason: "invalid_slot_format" };1511 }1512 parsedSlotA = rawA;1513 if (params.slotB !== null) {1514 const rawB = JSON.parse(params.slotB) as unknown;1515 if (!hasValidVaultSchema(rawB)) {1516 return { ok: false, reason: "invalid_slot_format" };1517 }1518 parsedSlotB = rawB;1519 }1520 } catch {1521 return { ok: false, reason: "invalid_slot_format" };1522 }15231524 1525 1526 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1527 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1528 if (!trialPayload) {1529 zeroKey(trialKey);1530 return { ok: false, reason: "wrong_passphrase" };1531 }1532 1533 1534 zeroKey(trialKey);15351536 1537 1538 await takeVaultSnapshot("restore");15391540 await writeSlot(SLOT_A, parsedSlotA);1541 if (parsedSlotB) {1542 await writeSlot(SLOT_B, parsedSlotB);1543 } else {1544 1545 1546 1547 await writeSlot(1548 SLOT_B,1549 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1550 );1551 }1552 await chrome.storage.local.set({1553 peach_last_modified_at: String(params.timestamp),1554 });15551556 1557 1558 const ok = await unlock(params.masterPassphrase);1559 if (!ok) {1560 1561 1562 1563 return { ok: false, reason: "wrong_passphrase" };1564 }1565 return { ok: true, entryCount: getEntries().length };1566}1567156815691570export function getDerivedKeyHex(): string {1571 if (!derivedKey) throw new Error("vault_not_unlocked");1572 return hex(derivedKey);1573}15741575157615771578157915801581158215831584158515861587158815891590export function getRootKeyHex(): string | null {1591 return rootKey ? hex(rootKey) : null;1592}15931594export function getEntries(): VaultEntry[] {1595 if (!derivedKey) throw new Error("vault_locked");1596 return entries.filter((entry) => !entry.deletedAt);1597}15981599const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;16001601export function getIdentities(): SavedIdentity[] {1602 if (!derivedKey) throw new Error("vault_locked");1603 const active = entries.filter((e) => !e.deletedAt && e.type === "login");16041605 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1606 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();16071608 for (const entry of active) {1609 const domain = extractDomain(entry.url ?? "") ?? "";1610 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;16111612 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1613 const existing = map.get(v);1614 if (!existing || modifiedAt > existing.lastUsed) {1615 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1616 } else {1617 map.set(v, { ...existing, count: existing.count + 1 });1618 }1619 }16201621 const uVal = entry.username?.trim();1622 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);16231624 1625 1626 const eVal = entry.email?.trim();1627 if (eVal) tally(emailMap, eVal);1628 }16291630 const toIdentity = (type: "email" | "username") =>1631 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1632 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1633 });16341635 return [1636 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1637 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1638 ];1639}16401641export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1642 if (!derivedKey) throw new Error("vault_locked");16431644 const now = Date.now();1645 const added: VaultEntry[] = [];1646 for (const draft of drafts) {1647 try {1648 const sanitized = sanitizeEntryDraft(draft);1649 const nextEntry: VaultEntry = {1650 ...sanitized,1651 id: crypto.randomUUID(),1652 createdAt: now,1653 modifiedAt: monotonicEntryTimestamp(),1654 };1655 entries.push(nextEntry);1656 added.push(nextEntry);1657 } catch {1658 1659 }1660 }16611662 if (added.length > 0) {1663 await persistEntries();1664 }16651666 return added;1667}166816691670167116721673167416751676export async function restoreEntries(1677 incoming: VaultEntry[],1678 mode: "replace" | "merge"1679): Promise<{ added: number; updated: number; skipped: number }> {1680 if (!derivedKey) throw new Error("vault_locked");16811682 1683 1684 1685 if (mode === "replace" && incoming.length === 0) {1686 throw new Error("refuse to replace with empty entry set");1687 }16881689 let added = 0;1690 let updated = 0;1691 let skipped = 0;16921693 if (mode === "replace") {1694 entries = [];1695 for (const entry of incoming) {1696 const normalized = sanitizeRestoredEntry(entry);1697 if (!normalized) {1698 skipped += 1;1699 continue;1700 }1701 entries.push(normalized);1702 added += 1;1703 }1704 } else {1705 const byId = new Map<string, VaultEntry>();1706 for (const e of entries) byId.set(e.id, e);17071708 for (const entry of incoming) {1709 const normalized = sanitizeRestoredEntry(entry);1710 if (!normalized) {1711 skipped += 1;1712 continue;1713 }1714 const existing = byId.get(normalized.id);1715 if (!existing) {1716 entries.push(normalized);1717 added += 1;1718 } else if (normalized.modifiedAt > existing.modifiedAt) {1719 const idx = entries.findIndex((e) => e.id === existing.id);1720 if (idx !== -1) {1721 entries[idx] = normalized;1722 updated += 1;1723 }1724 } else {1725 skipped += 1;1726 }1727 }1728 }17291730 await persistEntries("import");1731 return { added, updated, skipped };1732}173317341735173617371738173917401741function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1742 if (!entry || typeof entry !== "object") return null;1743 const title = typeof entry.title === "string" ? entry.title.trim() : "";1744 if (!title) return null;1745 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1746 const now = Date.now();1747 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1748 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;17491750 1751 1752 1753 1754 const normalized = normalizeEntry({1755 ...entry,1756 id,1757 title,1758 type: entry.type ?? "login",1759 createdAt,1760 modifiedAt,1761 });17621763 if (normalized.passkey) {1764 normalized.passkey = sanitizePasskey(normalized.passkey);1765 }1766 if (normalized.canary) {1767 normalized.canary = sanitizeCanary(normalized.canary);1768 }1769 1770 normalized.tags = sanitizeTags(normalized.tags);17711772 1773 1774 if (1775 normalized.type === "login" &&1776 !normalized.passkey &&1777 typeof normalized.password !== "string"1778 ) {1779 return null;1780 }1781 if (entry.passkey && !normalized.passkey) {1782 1783 1784 return null;1785 }17861787 return normalized;1788}1789179017911792179317941795function sanitizePasskey(pk: unknown): PasskeyData | undefined {1796 if (!pk || typeof pk !== "object") return undefined;1797 const r = pk as Record<string, unknown>;1798 if (1799 typeof r.rpId !== "string" || !r.rpId ||1800 typeof r.credentialId !== "string" || !r.credentialId ||1801 typeof r.publicKeyCose !== "string" ||1802 typeof r.userHandle !== "string" ||1803 typeof r.userName !== "string"1804 ) {1805 return undefined;1806 }1807 return {1808 rpId: r.rpId,1809 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1810 credentialId: r.credentialId,1811 publicKeyCose: r.publicKeyCose,1812 privateKeyJwk:1813 r.privateKeyJwk && typeof r.privateKeyJwk === "object"1814 ? (r.privateKeyJwk as JsonWebKey)1815 : undefined,1816 userHandle: r.userHandle,1817 userName: r.userName,1818 userDisplayName:1819 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1820 algorithm: r.algorithm === -8 ? -8 : -7,1821 signCount: typeof r.signCount === "number" ? r.signCount : 0,1822 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1823 backupEligible: r.backupEligible === true,1824 backupState: r.backupState === true,1825 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1826 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1827 transports: Array.isArray(r.transports)1828 ? r.transports.filter((t): t is string => typeof t === "string")1829 : undefined,1830 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1831 };1832}18331834183518361837183818391840184118421843function sanitizeCanary(c: unknown): Canary | undefined {1844 if (!c || typeof c !== "object") return undefined;1845 const r = c as Record<string, unknown>;1846 if (typeof r.webhookUrl !== "string") return undefined;1847 let url: URL;1848 try {1849 url = new URL(r.webhookUrl);1850 } catch {1851 return undefined;1852 }1853 if (url.protocol !== "https:") return undefined;1854 return {1855 webhookUrl: url.toString(),1856 label: typeof r.label === "string" ? r.label : undefined,1857 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,1858 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,1859 };1860}18611862186318641865186618671868function sanitizeTags(tags: unknown): string[] | undefined {1869 if (!Array.isArray(tags)) return undefined;1870 const out: string[] = [];1871 for (const t of tags) {1872 if (typeof t !== "string") continue;1873 const trimmed = t.trim();1874 if (!trimmed) continue;1875 out.push(trimmed.slice(0, 256));1876 if (out.length >= 255) break;1877 }1878 return out.length > 0 ? out : undefined;1879}188018811882188318841885export async function clearVaultEntries(1886 confirmation: "REPLACE_ALL"1887): Promise<void> {1888 if (confirmation !== "REPLACE_ALL") {1889 throw new Error("clearVaultEntries requires explicit confirmation");1890 }1891 if (!derivedKey) throw new Error("vault_locked");1892 for (const entry of entries) {1893 if (!entry.deletedAt) {1894 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1895 entry.deletedAt = stamp;1896 entry.modifiedAt = stamp;1897 }1898 }1899 await persistEntries();1900}19011902export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1903 if (!derivedKey) throw new Error("vault_locked");19041905 const sanitized = sanitizeEntryDraft(entry);1906 const now = Date.now();1907 const stamp = monotonicEntryTimestamp();1908 const nextEntry: VaultEntry = {1909 ...sanitized,1910 id: crypto.randomUUID(),1911 createdAt: now,1912 modifiedAt: stamp,1913 1914 1915 passwordChangedAt: sanitized.password ? stamp : undefined,1916 };19171918 1919 1920 1921 const rollback = entries;1922 entries = [...entries, nextEntry];1923 try {1924 await persistEntries();1925 } catch (err) {1926 entries = rollback;1927 throw err;1928 }19291930 1931 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1932 checkBreach(nextEntry.password).then(async (count) => {1933 if (count > 0) {1934 const idx = entries.findIndex((e) => e.id === nextEntry.id);1935 if (idx !== -1) {1936 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };1937 await persistEntries();1938 }1939 }1940 });1941 }19421943 fireSyncTrigger("vault_change");19441945 return nextEntry;1946}19471948194919501951195219531954export async function consolidatePasskeyEntries(): Promise<void> {1955 if (!derivedKey) return;1956 const { merged } = mergePasskeyOnlyIntoLogins(entries);1957 if (merged) {1958 await persistEntries();1959 }1960}19611962export async function updateEntry(1963 id: string,1964 patch: Partial<VaultEntry>1965): Promise<void> {1966 if (!derivedKey) throw new Error("vault_locked");19671968 const idx = entries.findIndex((entry) => entry.id === id);1969 if (idx === -1 || entries[idx].deletedAt) {1970 throw new Error("entry_not_found");1971 }19721973 const current = entries[idx];1974 const sanitizedPatch = sanitizeEntryPatch(patch);1975 1976 1977 1978 const passwordChanged =1979 "password" in patch &&1980 typeof sanitizedPatch.password === "string" &&1981 sanitizedPatch.password !== current.password;1982 const stamp = monotonicEntryTimestamp(current.modifiedAt);1983 1984 1985 1986 1987 1988 1989 1990 const priorPassword = current.password;1991 const shouldCapture =1992 passwordChanged &&1993 typeof priorPassword === "string" &&1994 priorPassword.length > 0 &&1995 current.passwordHistory?.[0]?.password !== priorPassword;1996 const nextHistory = shouldCapture1997 ? [1998 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },1999 ...(current.passwordHistory ?? []),2000 ].slice(0, PASSWORD_HISTORY_MAX)2001 : current.passwordHistory;2002 entries[idx] = {2003 ...current,2004 ...sanitizedPatch,2005 modifiedAt: stamp,2006 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2007 };2008200920102011 2012 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2013 entries[idx] = current;2014 throw new Error("card number is required for card entries");2015 }20162017 2018 2019 2020 2021 try {2022 await persistEntries();2023 } catch (err) {2024 entries[idx] = current;2025 throw err;2026 }20272028 2029 2030 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2031 entries[idx] = { ...entries[idx], breach: undefined };2032 await persistEntries();2033 const password = entries[idx].password;2034 if (password) {2035 checkBreach(password).then(async (count) => {2036 const i = entries.findIndex((e) => e.id === id);2037 if (i !== -1 && count > 0) {2038 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2039 await persistEntries();2040 }2041 });2042 }2043 }20442045 fireSyncTrigger("vault_change");2046}20472048export async function deleteEntry(id: string): Promise<void> {2049 if (!derivedKey) throw new Error("vault_locked");20502051 const entry = entries.find((item) => item.id === id);2052 if (!entry || entry.deletedAt) {2053 throw new Error("entry_not_found");2054 }20552056 2057 2058 2059 const previousModifiedAt = entry.modifiedAt;2060 const stamp = monotonicEntryTimestamp(entry.modifiedAt);2061 entry.deletedAt = stamp;2062 entry.modifiedAt = stamp;2063 2064 2065 try {2066 await persistEntries();2067 } catch (err) {2068 delete entry.deletedAt;2069 entry.modifiedAt = previousModifiedAt;2070 throw err;2071 }2072 fireSyncTrigger("vault_change");2073}20742075export function getDeletedEntries(): VaultEntry[] {2076 if (!derivedKey) return [];2077 return entries.filter((e) => !!e.deletedAt);2078}2079208020812082208320842085export async function getVaultVersion(): Promise<number> {2086 try {2087 const raw = await chrome.storage.local.get("peach_last_modified_at");2088 const v = (raw as Record<string, unknown>).peach_last_modified_at;2089 if (typeof v === "number") return v;2090 if (typeof v === "string" && v !== "") {2091 const n = Number.parseInt(v, 10);2092 return Number.isFinite(n) ? n : 0;2093 }2094 return 0;2095 } catch {2096 return 0;2097 }2098}209921002101210221032104210521062107210821092110export async function addEntryDirect(entry: VaultEntry): Promise<void> {2111 if (!derivedKey) throw new Error("vault_locked");2112 entries = [...entries, entry];2113 await persistEntries("sync_received");2114}21152116export async function updateEntryDirect(patch: VaultEntry): Promise<void> {2117 if (!derivedKey) throw new Error("vault_locked");2118 const idx = entries.findIndex((e) => e.id === patch.id);2119 if (idx === -1) throw new Error("entry_not_found");2120 entries[idx] = { ...entries[idx], ...patch };2121 await persistEntries("sync_received");2122}21232124export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {2125 if (!derivedKey) throw new Error("vault_locked");2126 const idx = entries.findIndex((e) => e.id === id);2127 if (idx === -1) throw new Error("entry_not_found");2128 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };2129 await persistEntries("sync_received");2130}2131213221332134213521362137213821392140214121422143export async function applyMergedRemoteEntries(2144 remoteEntries: VaultEntry[],2145 remoteTombstones: Record<string, number>,2146 context: { deviceName?: string } = {},2147): Promise<{ received: number; applied: number; conflicts: number }> {2148 2149 2150 2151 2152 2153 2154 if (!derivedKey) throw new Error("vault_locked");21552156 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2157 let applied = 0;2158 let conflicts = 0;21592160 const byId = new Map<string, VaultEntry>();2161 for (const e of entries) byId.set(e.id, e);21622163 const alerts: SyncAlert[] = [];2164 const deviceName = context.deviceName ?? "paired device";2165 const mergeNow = Date.now();21662167 for (const remote of remoteEntries) {2168 const sanitized = sanitizeRestoredEntry(remote);2169 if (!sanitized) continue;2170 2171 2172 2173 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2174 if (typeof sanitized.deletedAt === "number") {2175 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2176 }2177 const tombstoneTs = remoteTombstones[sanitized.id];2178 if (tombstoneTs !== undefined && clampSyncTimestamp(tombstoneTs, mergeNow) >= sanitized.modifiedAt) {2179 continue;2180 }2181 const local = byId.get(sanitized.id);2182 if (!local) {2183 entries.push(sanitized);2184 byId.set(sanitized.id, sanitized);2185 applied += 1;2186 } else if (sanitized.modifiedAt > local.modifiedAt) {2187 const idx = entries.findIndex((e) => e.id === local.id);2188 if (idx !== -1) {2189 2190 2191 if (2192 !local.deletedAt &&2193 typeof sanitized.password === "string" &&2194 typeof local.password === "string" &&2195 sanitized.password !== local.password2196 ) {2197 alerts.push({2198 type: "REMOTE_PASSWORD_CHANGE",2199 entryId: sanitized.id,2200 entryTitle: sanitized.title,2201 deviceName,2202 timestamp: mergeNow,2203 });2204 }2205 entries[idx] = sanitized;2206 byId.set(sanitized.id, sanitized);2207 applied += 1;2208 if (!local.deletedAt) conflicts += 1;2209 }2210 }2211 }22122213 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2214 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt)) continue;2215 2216 const deletedAt = clampSyncTimestamp(rawDeletedAt, mergeNow);2217 const local = byId.get(id);2218 if (!local) continue;2219 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2220 if (deletedAt < local.modifiedAt) continue;2221 const idx = entries.findIndex((e) => e.id === id);2222 if (idx !== -1) {2223 2224 if (!local.deletedAt) {2225 alerts.push({2226 type: "REMOTE_DELETE",2227 entryId: id,2228 entryTitle: local.title,2229 deviceName,2230 timestamp: mergeNow,2231 });2232 }2233 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2234 applied += 1;2235 }2236 }22372238 if (applied > 0) {2239 await persistEntries("sync_received");2240 buildDomainIndexSync(entries);2241 }2242 2243 2244 await recordSyncAlerts(alerts);2245 return { received, applied, conflicts };2246}22472248export async function restoreEntry(id: string): Promise<void> {2249 if (!derivedKey) throw new Error("vault_locked");2250 const entry = entries.find((e) => e.id === id && e.deletedAt);2251 if (!entry) throw new Error("deleted entry not found");2252 delete entry.deletedAt;2253 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);2254 await persistEntries();2255 buildDomainIndexSync(entries);2256}22572258export async function purgeEntry(id: string): Promise<void> {2259 if (!derivedKey) throw new Error("vault_locked");2260 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2261 if (idx === -1) throw new Error("deleted entry not found");2262 entries.splice(idx, 1);2263 await persistEntries();2264}226522662267export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2268 if (!derivedKey || retentionMs === 0) return 0;2269 const cutoff = Date.now() - retentionMs;2270 const before = entries.length;2271 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2272 const purged = before - entries.length;2273 if (purged > 0) await persistEntries();2274 return purged;2275}22762277export function queryDomain(domain: string): VaultEntry[] {2278 return domainIndex.get(domain) ?? [];2279}22802281export function getEntryById(id: string): VaultEntry | undefined {2282 return entries.find((entry) => entry.id === id && !entry.deletedAt);2283}22842285async function persistSlotA(2286 reason: VaultSnapshot["reason"] = "local_save",2287): Promise<StoredVault> {2288 if (!derivedKey) throw new Error("vault_locked");22892290 const slotA = await loadSlot(SLOT_A);2291 if (!slotA) throw new Error("missing slot A");22922293 2294 2295 await takeVaultSnapshot(reason);22962297 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2298 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");22992300 2301 const nextSlotA: StoredVault = {2302 ...slotA,2303 nonce,2304 ciphertext,2305 tag,2306 2307 };2308 await writeSlot(SLOT_A, nextSlotA);23092310 2311 2312 2313 2314 2315 2316 try {2317 2318 2319 2320 if (!duressConfigured) {2321 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2322 }2323 2324 2325 2326 2327 2328 if (reason !== "sync_received") {2329 await stampLocalVaultMutation();2330 }2331 } catch (err) {2332 2333 2334 await writeSlot(SLOT_A, slotA).catch(() => {});2335 throw err;2336 }23372338 return nextSlotA;2339}23402341async function persistSlotB(): Promise<void> {2342 if (!derivedKey) throw new Error("vault_locked");23432344 const slotB = await loadSlot(SLOT_B);2345 if (!slotB) throw new Error("missing slot B");23462347 2348 2349 2350 2351 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2352 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");23532354 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2355 2356 2357 2358 try {2359 await stampLocalVaultMutation();2360 } catch (err) {2361 await writeSlot(SLOT_B, slotB).catch(() => {});2362 throw err;2363 }2364}23652366async function persistEntries(2367 reason: VaultSnapshot["reason"] = "local_save",2368): Promise<void> {2369 if (!derivedKey) throw new Error("vault_locked");23702371 if (vaultMode === "decoy") {2372 await persistSlotB();2373 } else {2374 await persistSlotA(reason);2375 }2376 buildDomainIndexSync(entries);2377}23782379function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2380 if (!Array.isArray(values)) return undefined;2381 const seen = new Set<string>();2382 const cleaned: string[] = [];2383 for (const value of values) {2384 const trimmed = typeof value === "string" ? value.trim() : "";2385 if (!trimmed) continue;2386 const key = trimmed.toLowerCase();2387 if (seen.has(key)) continue;2388 seen.add(key);2389 cleaned.push(trimmed);2390 }2391 return cleaned.length > 0 ? cleaned : undefined;2392}23932394function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2395 const title = entry.title.trim();2396 2397 2398 2399 let type: VaultEntry["type"] = entry.type ?? "login";2400 if (type === "passkey") type = "login";24012402 if (!title) throw new Error("title is required");24032404 if (entry.passkey) {2405 const p = entry.passkey;2406 if (2407 !p.rpId ||2408 !p.credentialId ||2409 !p.publicKeyCose ||2410 !p.userHandle ||2411 !p.userName ||2412 (p.algorithm !== -7 && p.algorithm !== -8)2413 ) {2414 throw new Error("invalid passkey credential");2415 }2416 }2417 if (type === "card") {2418 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2419 throw new Error("cardholder name and card number are required");2420 }2421 }24222423 return {2424 type,2425 title,2426 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2427 email: type === "login" ? entry.email?.trim() || undefined : undefined,2428 password: type === "login" ? entry.password : undefined,2429 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2430 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2431 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2432 2433 2434 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2435 totpDigits: type === "login" ? entry.totpDigits : undefined,2436 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2437 notes: entry.notes?.trim() || undefined,2438 breach: type === "login" ? entry.breach : undefined,2439 2440 2441 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2442 tags: sanitizeTags(entry.tags),2443 passkey: entry.passkey,2444 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2445 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2446 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2447 expYear: type === "card" ? entry.expYear?.trim() : undefined,2448 cvc: type === "card" ? entry.cvc?.trim() : undefined,2449 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2450 network: type === "card" ? entry.network : undefined,2451 last4: type === "card" ? entry.last4?.trim() : undefined,2452 };2453}24542455function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2456 const nextPatch: Partial<VaultEntry> = {};24572458 if ("type" in patch) {2459 nextPatch.type = patch.type;2460 }24612462 if ("title" in patch) {2463 const title = patch.title?.trim() ?? "";2464 if (!title) throw new Error("title is required");2465 nextPatch.title = title;2466 }24672468 if ("url" in patch) {2469 nextPatch.url = patch.url?.trim() ?? "";2470 }24712472 if ("additionalUrls" in patch) {2473 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2474 const seen = new Set<string>();2475 const cleaned: string[] = [];2476 for (const value of incoming) {2477 const trimmed = typeof value === "string" ? value.trim() : "";2478 if (!trimmed) continue;2479 const key = trimmed.toLowerCase();2480 if (seen.has(key)) continue;2481 seen.add(key);2482 cleaned.push(trimmed);2483 }2484 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2485 }24862487 if ("username" in patch) {2488 nextPatch.username = patch.username?.trim() ?? "";2489 }24902491 if ("email" in patch) {2492 nextPatch.email = patch.email?.trim() || undefined;2493 }24942495 if ("password" in patch) {2496 const password = patch.password ?? "";2497 2498 nextPatch.password = password || undefined;2499 }25002501 if ("totp" in patch) {2502 nextPatch.totp = patch.totp?.trim() || undefined;2503 }25042505 if ("notes" in patch) {2506 nextPatch.notes = patch.notes?.trim() || undefined;2507 }25082509 if ("breach" in patch) {2510 nextPatch.breach = patch.breach;2511 }25122513 if ("canary" in patch) {2514 2515 2516 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2517 }25182519 if ("passkey" in patch) {2520 2521 2522 2523 if (patch.passkey === undefined || patch.passkey === null) {2524 nextPatch.passkey = undefined;2525 } else {2526 const sanitized = sanitizePasskey(patch.passkey);2527 if (!sanitized) throw new Error("invalid passkey credential");2528 nextPatch.passkey = sanitized;2529 }2530 }25312532 if ("tags" in patch) {2533 2534 nextPatch.tags = sanitizeTags(patch.tags);2535 }25362537 if ("cardholderName" in patch) {2538 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2539 }2540 if ("cardNumber" in patch) {2541 const n = patch.cardNumber ?? "";2542 nextPatch.cardNumber = n || undefined;2543 }2544 if ("expMonth" in patch) {2545 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2546 }2547 if ("expYear" in patch) {2548 nextPatch.expYear = patch.expYear?.trim() || undefined;2549 }2550 if ("cvc" in patch) {2551 nextPatch.cvc = patch.cvc?.trim() || undefined;2552 }2553 if ("cardNickname" in patch) {2554 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2555 }2556 if ("network" in patch) {2557 nextPatch.network = patch.network;2558 }2559 if ("last4" in patch) {2560 nextPatch.last4 = patch.last4?.trim() || undefined;2561 }25622563 return nextPatch;2564}25652566function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2567 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2568 const modifiedAt =2569 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;25702571 const base: VaultEntry = {2572 id: entry.id,2573 type: "type" in entry ? entry.type : "login",2574 title: entry.title,2575 username: entry.username,2576 email: "email" in entry ? entry.email : undefined,2577 password: entry.password,2578 url: entry.url,2579 additionalUrls:2580 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2581 ? entry.additionalUrls2582 : undefined,2583 totp: entry.totp,2584 2585 totpAlgorithm: entry.totpAlgorithm,2586 totpDigits: entry.totpDigits,2587 totpPeriod: entry.totpPeriod,2588 notes: entry.notes,2589 breach: entry.breach,2590 2591 2592 2593 passwordChangedAt:2594 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2595 2596 2597 2598 passwordHistory:2599 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2600 ? entry.passwordHistory2601 : undefined,2602 canary: "canary" in entry ? entry.canary : undefined,2603 passkey: "passkey" in entry ? entry.passkey : undefined,2604 createdAt,2605 modifiedAt,2606 deletedAt: entry.deletedAt,2607 tags: "tags" in entry ? entry.tags : undefined,2608 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2609 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2610 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2611 expYear: "expYear" in entry ? entry.expYear : undefined,2612 cvc: "cvc" in entry ? entry.cvc : undefined,2613 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2614 network: "network" in entry ? entry.network : undefined,2615 last4: "last4" in entry ? entry.last4 : undefined,2616 };26172618 return migrateLegacyPasskey(base, entry);2619}262026212622262326242625262626272628function migrateLegacyPasskey(2629 base: VaultEntry,2630 raw: VaultEntry | LegacyVaultEntry2631): VaultEntry {2632 if (base.passkey) return base;2633 const flat = raw as Partial<VaultEntry>;2634 const isPasskeyType = base.type === "passkey";2635 const hasFlatPasskeyFields =2636 typeof flat.rpId === "string" &&2637 typeof flat.credentialId === "string" &&2638 typeof flat.publicKeyCose === "string" &&2639 typeof flat.userHandle === "string" &&2640 typeof flat.userName === "string";2641 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2642 if (!hasFlatPasskeyFields) return base;26432644 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2645 base.passkey = {2646 rpId: flat.rpId!,2647 rpName: flat.rpName ?? flat.rpId!,2648 credentialId: flat.credentialId!,2649 publicKeyCose: flat.publicKeyCose!,2650 privateKeyJwk: flat.privateKeyJwk,2651 userHandle: flat.userHandle!,2652 userName: flat.userName!,2653 userDisplayName: flat.userDisplayName ?? flat.userName!,2654 algorithm,2655 signCount: flat.signCount ?? 0,2656 aaguid: flat.aaguid ?? "",2657 backupEligible: flat.backupEligible === true,2658 backupState: flat.backupState === true,2659 lastUsedAt: flat.lastUsedAt,2660 browser: flat.browser,2661 transports: flat.transports,2662 createdAt: base.createdAt,2663 };2664 2665 2666 2667 if (isPasskeyType) base.type = "login";2668 if (!base.username) base.username = flat.userName;2669 if (!base.url) base.url = `https://${flat.rpId}`;2670 return base;2671}2672267326742675267626772678function entryDupeKey(e: VaultEntry): string {2679 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2680 2681 2682 2683 2684 if (e.passkey) {2685 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2686 }2687 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2688}268926902691export function countDuplicates(): number {2692 const best = new Map<string, VaultEntry>();2693 let dupeCount = 0;2694 for (const e of entries.filter((e) => !e.deletedAt)) {2695 const key = entryDupeKey(e);2696 const existing = best.get(key);2697 if (!existing) {2698 best.set(key, e);2699 } else {2700 dupeCount++;2701 2702 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2703 best.set(key, e);2704 }2705 }2706 }2707 return dupeCount;2708}270927102711271227132714export async function deduplicateEntries(): Promise<number> {2715 if (!derivedKey) throw new Error("vault_locked");2716 2717 2718 if (vaultMode === "decoy") {2719 throw new Error("deduplicate disabled in decoy mode");2720 }27212722 const best = new Map<string, VaultEntry>();2723 for (const e of entries.filter((en) => !en.deletedAt)) {2724 const key = entryDupeKey(e);2725 const existing = best.get(key);2726 if (!existing) {2727 best.set(key, e);2728 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2729 best.set(key, e);2730 }2731 }27322733 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2734 let removed = 0;2735 entries = entries.map((e) => {2736 if (!e.deletedAt && !keepIds.has(e.id)) {2737 removed++;2738 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };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 const { clearPairedDevices } = await import("./paired-devices");2954 await clearPairedDevices();29552956 2957 await setRecoveryConfirmPending(); 29582959 await stampLocalVaultMutation();2960 return { ok: true };2961}29622963296429652966296729682969export async function changePassword(2970 currentPassword: string,2971 newPassword: string2972): Promise<boolean> {2973 if (!derivedKey || vaultMode !== "real") return false;29742975 2976 const valid = await verifyMainPassword(currentPassword);2977 if (!valid) return false;29782979 const slotA = await loadSlot(SLOT_A);2980 if (!slotA) return false;2981 if (!rootKey) return false;29822983 2984 const newSalt = generateSalt();2985 const newKey = await deriveKey(newPassword, newSalt);29862987 2988 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2989 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");29902991 2992 2993 2994 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);29952996 const finalSlotA: StoredVault = {2997 ...slotA,2998 salt: hex(newSalt),2999 nonce,3000 ciphertext,3001 tag,3002 ...pwRoot,3003 };30043005 3006 3007 3008 await takeVaultSnapshot("local_save");30093010 3011 3012 3013 3014 await writeSlot(SLOT_A, finalSlotA);30153016 3017 3018 3019 zeroKey(derivedKey);3020 derivedKey = newKey;30213022 3023 3024 3025 3026 void saveSessionKey(newKey, "real");30273028 3029 3030 3031 if (!duressConfigured) {3032 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3033 }3034 await stampLocalVaultMutation();30353036 return true;3037}