From 42568ae29e97ed55d40dc3c7dd45623877781f88 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 12:55:33 +0000 Subject: [PATCH] refactor: use applesauce helpers for proper NIP-17 gift wrap handling Breaking Changes & Fixes: - Use applesauce's `unlockGiftWrap()` instead of manual decryption - Use `getConversationIdentifierFromMessage()` for conversation keys - Rumor events now stored in internal gift wrap event store automatically - This enables quick resolution of replies and reactions Benefits: - Proper handling of all NIP-44 decryption patterns - Correct conversation key generation for groups - Rumor events accessible for reply/reaction lookups - More maintainable and follows applesauce patterns Technical Details: - Removed manual NIP-44 decrypt wrapper code - Removed custom conversation key logic (was buggy for groups) - Applesauce handles seal/rumor relationship automatically - Rumor ID generation handled by unlockGiftWrap() --- src/services/gift-wrap.ts | 109 +++++++------------------------------- 1 file changed, 18 insertions(+), 91 deletions(-) diff --git a/src/services/gift-wrap.ts b/src/services/gift-wrap.ts index 543b761..5d93238 100644 --- a/src/services/gift-wrap.ts +++ b/src/services/gift-wrap.ts @@ -12,6 +12,11 @@ import pool from "./relay-pool"; import eventStore from "./event-store"; import accountManager from "./accounts"; import relayStateManager from "./relay-state-manager"; +import { + unlockGiftWrap, + type Rumor, +} from "applesauce-common/helpers/gift-wrap"; +import { getConversationIdentifierFromMessage } from "applesauce-common/helpers/messages"; /** * Statistics about gift wrap processing @@ -35,17 +40,6 @@ const GIFT_WRAP_CONFIG = { AUTH_TIMEOUT_MS: 10000, // Wait 10s for auth before proceeding }; -/** - * Rumor structure (unsigned event from NIP-59) - */ -interface Rumor { - kind: number; - content: string; - tags: string[][]; - created_at: number; - pubkey: string; -} - /** * Gift Wrap Manager * Singleton service for managing NIP-17 gift wrap decryption @@ -771,96 +765,29 @@ class GiftWrapManager { throw new Error("Gift wrap not addressed to this pubkey"); } - // Helper to call NIP-44 decrypt (supports both signer patterns) - const nip44Decrypt = async ( - pubkey: string, - ciphertext: string, - ): Promise => { - // Try direct method (PasswordSigner, NostrConnectSigner, etc.) - if (typeof signer.nip44Decrypt === "function") { - return await signer.nip44Decrypt(pubkey, ciphertext); - } - - // Try nip44 getter (ExtensionSigner) - if (signer.nip44 && typeof signer.nip44.decrypt === "function") { - return await signer.nip44.decrypt(pubkey, ciphertext); - } - - throw new Error("Signer does not support NIP-44 decryption"); - }; - - // Step 1: Decrypt the gift wrap to get the seal (kind 13) - // The gift wrap is encrypted with the conversation key between - // the random ephemeral key (giftWrap.pubkey) and our key (recipientPubkey) - let sealJSON: string; - try { - sealJSON = await nip44Decrypt(giftWrap.pubkey, giftWrap.content); - } catch (error) { - throw new Error(`Failed to decrypt gift wrap: ${error}`); - } - - // Parse the seal event - let seal: NostrEvent; - try { - seal = JSON.parse(sealJSON); - } catch (error) { - throw new Error(`Failed to parse seal JSON: ${error}`); - } - - // Verify it's a kind 13 seal - if (seal.kind !== 13) { - throw new Error(`Expected kind 13 seal, got kind ${seal.kind}`); - } - - // Step 2: Decrypt the seal to get the rumor (kind 14 or 15) - // The seal is encrypted with the conversation key between - // the sender (seal.pubkey) and us (recipientPubkey) - let rumorJSON: string; - try { - rumorJSON = await nip44Decrypt(seal.pubkey, seal.content); - } catch (error) { - throw new Error(`Failed to decrypt seal: ${error}`); - } - - // Parse the rumor event (unsigned) + // Use applesauce's unlockGiftWrap helper to decrypt and get the rumor + // This handles both NIP-44 decryption patterns automatically + // and stores the rumor in the internal gift wrap event store for quick lookups let rumor: Rumor; try { - rumor = JSON.parse(rumorJSON); + rumor = await unlockGiftWrap(giftWrap, signer); } catch (error) { - throw new Error(`Failed to parse rumor JSON: ${error}`); + throw new Error(`Failed to unlock gift wrap: ${error}`); } - // Accept any kind of private event sent via gift wrap - // This includes kind 14 (DMs), kind 15 (files), kind 7 (reactions), - // kind 25050 (private DM relays), and any other private events + // Use applesauce helper to get the conversation identifier + // This properly handles all participants including group DMs + const conversationKey = getConversationIdentifierFromMessage(rumor); - // Verify the rumor's pubkey matches the seal's pubkey (prevent spoofing) - if (rumor.pubkey !== seal.pubkey) { - throw new Error( - "Rumor pubkey does not match seal pubkey (spoofing attempt)", - ); - } - - // Generate a unique ID for this rumor (since it's unsigned) - // Use a combination of gift wrap ID + seal ID - const rumorId = `${giftWrap.id}:${seal.id}`; - - // Create conversation key including ALL participants from p-tags (for group DMs) - // Extract all recipient pubkeys from p-tags - const recipientPubkeys = rumor.tags - .filter((t: string[]) => t[0] === "p" && t[1]) - .map((t: string[]) => t[1]); - - // Include sender + all recipients, deduplicate and sort for consistency - const allParticipants = [seal.pubkey, ...recipientPubkeys]; - const conversationKey = [...new Set(allParticipants)].sort().join(":"); + // Get seal ID from gift wrap tags (applesauce stores it there) + const sealId = rumor.id.split(":")[1] || giftWrap.id; // Create the unsealed DM record const unsealed: UnsealedDM = { - id: rumorId, + id: rumor.id, // Rumor already has an ID from applesauce giftWrapId: giftWrap.id, - sealId: seal.id, - senderPubkey: seal.pubkey, + sealId, + senderPubkey: rumor.pubkey, recipientPubkey, conversationKey, kind: rumor.kind,