mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-15 17:19:27 +02:00
feat: Add NIP-59 gift wrap (private messages) support
Implements core infrastructure for NIP-59 gift wraps with optional feature flag control. Gift wraps enable private, metadata-obscured messaging by wrapping events in multiple encryption layers. **Architecture:** - Gift wrap (kind 1059) → Seal (kind 13) → Rumor (unsigned event) - Uses NIP-44 versioned encryption for all layers - Fetches from inbox relays (NIP-65 read relays) - Caches decryption results to avoid redundant processing **Database Schema (Dexie v16):** - `giftWraps` - tracks envelope metadata and decryption status - `decryptedRumors` - stores unwrapped message content - `conversations` - denormalized cache for conversation list queries **Indexes:** - By recipient pubkey (query all messages for user) - By sender + timestamp (query messages from specific sender) - By conversation (recipient + sender pair) - By last message timestamp (sort conversation list) **Services:** - `gift-wrap.ts` - unwrapping/unsealing logic with validation - `gift-wrap-loader.ts` - loader service for fetching from relays - Integrates with account sync hook (auto-enable when flag set) **Feature Flag:** - `state.privateMessagesEnabled` - opt-in control - When enabled, automatically syncs gift wraps for active account - Properly cleans up subscriptions on disable/unmount **Privacy Features:** - Never exposes gift wrap → rumor mapping to relays - Tracks decryption failures to avoid retry loops - Supports conversation metadata for UI (unread counts, previews) **Testing:** - Comprehensive unit tests for validation and unwrapping logic - Tests cover error cases, invalid formats, and decryption failures **References:** - NIP-59: https://github.com/nostr-protocol/nips/blob/master/59.md - NIP-44: https://github.com/nostr-protocol/nips/blob/master/44.md - NIP-65: https://github.com/nostr-protocol/nips/blob/master/65.md
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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<Profile>;
|
||||
nip05!: Table<Nip05>;
|
||||
@@ -98,6 +144,9 @@ class GrimoireDb extends Dexie {
|
||||
blossomServers!: Table<CachedBlossomServerList>;
|
||||
spells!: Table<LocalSpell>;
|
||||
spellbooks!: Table<LocalSpellbook>;
|
||||
giftWraps!: Table<GiftWrapEnvelope>;
|
||||
decryptedRumors!: Table<DecryptedRumor>;
|
||||
conversations!: Table<ConversationMetadata>;
|
||||
|
||||
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]",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
309
src/services/gift-wrap-loader.ts
Normal file
309
src/services/gift-wrap-loader.ts
Normal file
@@ -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<GiftWrapLoaderState>({
|
||||
enabled: false,
|
||||
loading: false,
|
||||
errorCount: 0,
|
||||
});
|
||||
|
||||
private subscription?: { unsubscribe: () => void };
|
||||
private currentSigner?: Signer;
|
||||
|
||||
/**
|
||||
* Observable state of the loader
|
||||
*/
|
||||
get state(): Observable<GiftWrapLoaderState> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
// 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<void> {
|
||||
// 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<number> {
|
||||
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<number> {
|
||||
return db.conversations
|
||||
.where("recipientPubkey")
|
||||
.equals(recipientPubkey)
|
||||
.count();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const giftWrapLoader = new GiftWrapLoader();
|
||||
|
||||
export default giftWrapLoader;
|
||||
320
src/services/gift-wrap.test.ts
Normal file
320
src/services/gift-wrap.test.ts
Normal file
@@ -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<string, string>,
|
||||
): Signer & {
|
||||
nip44Decrypt: (pubkey: string, ciphertext: string) => Promise<string>;
|
||||
} {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
406
src/services/gift-wrap.ts
Normal file
406
src/services/gift-wrap.ts
Normal file
@@ -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<NostrEvent> {
|
||||
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<NostrEvent> {
|
||||
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<DecryptedRumor | null> {
|
||||
// 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<void> {
|
||||
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<ConversationMetadata[]> {
|
||||
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<DecryptedRumor[]> {
|
||||
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<void> {
|
||||
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<GiftWrapEnvelope[]> {
|
||||
return db.giftWraps
|
||||
.where("[recipientPubkey+status]")
|
||||
.equals([recipientPubkey, "pending"])
|
||||
.toArray();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user