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:
Claude
2026-01-15 21:01:26 +00:00
parent d172d67584
commit 331777012c
6 changed files with 1142 additions and 0 deletions

View File

@@ -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,
]);
}

View File

@@ -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]",
});
}
}

View 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;

View 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
View 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();
}

View File

@@ -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;
}