nostr-bray 1.26.0 → 1.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * NIP-17 gift-wrap helpers that work with any SigningContext (local keys OR remote bunker).
3
+ *
4
+ * nostr-tools' wrapEvent/unwrapEvent require raw private key bytes, which are
5
+ * unavailable when signing is delegated to a NIP-46 bunker. These helpers
6
+ * reimplement the NIP-59 gift-wrap flow using the SigningContext interface
7
+ * (nip44Encrypt, nip44Decrypt, getSigningFunction) so that both local-key
8
+ * and bunker modes work identically.
9
+ */
10
+ import type { Event as NostrEvent } from 'nostr-tools';
11
+ import type { SigningContext } from './signing-context.js';
12
+ interface Recipient {
13
+ publicKey: string;
14
+ relayUrl?: string;
15
+ }
16
+ /**
17
+ * Wrap a DM as a NIP-17 gift-wrapped event using the SigningContext.
18
+ *
19
+ * Flow (NIP-59):
20
+ * 1. Build unsigned rumor (kind 14) with sender pubkey + message
21
+ * 2. Create seal (kind 13): NIP-44 encrypt rumor with sender key, sign with sender key
22
+ * 3. Create gift wrap (kind 1059): NIP-44 encrypt seal with random ephemeral key
23
+ *
24
+ * The sender's private key is never accessed directly -- all crypto operations
25
+ * go through ctx.nip44Encrypt() and ctx.getSigningFunction(), which delegate
26
+ * to the bunker in NIP-46 mode.
27
+ */
28
+ export declare function wrapEventAsync(ctx: SigningContext, recipient: Recipient, message: string): Promise<NostrEvent>;
29
+ /**
30
+ * Unwrap a NIP-17 gift-wrapped event using the SigningContext.
31
+ *
32
+ * Flow:
33
+ * 1. Decrypt the gift wrap (kind 1059) content to get the seal
34
+ * 2. Decrypt the seal (kind 13) content to get the rumor
35
+ * 3. Return the rumor (the actual DM content)
36
+ */
37
+ export declare function unwrapEventAsync(ctx: SigningContext, wrap: NostrEvent): Promise<{
38
+ pubkey: string;
39
+ content: string;
40
+ created_at: number;
41
+ tags: string[][];
42
+ }>;
43
+ export {};
@@ -0,0 +1,91 @@
1
+ /**
2
+ * NIP-17 gift-wrap helpers that work with any SigningContext (local keys OR remote bunker).
3
+ *
4
+ * nostr-tools' wrapEvent/unwrapEvent require raw private key bytes, which are
5
+ * unavailable when signing is delegated to a NIP-46 bunker. These helpers
6
+ * reimplement the NIP-59 gift-wrap flow using the SigningContext interface
7
+ * (nip44Encrypt, nip44Decrypt, getSigningFunction) so that both local-key
8
+ * and bunker modes work identically.
9
+ */
10
+ import { generateSecretKey, finalizeEvent, getEventHash } from 'nostr-tools/pure';
11
+ const TWO_DAYS = 2 * 24 * 60 * 60;
12
+ const now = () => Math.round(Date.now() / 1000);
13
+ const randomNow = () => Math.round(now() - Math.random() * TWO_DAYS);
14
+ /** Build the unsigned kind-14 rumor (inner DM event). */
15
+ function createRumor(senderPubkeyHex, recipient, message) {
16
+ const recipients = Array.isArray(recipient) ? recipient : [recipient];
17
+ const tags = [];
18
+ for (const r of recipients) {
19
+ tags.push(r.relayUrl ? ['p', r.publicKey, r.relayUrl] : ['p', r.publicKey]);
20
+ }
21
+ const rumor = {
22
+ created_at: Math.ceil(Date.now() / 1000),
23
+ kind: 14,
24
+ tags,
25
+ content: message,
26
+ pubkey: senderPubkeyHex,
27
+ };
28
+ rumor.id = getEventHash(rumor);
29
+ return rumor;
30
+ }
31
+ /**
32
+ * Wrap a DM as a NIP-17 gift-wrapped event using the SigningContext.
33
+ *
34
+ * Flow (NIP-59):
35
+ * 1. Build unsigned rumor (kind 14) with sender pubkey + message
36
+ * 2. Create seal (kind 13): NIP-44 encrypt rumor with sender key, sign with sender key
37
+ * 3. Create gift wrap (kind 1059): NIP-44 encrypt seal with random ephemeral key
38
+ *
39
+ * The sender's private key is never accessed directly -- all crypto operations
40
+ * go through ctx.nip44Encrypt() and ctx.getSigningFunction(), which delegate
41
+ * to the bunker in NIP-46 mode.
42
+ */
43
+ export async function wrapEventAsync(ctx, recipient, message) {
44
+ const senderPubkeyHex = ctx.activePublicKeyHex;
45
+ // 1. Build the rumor (unsigned kind-14)
46
+ const rumor = createRumor(senderPubkeyHex, recipient, message);
47
+ // 2. Create the seal (kind 13) -- encrypted with sender's key, signed by sender
48
+ const sealContent = await ctx.nip44Encrypt(recipient.publicKey, JSON.stringify(rumor));
49
+ const sign = ctx.getSigningFunction();
50
+ const seal = await sign({
51
+ kind: 13,
52
+ content: sealContent,
53
+ created_at: randomNow(),
54
+ tags: [],
55
+ });
56
+ // 3. Create the gift wrap (kind 1059) -- encrypted with random ephemeral key
57
+ const randomSk = generateSecretKey();
58
+ const { getConversationKey, encrypt } = await import('nostr-tools/nip44');
59
+ const ck = getConversationKey(randomSk, recipient.publicKey);
60
+ const wrapContent = encrypt(JSON.stringify(seal), ck);
61
+ const wrap = finalizeEvent({
62
+ kind: 1059,
63
+ content: wrapContent,
64
+ created_at: randomNow(),
65
+ tags: [['p', recipient.publicKey]],
66
+ }, randomSk);
67
+ // Zeroise the ephemeral key
68
+ randomSk.fill(0);
69
+ return wrap;
70
+ }
71
+ /**
72
+ * Unwrap a NIP-17 gift-wrapped event using the SigningContext.
73
+ *
74
+ * Flow:
75
+ * 1. Decrypt the gift wrap (kind 1059) content to get the seal
76
+ * 2. Decrypt the seal (kind 13) content to get the rumor
77
+ * 3. Return the rumor (the actual DM content)
78
+ */
79
+ export async function unwrapEventAsync(ctx, wrap) {
80
+ // 1. Decrypt outer layer (gift wrap -> seal)
81
+ // The wrap was encrypted by a random ephemeral key; we decrypt using
82
+ // our key + the wrap's pubkey (the ephemeral key's public half).
83
+ const sealJson = await ctx.nip44Decrypt(wrap.pubkey, wrap.content);
84
+ const seal = JSON.parse(sealJson);
85
+ // 2. Decrypt inner layer (seal -> rumor)
86
+ // The seal was encrypted by the sender's key; we decrypt using
87
+ // our key + the seal's pubkey (the sender's public key).
88
+ const rumorJson = await ctx.nip44Decrypt(seal.pubkey, seal.content);
89
+ const rumor = JSON.parse(rumorJson);
90
+ return rumor;
91
+ }
package/dist/social/dm.js CHANGED
@@ -1,15 +1,19 @@
1
- import { wrapEvent } from 'nostr-tools/nip17';
2
1
  import * as nip04 from 'nostr-tools/nip04';
3
2
  import { decode, npubEncode } from 'nostr-tools/nip19';
4
3
  import { toAnnotation } from '../trust-context.js';
4
+ import { wrapEventAsync, unwrapEventAsync } from '../nip17-wrap.js';
5
5
  /** Send a DM. Uses NIP-17 gift wrap by default. NIP-04 only if explicitly requested and enabled. */
6
6
  export async function handleDmSend(ctx, pool, args) {
7
7
  if (args.nip04) {
8
8
  if (!args.nip04Enabled) {
9
9
  throw new Error('NIP-04 is not enabled. Set NIP04_ENABLED=1 to use legacy DMs.');
10
10
  }
11
- // NIP-04 legacy DM (kind 4)
12
- const encrypted = await nip04.encrypt(ctx.activePrivateKey, args.recipientPubkeyHex, args.message);
11
+ // NIP-04 legacy DM (kind 4) — requires local key (not available in bunker mode)
12
+ const localCtx = ctx;
13
+ if (!localCtx.activePrivateKey) {
14
+ throw new Error('NIP-04 is not supported in bunker mode. Use NIP-17 (the default) instead.');
15
+ }
16
+ const encrypted = await nip04.encrypt(localCtx.activePrivateKey, args.recipientPubkeyHex, args.message);
13
17
  const sign = ctx.getSigningFunction();
14
18
  const event = await sign({
15
19
  kind: 4,
@@ -34,7 +38,7 @@ export async function handleDmSend(ctx, pool, args) {
34
38
  }
35
39
  const recipientRelayHint = args.recipientRelay ?? recipientRelays[0];
36
40
  // Wrap for recipient
37
- const event = wrapEvent(ctx.activePrivateKey, { publicKey: args.recipientPubkeyHex, relayUrl: recipientRelayHint }, args.message);
41
+ const event = await wrapEventAsync(ctx, { publicKey: args.recipientPubkeyHex, relayUrl: recipientRelayHint }, args.message);
38
42
  // Publish to recipient's relays (primary) and sender's relays (fallback)
39
43
  let publish;
40
44
  if (recipientRelays.length > 0) {
@@ -47,10 +51,10 @@ export async function handleDmSend(ctx, pool, args) {
47
51
  publish = await pool.publish(ctx.activeNpub, event);
48
52
  }
49
53
  // Sender copy — wrap the same message addressed to ourselves so our client shows sent DMs.
50
- // The inner rumor's p-tag (set by wrapEvent) points to us, but clients use the
54
+ // The inner rumor's p-tag (set by wrapEventAsync) points to us, but clients use the
51
55
  // outer gift-wrap's p-tag to route, and the inner content to display.
52
56
  const senderHex = decode(ctx.activeNpub).data;
53
- const senderCopy = wrapEvent(ctx.activePrivateKey, { publicKey: senderHex, relayUrl: recipientRelayHint }, args.message);
57
+ const senderCopy = await wrapEventAsync(ctx, { publicKey: senderHex, relayUrl: recipientRelayHint }, args.message);
54
58
  const senderCopyPublish = await pool.publish(ctx.activeNpub, senderCopy);
55
59
  const result = { event, senderCopy, protocol: 'nip17', publish, senderCopyPublish };
56
60
  if (recipientRelays.length > 0 && publish.accepted.length === 0) {
@@ -97,8 +101,7 @@ async function decryptDmEvents(ctx, events) {
97
101
  if (event.kind === 1059) {
98
102
  // NIP-17 gift wrap — try to unwrap
99
103
  try {
100
- const { unwrapEvent } = await import('nostr-tools/nip17');
101
- const unwrapped = unwrapEvent(event, ctx.activePrivateKey);
104
+ const unwrapped = await unwrapEventAsync(ctx, event);
102
105
  results.push({
103
106
  id: event.id,
104
107
  from: unwrapped.pubkey,
@@ -120,9 +123,21 @@ async function decryptDmEvents(ctx, events) {
120
123
  }
121
124
  }
122
125
  else if (event.kind === 4) {
123
- // NIP-04 legacy DM
126
+ // NIP-04 legacy DM — needs local key; silently skip in bunker mode
127
+ const localCtx = ctx;
128
+ if (!localCtx.activePrivateKey) {
129
+ results.push({
130
+ id: event.id,
131
+ from: event.pubkey,
132
+ createdAt: event.created_at,
133
+ protocol: 'nip04-deprecated',
134
+ decrypted: false,
135
+ error: 'NIP-04 decryption not available in bunker mode',
136
+ });
137
+ continue;
138
+ }
124
139
  try {
125
- const decrypted = await nip04.decrypt(ctx.activePrivateKey, event.pubkey, event.content);
140
+ const decrypted = await nip04.decrypt(localCtx.activePrivateKey, event.pubkey, event.content);
126
141
  results.push({
127
142
  id: event.id,
128
143
  from: event.pubkey,
@@ -1,10 +1,10 @@
1
1
  import { createAttestation, createRevocation } from 'nostr-attestations';
2
2
  import { validateAttestation } from 'nostr-attestations';
3
3
  import { attestationFilter } from 'nostr-attestations';
4
- import { wrapEvent } from 'nostr-tools/nip17';
5
4
  import { toUnsignedEvent } from 'nsec-tree/event';
6
5
  import { decode } from 'nostr-tools/nip19';
7
6
  import { hasExtendedIdentity } from '../signing-context.js';
7
+ import { wrapEventAsync } from '../nip17-wrap.js';
8
8
  /** Create and publish a kind 31000 attestation */
9
9
  export async function handleTrustAttest(ctx, pool, args) {
10
10
  if (!args.type && !args.assertionId) {
@@ -88,7 +88,7 @@ export async function handleTrustRequest(ctx, pool, args) {
88
88
  attestation_type: args.attestationType,
89
89
  message: args.message,
90
90
  });
91
- const event = wrapEvent(ctx.activePrivateKey, { publicKey: args.recipientPubkeyHex }, payload);
91
+ const event = await wrapEventAsync(ctx, { publicKey: args.recipientPubkeyHex }, payload);
92
92
  const publish = await pool.publish(ctx.activeNpub, event);
93
93
  return { event, publish };
94
94
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nostr-bray",
3
- "version": "1.26.0",
3
+ "version": "1.26.1",
4
4
  "description": "Trust-aware Nostr for AI and humans. Three dimensions of trust -- verification, proximity, and access -- woven into every interaction.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",