diff --git a/src/hooks/useAccountSync.ts b/src/hooks/useAccountSync.ts index bc7f96f..5dd5231 100644 --- a/src/hooks/useAccountSync.ts +++ b/src/hooks/useAccountSync.ts @@ -6,12 +6,14 @@ import { addressLoader } from "@/services/loaders"; import type { RelayInfo } from "@/types/app"; import { normalizeRelayURL } from "@/lib/relay-url"; import { getServersFromEvent } from "@/services/blossom"; +import giftWrapLoader from "@/services/gift-wrap-loader"; /** * Hook that syncs active account with Grimoire state and fetches relay lists and blossom servers */ export function useAccountSync() { const { + state, setActiveAccount, setActiveAccountRelays, setActiveAccountBlossomServers, @@ -125,4 +127,33 @@ export function useAccountSync() { storeSubscription.unsubscribe(); }; }, [activeAccount?.pubkey, eventStore, setActiveAccountBlossomServers]); + + // Enable/disable gift wrap loader based on feature flag and active account + useEffect(() => { + const privateMessagesEnabled = state.privateMessagesEnabled ?? false; + + if ( + privateMessagesEnabled && + activeAccount?.pubkey && + activeAccount.signer + ) { + // Enable gift wrap loading + console.log( + `[AccountSync] Enabling private messages for ${activeAccount.pubkey.slice(0, 8)}`, + ); + giftWrapLoader.enable(activeAccount.pubkey, activeAccount.signer); + } else { + // Disable gift wrap loading + giftWrapLoader.disable(); + } + + return () => { + // Cleanup on unmount + giftWrapLoader.disable(); + }; + }, [ + state.privateMessagesEnabled, + activeAccount?.pubkey, + activeAccount?.signer, + ]); } diff --git a/src/services/db.ts b/src/services/db.ts index 6e9ee79..d65d0d6 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -87,6 +87,52 @@ export interface LocalSpellbook { deletedAt?: number; } +/** + * Gift wrap envelope (kind 1059) - tracks outer layer + * Records which gift wraps we've seen and their decryption status + */ +export interface GiftWrapEnvelope { + id: string; // gift wrap event ID (kind 1059) + recipientPubkey: string; // who it's addressed to (from p-tag) + event: NostrEvent; // full gift wrap event + status: "pending" | "decrypted" | "failed"; // decryption state + failureReason?: string; // if failed, why? + receivedAt: number; // when we first saw it + processedAt?: number; // when we attempted decryption +} + +/** + * Decrypted rumor - the actual message content after unwrapping + * Stores the seal (kind 13) and extracted rumor (unsigned event) + */ +export interface DecryptedRumor { + giftWrapId: string; // links back to gift wrap (primary key) + recipientPubkey: string; // which of our accounts received this + senderPubkey: string; // from seal (who sent it) + seal: NostrEvent; // kind 13 seal event + rumor: NostrEvent; // the unsigned inner event + rumorCreatedAt: number; // canonical timestamp from rumor + rumorKind: number; // kind of the rumor (for filtering) + decryptedAt: number; // when we successfully decrypted it +} + +/** + * Conversation metadata - denormalized cache for fast conversation list queries + * One entry per (sender, recipient) pair + */ +export interface ConversationMetadata { + id: string; // `${senderPubkey}:${recipientPubkey}` (primary key) + senderPubkey: string; // who sent messages + recipientPubkey: string; // which of our accounts + lastMessageGiftWrapId: string; // ID of most recent gift wrap + lastMessageCreatedAt: number; // rumor created_at of most recent message + lastMessagePreview: string; // content preview for UI + lastMessageKind: number; // rumor kind of most recent message + messageCount: number; // total messages in conversation + unreadCount: number; // unread message count + updatedAt: number; // when this metadata was last updated +} + class GrimoireDb extends Dexie { profiles!: Table; nip05!: Table; @@ -98,6 +144,9 @@ class GrimoireDb extends Dexie { blossomServers!: Table; spells!: Table; spellbooks!: Table; + giftWraps!: Table; + decryptedRumors!: Table; + conversations!: Table; constructor(name: string) { super(name); @@ -333,6 +382,28 @@ class GrimoireDb extends Dexie { spells: "&id, alias, createdAt, isPublished, deletedAt", spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", }); + + // Version 16: Add gift wrap (NIP-59) support + this.version(16).stores({ + profiles: "&pubkey", + nip05: "&nip05", + nips: "&id", + relayInfo: "&url", + relayAuthPreferences: "&url", + relayLists: "&pubkey, updatedAt", + relayLiveness: "&url", + blossomServers: "&pubkey, updatedAt", + spells: "&id, alias, createdAt, isPublished, deletedAt", + spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", + // Gift wrap envelopes indexed by recipient and status for efficient queries + giftWraps: "&id, recipientPubkey, [recipientPubkey+status], receivedAt", + // Decrypted rumors indexed by sender and timestamp for conversation queries + decryptedRumors: + "&giftWrapId, recipientPubkey, senderPubkey, [senderPubkey+rumorCreatedAt], [recipientPubkey+senderPubkey], rumorCreatedAt", + // Conversation metadata for fast conversation list queries + conversations: + "&id, recipientPubkey, [recipientPubkey+lastMessageCreatedAt]", + }); } } diff --git a/src/services/gift-wrap-loader.ts b/src/services/gift-wrap-loader.ts new file mode 100644 index 0000000..0e9ceca --- /dev/null +++ b/src/services/gift-wrap-loader.ts @@ -0,0 +1,309 @@ +/** + * NIP-59 Gift Wrap Loader Service + * + * Loads gift wraps (kind 1059) from user's inbox relays and processes them. + * Gift wraps are private messages wrapped in multiple layers of encryption. + * + * Architecture: + * - Fetches from inbox relays (NIP-65 read relays) + * - Requires NIP-42 AUTH for relay access + * - Caches decryption results to avoid re-processing + * - Updates conversation metadata for UI + * + * See: https://github.com/nostr-protocol/nips/blob/master/59.md + */ + +import { BehaviorSubject, Observable } from "rxjs"; +import type { NostrEvent } from "@/types/nostr"; +import type { Signer } from "applesauce-signers"; +import pool from "./relay-pool"; +import eventStore from "./event-store"; +import { relayListCache } from "./relay-list-cache"; +import { processGiftWrap, getPendingGiftWraps } from "./gift-wrap"; +import db from "./db"; + +/** + * Gift wrap loader state + */ +interface GiftWrapLoaderState { + enabled: boolean; + loading: boolean; + recipientPubkey?: string; + lastSync?: number; + errorCount: number; +} + +/** + * Gift wrap loader service + * Manages loading and processing of gift wraps for the active account + */ +class GiftWrapLoader { + private state$ = new BehaviorSubject({ + enabled: false, + loading: false, + errorCount: 0, + }); + + private subscription?: { unsubscribe: () => void }; + private currentSigner?: Signer; + + /** + * Observable state of the loader + */ + get state(): Observable { + return this.state$.asObservable(); + } + + /** + * Gets current state + */ + getCurrentState(): GiftWrapLoaderState { + return this.state$.value; + } + + /** + * Enables gift wrap loading for a user + * + * @param recipientPubkey - The user's public key + * @param signer - The user's signer (for decryption) + */ + async enable(recipientPubkey: string, signer: Signer): Promise { + // Stop any existing subscription + this.disable(); + + this.currentSigner = signer; + + this.state$.next({ + ...this.state$.value, + enabled: true, + recipientPubkey, + }); + + console.log(`[GiftWrapLoader] Enabled for ${recipientPubkey.slice(0, 8)}`); + + // Start loading + await this.sync(); + } + + /** + * Disables gift wrap loading + */ + disable(): void { + if (this.subscription) { + this.subscription.unsubscribe(); + this.subscription = undefined; + } + + this.currentSigner = undefined; + + this.state$.next({ + ...this.state$.value, + enabled: false, + loading: false, + recipientPubkey: undefined, + }); + + console.log("[GiftWrapLoader] Disabled"); + } + + /** + * Syncs gift wraps from inbox relays + */ + async sync(): Promise { + const state = this.state$.value; + + if (!state.enabled || !state.recipientPubkey || !this.currentSigner) { + console.warn("[GiftWrapLoader] Cannot sync: not enabled or no signer"); + return; + } + + if (state.loading) { + console.log("[GiftWrapLoader] Already syncing, skipping"); + return; + } + + this.state$.next({ ...state, loading: true }); + + try { + // Get inbox relays from cache + const inboxRelays = await this.getInboxRelays(state.recipientPubkey); + + if (inboxRelays.length === 0) { + console.warn( + "[GiftWrapLoader] No inbox relays found, using aggregator relays", + ); + // Could fall back to aggregator relays, but gift wraps are typically private + // so this might not work well + } + + console.log( + `[GiftWrapLoader] Syncing from ${inboxRelays.length} inbox relays`, + ); + + // Subscribe to kind 1059 events for this user + const filter = { + kinds: [1059], + "#p": [state.recipientPubkey], + // Optionally add since: to only get new messages + // since: state.lastSync ? Math.floor(state.lastSync / 1000) : undefined, + }; + + // Store subscription for cleanup + this.subscription = pool.subscribe(inboxRelays, [filter], { + onevent: async (event: NostrEvent) => { + await this.handleGiftWrap(event); + }, + oneose: () => { + console.log("[GiftWrapLoader] EOSE received"); + this.state$.next({ + ...this.state$.value, + loading: false, + lastSync: Date.now(), + }); + }, + onclose: (reason: string) => { + console.log(`[GiftWrapLoader] Subscription closed: ${reason}`); + this.state$.next({ + ...this.state$.value, + loading: false, + }); + }, + }); + + // Process any pending gift wraps from database + await this.processPendingGiftWraps(); + } catch (error) { + console.error("[GiftWrapLoader] Sync error:", error); + this.state$.next({ + ...this.state$.value, + loading: false, + errorCount: state.errorCount + 1, + }); + } + } + + /** + * Handles a received gift wrap event + */ + private async handleGiftWrap(event: NostrEvent): Promise { + const state = this.state$.value; + + if (!state.recipientPubkey || !this.currentSigner) { + return; + } + + try { + // Add to event store for tracking + eventStore.add(event); + + // Process (unwrap, unseal, cache) + await processGiftWrap(event, state.recipientPubkey, this.currentSigner); + + console.log( + `[GiftWrapLoader] Processed gift wrap ${event.id.slice(0, 8)}`, + ); + } catch (error) { + console.error( + `[GiftWrapLoader] Failed to handle gift wrap ${event.id.slice(0, 8)}:`, + error, + ); + } + } + + /** + * Processes pending gift wraps from database + * (Gift wraps that were received but not yet decrypted) + */ + private async processPendingGiftWraps(): Promise { + const state = this.state$.value; + + if (!state.recipientPubkey || !this.currentSigner) { + return; + } + + const pending = await getPendingGiftWraps(state.recipientPubkey); + + if (pending.length === 0) { + return; + } + + console.log( + `[GiftWrapLoader] Processing ${pending.length} pending gift wraps`, + ); + + for (const envelope of pending) { + try { + await processGiftWrap( + envelope.event, + state.recipientPubkey, + this.currentSigner, + ); + } catch (error) { + console.error( + `[GiftWrapLoader] Failed to process pending gift wrap ${envelope.id.slice(0, 8)}:`, + error, + ); + } + } + } + + /** + * Gets inbox relays for a user + */ + private async getInboxRelays(pubkey: string): Promise { + // Try cache first + let relays = await relayListCache.getInboxRelays(pubkey); + + if (!relays || relays.length === 0) { + // Try to get from event store + const event = eventStore.getReplaceable(10002, pubkey, ""); + if (event) { + // Cache it + relayListCache.set(event); + relays = await relayListCache.getInboxRelays(pubkey); + } + } + + return relays || []; + } + + /** + * Forces a full resync (re-fetches all gift wraps) + */ + async forceSync(): Promise { + // Clear lastSync to fetch all messages + this.state$.next({ + ...this.state$.value, + lastSync: undefined, + }); + + await this.sync(); + } + + /** + * Gets count of unread messages + */ + async getUnreadCount(recipientPubkey: string): Promise { + const conversations = await db.conversations + .where("recipientPubkey") + .equals(recipientPubkey) + .toArray(); + + return conversations.reduce((sum, conv) => sum + conv.unreadCount, 0); + } + + /** + * Gets total conversation count + */ + async getConversationCount(recipientPubkey: string): Promise { + return db.conversations + .where("recipientPubkey") + .equals(recipientPubkey) + .count(); + } +} + +// Singleton instance +const giftWrapLoader = new GiftWrapLoader(); + +export default giftWrapLoader; diff --git a/src/services/gift-wrap.test.ts b/src/services/gift-wrap.test.ts new file mode 100644 index 0000000..85e340f --- /dev/null +++ b/src/services/gift-wrap.test.ts @@ -0,0 +1,320 @@ +/** + * Tests for NIP-59 Gift Wrap Service + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { unwrapAndUnseal, GiftWrapError } from "./gift-wrap"; +import type { NostrEvent } from "@/types/nostr"; +import type { Signer } from "applesauce-signers"; + +// Mock signer for testing +function createMockSigner( + decryptResponses: Map, +): Signer & { + nip44Decrypt: (pubkey: string, ciphertext: string) => Promise; +} { + return { + getPublicKey: vi.fn().mockResolvedValue("mock-pubkey"), + signEvent: vi.fn(), + nip44Decrypt: vi.fn(async (pubkey: string, ciphertext: string) => { + const response = decryptResponses.get(`${pubkey}:${ciphertext}`); + if (!response) { + throw new Error("Mock decryption failed: no response configured"); + } + return response; + }), + }; +} + +describe("GiftWrapError", () => { + it("should create error with code", () => { + const error = new GiftWrapError("Test error", "INVALID_KIND"); + expect(error.message).toBe("Test error"); + expect(error.code).toBe("INVALID_KIND"); + expect(error.name).toBe("GiftWrapError"); + }); +}); + +describe("unwrapAndUnseal", () => { + describe("validation", () => { + it("should reject non-1059 events", async () => { + const invalidGiftWrap: NostrEvent = { + id: "test-id", + pubkey: "ephemeral-key", + created_at: 1234567890, + kind: 1, // Wrong kind + tags: [], + content: "encrypted-content", + sig: "signature", + }; + + const signer = createMockSigner(new Map()); + + await expect( + unwrapAndUnseal(invalidGiftWrap, "recipient", signer), + ).rejects.toThrow(GiftWrapError); + + await expect( + unwrapAndUnseal(invalidGiftWrap, "recipient", signer), + ).rejects.toThrow("Expected kind 1059"); + }); + + it("should reject gift wrap with empty content", async () => { + const emptyGiftWrap: NostrEvent = { + id: "test-id", + pubkey: "ephemeral-key", + created_at: 1234567890, + kind: 1059, + tags: [], + content: "", // Empty + sig: "signature", + }; + + const signer = createMockSigner(new Map()); + + await expect( + unwrapAndUnseal(emptyGiftWrap, "recipient", signer), + ).rejects.toThrow(GiftWrapError); + + await expect( + unwrapAndUnseal(emptyGiftWrap, "recipient", signer), + ).rejects.toThrow("content is empty"); + }); + }); + + describe("unwrapping", () => { + it("should unwrap valid gift wrap to get seal", async () => { + const seal: NostrEvent = { + id: "seal-id", + pubkey: "sender-real-key", + created_at: 1234567890, + kind: 13, + tags: [], + content: "encrypted-rumor", + sig: "seal-signature", + }; + + const rumor = { + kind: 1, + content: "Hello, world!", + tags: [], + created_at: 1234567890, + pubkey: "sender-real-key", + }; + + const giftWrap: NostrEvent = { + id: "gift-wrap-id", + pubkey: "ephemeral-key", + created_at: 1234567899, // Tweaked timestamp + kind: 1059, + tags: [["p", "recipient-pubkey"]], + content: "encrypted-seal", + sig: "gift-wrap-signature", + }; + + const decryptResponses = new Map([ + ["ephemeral-key:encrypted-seal", JSON.stringify(seal)], + ["sender-real-key:encrypted-rumor", JSON.stringify(rumor)], + ]); + + const signer = createMockSigner(decryptResponses); + + const result = await unwrapAndUnseal( + giftWrap, + "recipient-pubkey", + signer, + ); + + expect(result.seal).toEqual(seal); + expect(result.rumor.kind).toBe(1); + expect(result.rumor.content).toBe("Hello, world!"); + expect(result.rumor.pubkey).toBe("sender-real-key"); + }); + + it("should attach sender pubkey to rumor", async () => { + const seal: NostrEvent = { + id: "seal-id", + pubkey: "sender-real-key", + created_at: 1234567890, + kind: 13, + tags: [], + content: "encrypted-rumor", + sig: "seal-signature", + }; + + // Rumor without pubkey (as it should be when extracted) + const rumor = { + kind: 1, + content: "Test message", + tags: [], + created_at: 1234567890, + // No pubkey initially + }; + + const giftWrap: NostrEvent = { + id: "gift-wrap-id", + pubkey: "ephemeral-key", + created_at: 1234567899, + kind: 1059, + tags: [["p", "recipient-pubkey"]], + content: "encrypted-seal", + sig: "gift-wrap-signature", + }; + + const decryptResponses = new Map([ + ["ephemeral-key:encrypted-seal", JSON.stringify(seal)], + ["sender-real-key:encrypted-rumor", JSON.stringify(rumor)], + ]); + + const signer = createMockSigner(decryptResponses); + + const result = await unwrapAndUnseal( + giftWrap, + "recipient-pubkey", + signer, + ); + + // Pubkey should be attached from seal + expect(result.rumor.pubkey).toBe("sender-real-key"); + }); + + it("should reject invalid seal kind", async () => { + const invalidSeal = { + id: "seal-id", + pubkey: "sender-real-key", + created_at: 1234567890, + kind: 1, // Wrong kind + tags: [], + content: "encrypted-rumor", + sig: "seal-signature", + }; + + const giftWrap: NostrEvent = { + id: "gift-wrap-id", + pubkey: "ephemeral-key", + created_at: 1234567899, + kind: 1059, + tags: [["p", "recipient-pubkey"]], + content: "encrypted-seal", + sig: "gift-wrap-signature", + }; + + const decryptResponses = new Map([ + ["ephemeral-key:encrypted-seal", JSON.stringify(invalidSeal)], + ]); + + const signer = createMockSigner(decryptResponses); + + await expect( + unwrapAndUnseal(giftWrap, "recipient-pubkey", signer), + ).rejects.toThrow(GiftWrapError); + + await expect( + unwrapAndUnseal(giftWrap, "recipient-pubkey", signer), + ).rejects.toThrow("Expected seal kind 13"); + }); + + it("should reject seal with empty content", async () => { + const invalidSeal: NostrEvent = { + id: "seal-id", + pubkey: "sender-real-key", + created_at: 1234567890, + kind: 13, + tags: [], + content: "", // Empty + sig: "seal-signature", + }; + + const giftWrap: NostrEvent = { + id: "gift-wrap-id", + pubkey: "ephemeral-key", + created_at: 1234567899, + kind: 1059, + tags: [["p", "recipient-pubkey"]], + content: "encrypted-seal", + sig: "gift-wrap-signature", + }; + + const decryptResponses = new Map([ + ["ephemeral-key:encrypted-seal", JSON.stringify(invalidSeal)], + ]); + + const signer = createMockSigner(decryptResponses); + + await expect( + unwrapAndUnseal(giftWrap, "recipient-pubkey", signer), + ).rejects.toThrow(GiftWrapError); + + await expect( + unwrapAndUnseal(giftWrap, "recipient-pubkey", signer), + ).rejects.toThrow("Seal content is empty"); + }); + + it("should reject invalid rumor structure", async () => { + const seal: NostrEvent = { + id: "seal-id", + pubkey: "sender-real-key", + created_at: 1234567890, + kind: 13, + tags: [], + content: "encrypted-rumor", + sig: "seal-signature", + }; + + const invalidRumor = { + kind: 1, + // Missing content + tags: [], + created_at: 1234567890, + }; + + const giftWrap: NostrEvent = { + id: "gift-wrap-id", + pubkey: "ephemeral-key", + created_at: 1234567899, + kind: 1059, + tags: [["p", "recipient-pubkey"]], + content: "encrypted-seal", + sig: "gift-wrap-signature", + }; + + const decryptResponses = new Map([ + ["ephemeral-key:encrypted-seal", JSON.stringify(seal)], + ["sender-real-key:encrypted-rumor", JSON.stringify(invalidRumor)], + ]); + + const signer = createMockSigner(decryptResponses); + + await expect( + unwrapAndUnseal(giftWrap, "recipient-pubkey", signer), + ).rejects.toThrow(GiftWrapError); + + await expect( + unwrapAndUnseal(giftWrap, "recipient-pubkey", signer), + ).rejects.toThrow("Rumor missing content"); + }); + + it("should handle decryption failures", async () => { + const giftWrap: NostrEvent = { + id: "gift-wrap-id", + pubkey: "ephemeral-key", + created_at: 1234567899, + kind: 1059, + tags: [["p", "recipient-pubkey"]], + content: "encrypted-seal", + sig: "gift-wrap-signature", + }; + + // No responses configured - decryption will fail + const signer = createMockSigner(new Map()); + + await expect( + unwrapAndUnseal(giftWrap, "recipient-pubkey", signer), + ).rejects.toThrow(GiftWrapError); + + await expect( + unwrapAndUnseal(giftWrap, "recipient-pubkey", signer), + ).rejects.toThrow("Failed to decrypt"); + }); + }); +}); diff --git a/src/services/gift-wrap.ts b/src/services/gift-wrap.ts new file mode 100644 index 0000000..ab5fbc5 --- /dev/null +++ b/src/services/gift-wrap.ts @@ -0,0 +1,406 @@ +/** + * NIP-59 Gift Wrap Service + * + * Handles unwrapping gift wraps (kind 1059) and unsealing seals (kind 13) + * to extract encrypted rumors. + * + * Architecture: + * 1. Gift Wrap (kind 1059) - outer layer with random ephemeral key + * 2. Seal (kind 13) - middle layer with sender's real key + * 3. Rumor - inner unsigned event with actual content + * + * See: https://github.com/nostr-protocol/nips/blob/master/59.md + */ + +import type { NostrEvent } from "@/types/nostr"; +import type { Signer } from "applesauce-signers"; +import db, { + GiftWrapEnvelope, + DecryptedRumor, + ConversationMetadata, +} from "./db"; + +/** + * Error thrown when gift wrap unwrapping fails + */ +export class GiftWrapError extends Error { + constructor( + message: string, + public code: + | "INVALID_KIND" + | "MISSING_CONTENT" + | "DECRYPTION_FAILED" + | "INVALID_SEAL" + | "INVALID_RUMOR" + | "NO_SIGNER", + ) { + super(message); + this.name = "GiftWrapError"; + } +} + +/** + * Validates that an event is a gift wrap (kind 1059) + */ +function validateGiftWrap(event: NostrEvent): void { + if (event.kind !== 1059) { + throw new GiftWrapError( + `Expected kind 1059, got ${event.kind}`, + "INVALID_KIND", + ); + } + + if (!event.content || event.content.trim() === "") { + throw new GiftWrapError("Gift wrap content is empty", "MISSING_CONTENT"); + } +} + +/** + * Validates that an event is a seal (kind 13) + */ +function validateSeal(event: NostrEvent): void { + if (event.kind !== 13) { + throw new GiftWrapError( + `Expected seal kind 13, got ${event.kind}`, + "INVALID_SEAL", + ); + } + + if (!event.content || event.content.trim() === "") { + throw new GiftWrapError("Seal content is empty", "INVALID_SEAL"); + } + + if (!event.pubkey) { + throw new GiftWrapError("Seal missing pubkey", "INVALID_SEAL"); + } +} + +/** + * Validates that an event is a valid rumor (unsigned event) + */ +function validateRumor(event: any): NostrEvent { + if (!event || typeof event !== "object") { + throw new GiftWrapError("Rumor is not an object", "INVALID_RUMOR"); + } + + if (typeof event.kind !== "number") { + throw new GiftWrapError("Rumor missing kind", "INVALID_RUMOR"); + } + + if (typeof event.content !== "string") { + throw new GiftWrapError("Rumor missing content", "INVALID_RUMOR"); + } + + if (!Array.isArray(event.tags)) { + throw new GiftWrapError("Rumor missing tags array", "INVALID_RUMOR"); + } + + if (typeof event.created_at !== "number") { + throw new GiftWrapError("Rumor missing created_at", "INVALID_RUMOR"); + } + + // Rumor should NOT have id or sig (it's unsigned) + // But it SHOULD have pubkey from the seal + if (!event.pubkey) { + throw new GiftWrapError("Rumor missing pubkey", "INVALID_RUMOR"); + } + + return event as NostrEvent; +} + +/** + * Unwraps a gift wrap to extract the seal + * + * @param giftWrap - Kind 1059 gift wrap event + * @param signer - Signer for recipient (to decrypt) + * @returns The seal event (kind 13) + */ +async function unwrapGiftWrap( + giftWrap: NostrEvent, + signer: Signer, +): Promise { + validateGiftWrap(giftWrap); + + if (!signer.nip44Decrypt) { + throw new GiftWrapError( + "Signer does not support NIP-44 decryption", + "NO_SIGNER", + ); + } + + try { + // Decrypt using the gift wrap author's pubkey (ephemeral key) + const decryptedContent = await signer.nip44Decrypt( + giftWrap.pubkey, + giftWrap.content, + ); + + // Parse the seal event + const seal = JSON.parse(decryptedContent); + validateSeal(seal); + + return seal; + } catch (error) { + if (error instanceof GiftWrapError) { + throw error; + } + + throw new GiftWrapError( + `Failed to decrypt gift wrap: ${error instanceof Error ? error.message : String(error)}`, + "DECRYPTION_FAILED", + ); + } +} + +/** + * Unseals a seal to extract the rumor + * + * @param seal - Kind 13 seal event + * @param signer - Signer for recipient (to decrypt) + * @returns The rumor (unsigned event) with sender's pubkey attached + */ +async function unsealSeal( + seal: NostrEvent, + signer: Signer, +): Promise { + validateSeal(seal); + + if (!signer.nip44Decrypt) { + throw new GiftWrapError( + "Signer does not support NIP-44 decryption", + "NO_SIGNER", + ); + } + + try { + // Decrypt using the seal author's pubkey (sender's real key) + const decryptedContent = await signer.nip44Decrypt( + seal.pubkey, + seal.content, + ); + + // Parse the rumor + const rumor = JSON.parse(decryptedContent); + + // Attach sender's pubkey to rumor (from seal) + rumor.pubkey = seal.pubkey; + + const validatedRumor = validateRumor(rumor); + return validatedRumor; + } catch (error) { + if (error instanceof GiftWrapError) { + throw error; + } + + throw new GiftWrapError( + `Failed to unseal seal: ${error instanceof Error ? error.message : String(error)}`, + "DECRYPTION_FAILED", + ); + } +} + +/** + * Unwraps a gift wrap and unseals to extract the rumor (full process) + * + * @param giftWrap - Kind 1059 gift wrap event + * @param recipientPubkey - The recipient's public key + * @param signer - Signer for recipient (to decrypt) + * @returns Object with seal and rumor + */ +export async function unwrapAndUnseal( + giftWrap: NostrEvent, + recipientPubkey: string, + signer: Signer, +): Promise<{ seal: NostrEvent; rumor: NostrEvent }> { + // Step 1: Unwrap gift wrap to get seal + const seal = await unwrapGiftWrap(giftWrap, signer); + + // Step 2: Unseal to get rumor + const rumor = await unsealSeal(seal, signer); + + return { seal, rumor }; +} + +/** + * Processes a gift wrap: unwraps, unseals, and stores in database + * + * @param giftWrap - Kind 1059 gift wrap event + * @param recipientPubkey - The recipient's public key + * @param signer - Signer for recipient (to decrypt) + * @returns The decrypted rumor record from database + */ +export async function processGiftWrap( + giftWrap: NostrEvent, + recipientPubkey: string, + signer: Signer, +): Promise { + // Check if already processed + const existing = await db.giftWraps.get(giftWrap.id); + if (existing) { + // Already processed + if (existing.status === "decrypted") { + return (await db.decryptedRumors.get(giftWrap.id)) || null; + } + if (existing.status === "failed") { + // Already tried and failed, don't retry + return null; + } + } + + // Store gift wrap envelope + const envelope: GiftWrapEnvelope = { + id: giftWrap.id, + recipientPubkey, + event: giftWrap, + status: "pending", + receivedAt: existing?.receivedAt || Date.now(), + processedAt: Date.now(), + }; + + try { + // Unwrap and unseal + const { seal, rumor } = await unwrapAndUnseal( + giftWrap, + recipientPubkey, + signer, + ); + + // Store decrypted rumor + const decryptedRumor: DecryptedRumor = { + giftWrapId: giftWrap.id, + recipientPubkey, + senderPubkey: seal.pubkey, + seal, + rumor, + rumorCreatedAt: rumor.created_at, + rumorKind: rumor.kind, + decryptedAt: Date.now(), + }; + + // Update envelope and store rumor in transaction + await db.transaction("rw", [db.giftWraps, db.decryptedRumors], async () => { + envelope.status = "decrypted"; + await db.giftWraps.put(envelope); + await db.decryptedRumors.put(decryptedRumor); + }); + + // Update conversation metadata + await updateConversationMetadata(decryptedRumor); + + return decryptedRumor; + } catch (error) { + // Store failure + envelope.status = "failed"; + envelope.failureReason = + error instanceof Error ? error.message : String(error); + await db.giftWraps.put(envelope); + + console.error(`[GiftWrap] Failed to process ${giftWrap.id}:`, error); + return null; + } +} + +/** + * Updates conversation metadata after processing a new message + */ +async function updateConversationMetadata( + rumor: DecryptedRumor, +): Promise { + const conversationId = `${rumor.senderPubkey}:${rumor.recipientPubkey}`; + + const existing = await db.conversations.get(conversationId); + + // Get content preview (first 100 chars) + const preview = rumor.rumor.content.slice(0, 100); + + if (!existing) { + // Create new conversation + const conversation: ConversationMetadata = { + id: conversationId, + senderPubkey: rumor.senderPubkey, + recipientPubkey: rumor.recipientPubkey, + lastMessageGiftWrapId: rumor.giftWrapId, + lastMessageCreatedAt: rumor.rumorCreatedAt, + lastMessagePreview: preview, + lastMessageKind: rumor.rumorKind, + messageCount: 1, + unreadCount: 1, + updatedAt: Date.now(), + }; + await db.conversations.put(conversation); + } else { + // Update existing conversation if this is newer + if (rumor.rumorCreatedAt > existing.lastMessageCreatedAt) { + const conversation: ConversationMetadata = { + ...existing, + lastMessageGiftWrapId: rumor.giftWrapId, + lastMessageCreatedAt: rumor.rumorCreatedAt, + lastMessagePreview: preview, + lastMessageKind: rumor.rumorKind, + messageCount: existing.messageCount + 1, + unreadCount: existing.unreadCount + 1, + updatedAt: Date.now(), + }; + await db.conversations.put(conversation); + } else { + // Just increment message count for older messages + existing.messageCount++; + await db.conversations.put(existing); + } + } +} + +/** + * Gets all conversations for a recipient, sorted by most recent + */ +export async function getConversations( + recipientPubkey: string, +): Promise { + return db.conversations + .where("recipientPubkey") + .equals(recipientPubkey) + .reverse() + .sortBy("lastMessageCreatedAt"); +} + +/** + * Gets all decrypted messages in a conversation + */ +export async function getConversationMessages( + recipientPubkey: string, + senderPubkey: string, +): Promise { + return db.decryptedRumors + .where("[recipientPubkey+senderPubkey]") + .equals([recipientPubkey, senderPubkey]) + .sortBy("rumorCreatedAt"); +} + +/** + * Marks a conversation as read + */ +export async function markConversationAsRead( + recipientPubkey: string, + senderPubkey: string, +): Promise { + const conversationId = `${senderPubkey}:${recipientPubkey}`; + const conversation = await db.conversations.get(conversationId); + + if (conversation) { + conversation.unreadCount = 0; + conversation.updatedAt = Date.now(); + await db.conversations.put(conversation); + } +} + +/** + * Gets all pending gift wraps that need processing + */ +export async function getPendingGiftWraps( + recipientPubkey: string, +): Promise { + return db.giftWraps + .where("[recipientPubkey+status]") + .equals([recipientPubkey, "pending"]) + .toArray(); +} diff --git a/src/types/app.ts b/src/types/app.ts index 09a1148..d61f3b3 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -110,4 +110,9 @@ export interface GrimoireState { localId?: string; // Local DB ID if saved to library isPublished?: boolean; // Whether it has been published to Nostr }; + /** + * Feature flag: Enable NIP-59 private messages (gift wraps) + * When enabled, gift wraps will be fetched from inbox relays and decrypted + */ + privateMessagesEnabled?: boolean; }