From 9856c7976e4b72b225569493d1f8affad64c6428 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Thu, 30 Apr 2026 22:23:39 +0530 Subject: [PATCH 1/3] fix: enforce inner event signature verification --- src/transport/nostr-client-transport.ts | 16 ++ ...tr-server-transport.dedup-response.test.ts | 82 +++++---- ...transport.inner-event-verification.test.ts | 160 ++++++++++++++++++ src/transport/nostr-server-transport.ts | 16 ++ .../nostr-transport-deduplication.test.ts | 91 +++++++--- 5 files changed, 306 insertions(+), 59 deletions(-) create mode 100644 src/transport/nostr-server-transport.inner-event-verification.test.ts diff --git a/src/transport/nostr-client-transport.ts b/src/transport/nostr-client-transport.ts index b572e33..812fdbd 100644 --- a/src/transport/nostr-client-transport.ts +++ b/src/transport/nostr-client-transport.ts @@ -32,6 +32,7 @@ import { } from './base-nostr-transport.js'; import { getNostrEventTag } from '../core/utils/serializers.js'; import { NostrEvent } from 'nostr-tools'; +import { verifyEvent } from 'nostr-tools/pure'; import { LogLevel } from '../core/utils/logger.js'; import { EncryptionMode, GiftWrapMode } from '../core/interfaces.js'; import { @@ -602,6 +603,21 @@ export class NostrClientTransport 'Decrypt message timed out', ); nostrEvent = JSON.parse(decryptedContent) as NostrEvent; + + // Verify the inner event's cryptographic signature to prevent + // identity forgery. Without this check an attacker can place the + // server's pubkey inside the plaintext and spoof responses. (Fixes #64) + if (!verifyEvent(nostrEvent)) { + this.logger.error( + 'Rejecting decrypted inner event with invalid signature', + { + innerEventId: nostrEvent.id, + innerPubkey: nostrEvent.pubkey, + outerEventId: event.id, + }, + ); + return; + } } catch (decryptError) { this.logger.error('Failed to decrypt gift-wrapped event', { error: diff --git a/src/transport/nostr-server-transport.dedup-response.test.ts b/src/transport/nostr-server-transport.dedup-response.test.ts index f8a30fc..3c96bc1 100644 --- a/src/transport/nostr-server-transport.dedup-response.test.ts +++ b/src/transport/nostr-server-transport.dedup-response.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, mock } from 'bun:test'; import type { RelayHandler } from '../core/interfaces.js'; import type { NostrEvent } from 'nostr-tools'; +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; import type { JSONRPCResponse } from '@modelcontextprotocol/sdk/types.js'; import { NostrServerTransport } from './nostr-server-transport.js'; import { PrivateKeySigner } from '../signer/private-key-signer.js'; @@ -154,7 +155,27 @@ describe.serial('NostrServerTransport duplicate response prevention', () => { const onmessage = mock(() => {}); transport.onmessage = onmessage; - // Make decryptMessage deterministically return the same inner event id for both envelopes. + // Create a cryptographically valid inner event so verifyEvent passes. + const clientSk = generateSecretKey(); + const serverPubkey = getPublicKey( + Uint8Array.from(Buffer.from('1'.repeat(64), 'hex')), + ); + const validInner = finalizeEvent( + { + kind: 25910, + created_at: 1, + tags: [['p', serverPubkey]], + content: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {}, + }), + }, + clientSk, + ); + + // Make decryptMessage deterministically return the same valid inner event for both envelopes. const signer = transport['signer']; let decryptCalls = 0; signer.nip44 = { @@ -163,20 +184,7 @@ describe.serial('NostrServerTransport duplicate response prevention', () => { }, decrypt: async () => { decryptCalls += 1; - return JSON.stringify({ - id: 'inner-request-id', - kind: 25910, - pubkey: 'c'.repeat(64), - created_at: 1, - tags: [['p', 's'.repeat(64)]], - content: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'tools/list', - params: {}, - }), - sig: '0'.repeat(128), - } satisfies NostrEvent); + return JSON.stringify(validInner); }, }; @@ -186,7 +194,7 @@ describe.serial('NostrServerTransport duplicate response prevention', () => { kind: GIFT_WRAP_KIND, pubkey: 'a'.repeat(64), created_at: 1, - tags: [['p', 's'.repeat(64)]], + tags: [['p', serverPubkey]], content: 'ciphertext-1', sig: '0'.repeat(128), }; @@ -195,7 +203,7 @@ describe.serial('NostrServerTransport duplicate response prevention', () => { kind: GIFT_WRAP_KIND, pubkey: 'b'.repeat(64), created_at: 1, - tags: [['p', 's'.repeat(64)]], + tags: [['p', serverPubkey]], content: 'ciphertext-2', sig: '0'.repeat(128), }; @@ -209,7 +217,7 @@ describe.serial('NostrServerTransport duplicate response prevention', () => { expect(onmessage).toHaveBeenCalledTimes(1); expect(onmessage).toHaveBeenCalledWith({ jsonrpc: '2.0', - id: 'inner-request-id', + id: validInner.id, method: 'tools/list', params: {}, }); @@ -227,27 +235,33 @@ describe.serial('NostrServerTransport duplicate response prevention', () => { encryptionMode: EncryptionMode.REQUIRED, }); + // Create a cryptographically valid inner event so verifyEvent passes. + const clientSk = generateSecretKey(); + const serverPubkey = getPublicKey( + Uint8Array.from(Buffer.from('1'.repeat(64), 'hex')), + ); + const validInner = finalizeEvent( + { + kind: 25910, + created_at: 1, + tags: [['p', serverPubkey]], + content: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {}, + }), + }, + clientSk, + ); + // Deterministic decrypt. const signer = transport['signer']; signer.nip44 = { encrypt: async () => { throw new Error('encrypt not used in this test'); }, - decrypt: async () => - JSON.stringify({ - id: 'inner-request-id-2', - kind: 25910, - pubkey: 'c'.repeat(64), - created_at: 1, - tags: [['p', 's'.repeat(64)]], - content: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'tools/list', - params: {}, - }), - sig: '0'.repeat(128), - } satisfies NostrEvent), + decrypt: async () => JSON.stringify(validInner), }; const gw: NostrEvent = { @@ -255,7 +269,7 @@ describe.serial('NostrServerTransport duplicate response prevention', () => { kind: EPHEMERAL_GIFT_WRAP_KIND, pubkey: 'a'.repeat(64), created_at: 1, - tags: [['p', 's'.repeat(64)]], + tags: [['p', serverPubkey]], content: 'ciphertext', sig: '0'.repeat(128), }; diff --git a/src/transport/nostr-server-transport.inner-event-verification.test.ts b/src/transport/nostr-server-transport.inner-event-verification.test.ts new file mode 100644 index 0000000..d911c62 --- /dev/null +++ b/src/transport/nostr-server-transport.inner-event-verification.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, mock } from 'bun:test'; +import type { RelayHandler } from '../core/interfaces.js'; +import type { NostrEvent } from 'nostr-tools'; +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { NostrServerTransport } from './nostr-server-transport.js'; +import { PrivateKeySigner } from '../signer/private-key-signer.js'; +import { EncryptionMode } from '../core/interfaces.js'; +import { GIFT_WRAP_KIND } from '../core/constants.js'; + +function makeNoopRelayHandler(): RelayHandler { + return { + async connect() {}, + async disconnect() {}, + async publish() {}, + async subscribe() { + return () => {}; + }, + } as unknown as RelayHandler; +} + +/** + * Helper: creates a cryptographically valid inner event using a real keypair. + */ +function createValidInnerEvent( + secretKey: Uint8Array, + content: string, + serverPubkey: string, +): NostrEvent { + return finalizeEvent( + { + kind: 25910, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', serverPubkey]], + content, + }, + secretKey, + ); +} + +/** + * Helper: creates a forged inner event with a spoofed pubkey and garbage sig. + */ +function createForgedInnerEvent( + spoofedPubkey: string, + content: string, + serverPubkey: string, +): NostrEvent { + return { + id: 'forged-id-' + Math.random().toString(36).slice(2), + kind: 25910, + pubkey: spoofedPubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', serverPubkey]], + content, + sig: '0'.repeat(128), + }; +} + +describe.serial('Inner event signature verification (fixes #64)', () => { + it('rejects a decrypted inner event with an invalid signature', async () => { + const serverSk = generateSecretKey(); + const serverPubkey = getPublicKey(serverSk); + + const transport = new NostrServerTransport({ + signer: new PrivateKeySigner(Buffer.from(serverSk).toString('hex')), + relayHandler: makeNoopRelayHandler(), + encryptionMode: EncryptionMode.REQUIRED, + }); + + // Track onmessage calls — should never fire for a forged event. + const onmessageSpy = mock(() => {}); + transport.onmessage = onmessageSpy; + + // Forge an inner event with a whitelisted pubkey but garbage signature. + const whitelistedPubkey = 'c'.repeat(64); + const forgedInner = createForgedInnerEvent( + whitelistedPubkey, + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {}, + }), + serverPubkey, + ); + + // Stub decryption to return the forged inner event. + const signer = transport['signer']; + signer.nip44 = { + encrypt: async () => { + throw new Error('encrypt not used'); + }, + decrypt: async () => JSON.stringify(forgedInner), + }; + + const gw: NostrEvent = { + id: 'gw-forged', + kind: GIFT_WRAP_KIND, + pubkey: 'a'.repeat(64), + created_at: 1, + tags: [['p', serverPubkey]], + content: 'ciphertext', + sig: '0'.repeat(128), + }; + + await transport['processIncomingEvent'](gw); + + // The forged event must be rejected — onmessage should never be called. + expect(onmessageSpy).not.toHaveBeenCalled(); + }); + + it('accepts a decrypted inner event with a valid signature', async () => { + const serverSk = generateSecretKey(); + const serverPubkey = getPublicKey(serverSk); + + const transport = new NostrServerTransport({ + signer: new PrivateKeySigner(Buffer.from(serverSk).toString('hex')), + relayHandler: makeNoopRelayHandler(), + encryptionMode: EncryptionMode.REQUIRED, + }); + + // Create a legitimate inner event with a real key. + const clientSk = generateSecretKey(); + const validInner = createValidInnerEvent( + clientSk, + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {}, + }), + serverPubkey, + ); + + // Stub decryption to return the valid inner event. + const signer = transport['signer']; + signer.nip44 = { + encrypt: async () => { + throw new Error('encrypt not used'); + }, + decrypt: async () => JSON.stringify(validInner), + }; + + const gw: NostrEvent = { + id: 'gw-valid', + kind: GIFT_WRAP_KIND, + pubkey: 'a'.repeat(64), + created_at: 1, + tags: [['p', serverPubkey]], + content: 'ciphertext', + sig: '0'.repeat(128), + }; + + await transport['processIncomingEvent'](gw); + + // The valid event should be processed — check correlation store has the route. + const state = transport.getInternalStateForTesting(); + expect(state.correlationStore.eventRouteCount).toBe(1); + }); +}); diff --git a/src/transport/nostr-server-transport.ts b/src/transport/nostr-server-transport.ts index c549f86..992b6e1 100644 --- a/src/transport/nostr-server-transport.ts +++ b/src/transport/nostr-server-transport.ts @@ -30,6 +30,7 @@ import { } from '../core/index.js'; import { EncryptionMode, GiftWrapMode } from '../core/interfaces.js'; import { NostrEvent } from 'nostr-tools'; +import { verifyEvent } from 'nostr-tools/pure'; import { LogLevel } from '../core/utils/logger.js'; import { injectClientPubkey, withTimeout } from '../core/utils/utils.js'; import { CorrelationStore } from './nostr-server/correlation-store.js'; @@ -882,6 +883,21 @@ export class NostrServerTransport ); const currentEvent = JSON.parse(decryptedJson) as NostrEvent; + // Verify the inner event's cryptographic signature to prevent identity + // forgery. Without this check an attacker can place any pubkey inside + // the plaintext and bypass allowlists. (Fixes #64) + if (!verifyEvent(currentEvent)) { + this.logger.error( + 'Rejecting decrypted inner event with invalid signature', + { + innerEventId: currentEvent.id, + innerPubkey: currentEvent.pubkey, + outerEventId: event.id, + }, + ); + return; + } + // Deduplicate decrypted inner events before authorization and dispatch. if (this.seenEventIds.has(currentEvent.id)) { this.logger.debug('Skipping duplicate decrypted inner event', { diff --git a/src/transport/nostr-transport-deduplication.test.ts b/src/transport/nostr-transport-deduplication.test.ts index 3b0b8e2..e8ad80d 100644 --- a/src/transport/nostr-transport-deduplication.test.ts +++ b/src/transport/nostr-transport-deduplication.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'bun:test'; import { EncryptionMode, type RelayHandler } from '../core/interfaces.js'; import type { NostrEvent } from 'nostr-tools'; +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; import { NostrClientTransport } from './nostr-client-transport.js'; import { PrivateKeySigner } from '../signer/private-key-signer.js'; import { EPHEMERAL_GIFT_WRAP_KIND, GIFT_WRAP_KIND } from '../core/constants.js'; @@ -8,32 +9,48 @@ import { NostrServerTransport } from './nostr-server-transport.js'; let decryptCallCount = 0; -function installDeterministicDecrypt(transport: { - signer: { - nip44: { - encrypt: (plaintext: string, pubkey: string) => Promise; - decrypt: (ciphertext: string, pubkey: string) => Promise; +/** + * Creates a cryptographically valid inner event so verifyEvent passes. + * When signerSk is provided, the event is signed by that key (useful for + * client tests where the inner event must come from the server keypair). + */ +function createValidInnerEvent( + serverPubkey: string, + signerSk?: Uint8Array, +): NostrEvent { + const sk = signerSk ?? generateSecretKey(); + return finalizeEvent( + { + kind: 25910, + created_at: 1, + tags: [['p', serverPubkey]], + content: JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/test', + }), + }, + sk, + ); +} + +function installDeterministicDecrypt( + transport: { + signer: { + nip44: { + encrypt: (plaintext: string, pubkey: string) => Promise; + decrypt: (ciphertext: string, pubkey: string) => Promise; + }; }; - }; -}): void { + }, + innerEvent: NostrEvent, +): void { transport.signer.nip44 = { encrypt: async () => { throw new Error('encrypt not used in this test'); }, decrypt: async () => { decryptCallCount += 1; - return JSON.stringify({ - id: 'inner-event-id', - kind: 25910, - pubkey: '0'.repeat(64), - created_at: 1, - tags: [], - content: JSON.stringify({ - jsonrpc: '2.0', - method: 'notifications/test', - }), - sig: '0'.repeat(128), - } satisfies NostrEvent); + return JSON.stringify(innerEvent); }, }; } @@ -53,7 +70,9 @@ describe('gift-wrap pre-decrypt deduplication', () => { test('client: decrypts only once for duplicate gift-wrap deliveries', async () => { decryptCallCount = 0; - const serverPubkey = '0'.repeat(64); + // Use a real server keypair so the inner event pubkey matches serverPubkey. + const serverSk = generateSecretKey(); + const serverPubkey = getPublicKey(serverSk); const clientPriv = '1'.repeat(64); const transport = new NostrClientTransport({ @@ -62,6 +81,9 @@ describe('gift-wrap pre-decrypt deduplication', () => { serverPubkey, encryptionMode: EncryptionMode.REQUIRED, }); + + // Sign the inner event with the server's key so pubkey matches. + const innerEvent = createValidInnerEvent(serverPubkey, serverSk); installDeterministicDecrypt( transport as unknown as { signer: { @@ -71,6 +93,7 @@ describe('gift-wrap pre-decrypt deduplication', () => { }; }; }, + innerEvent, ); const received: unknown[] = []; @@ -81,7 +104,7 @@ describe('gift-wrap pre-decrypt deduplication', () => { kind: GIFT_WRAP_KIND, pubkey: 'f'.repeat(64), created_at: 1, - tags: [['p', '0'.repeat(64)]], + tags: [['p', serverPubkey]], content: 'ciphertext', sig: 'f'.repeat(128), }; @@ -96,7 +119,9 @@ describe('gift-wrap pre-decrypt deduplication', () => { test('client: decrypts ephemeral gift wrap kind as well', async () => { decryptCallCount = 0; - const serverPubkey = '0'.repeat(64); + // Use a real server keypair so the inner event pubkey matches serverPubkey. + const serverSk = generateSecretKey(); + const serverPubkey = getPublicKey(serverSk); const clientPriv = '1'.repeat(64); const transport = new NostrClientTransport({ @@ -105,6 +130,9 @@ describe('gift-wrap pre-decrypt deduplication', () => { serverPubkey, encryptionMode: EncryptionMode.REQUIRED, }); + + // Sign the inner event with the server's key so pubkey matches. + const innerEvent = createValidInnerEvent(serverPubkey, serverSk); installDeterministicDecrypt( transport as unknown as { signer: { @@ -114,6 +142,7 @@ describe('gift-wrap pre-decrypt deduplication', () => { }; }; }, + innerEvent, ); const received: unknown[] = []; @@ -124,7 +153,7 @@ describe('gift-wrap pre-decrypt deduplication', () => { kind: EPHEMERAL_GIFT_WRAP_KIND, pubkey: 'f'.repeat(64), created_at: 1, - tags: [['p', '0'.repeat(64)]], + tags: [['p', serverPubkey]], content: 'ciphertext', sig: 'f'.repeat(128), }; @@ -139,11 +168,16 @@ describe('gift-wrap pre-decrypt deduplication', () => { decryptCallCount = 0; const serverPriv = '2'.repeat(64); + const serverPubkey = getPublicKey( + Uint8Array.from(Buffer.from(serverPriv, 'hex')), + ); const transport = new NostrServerTransport({ signer: new PrivateKeySigner(serverPriv), relayHandler: makeNoopRelayHandler(), encryptionMode: EncryptionMode.REQUIRED, }); + + const innerEvent = createValidInnerEvent(serverPubkey); installDeterministicDecrypt( transport as unknown as { signer: { @@ -153,6 +187,7 @@ describe('gift-wrap pre-decrypt deduplication', () => { }; }; }, + innerEvent, ); const received: unknown[] = []; @@ -163,7 +198,7 @@ describe('gift-wrap pre-decrypt deduplication', () => { kind: GIFT_WRAP_KIND, pubkey: 'e'.repeat(64), created_at: 1, - tags: [['p', '0'.repeat(64)]], + tags: [['p', serverPubkey]], content: 'ciphertext', sig: 'e'.repeat(128), }; @@ -180,11 +215,16 @@ describe('gift-wrap pre-decrypt deduplication', () => { decryptCallCount = 0; const serverPriv = '2'.repeat(64); + const serverPubkey = getPublicKey( + Uint8Array.from(Buffer.from(serverPriv, 'hex')), + ); const transport = new NostrServerTransport({ signer: new PrivateKeySigner(serverPriv), relayHandler: makeNoopRelayHandler(), encryptionMode: EncryptionMode.REQUIRED, }); + + const innerEvent = createValidInnerEvent(serverPubkey); installDeterministicDecrypt( transport as unknown as { signer: { @@ -194,6 +234,7 @@ describe('gift-wrap pre-decrypt deduplication', () => { }; }; }, + innerEvent, ); const received: unknown[] = []; @@ -204,7 +245,7 @@ describe('gift-wrap pre-decrypt deduplication', () => { kind: EPHEMERAL_GIFT_WRAP_KIND, pubkey: 'e'.repeat(64), created_at: 1, - tags: [['p', '0'.repeat(64)]], + tags: [['p', serverPubkey]], content: 'ciphertext', sig: 'e'.repeat(128), }; From 762bab08570dc70eb829d4d4608ee938fbe8389d Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Sun, 3 May 2026 16:16:47 +0530 Subject: [PATCH 2/3] Added patch changeset --- .changeset/very-very-secure-inner-events.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/very-very-secure-inner-events.md diff --git a/.changeset/very-very-secure-inner-events.md b/.changeset/very-very-secure-inner-events.md new file mode 100644 index 0000000..1b66738 --- /dev/null +++ b/.changeset/very-very-secure-inner-events.md @@ -0,0 +1,5 @@ +--- +"@contextvm/sdk": patch +--- + +fix(nostr): verify decrypted inner event signatures, dedupe inner event ids, and drop uncorrelated payment_required notifications From 8e7ed3fc82e2eb2f66a5761996560838be5084f1 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Sun, 3 May 2026 16:34:44 +0530 Subject: [PATCH 3/3] fix: verify unencrypted event signatures --- .changeset/very-very-secure-inner-events.md | 2 +- src/transport/nostr-client-transport.ts | 16 ++++++++++++++++ ...transport.inner-event-verification.test.ts | 19 +++++++++---------- src/transport/nostr-server-transport.ts | 7 +++++++ 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/.changeset/very-very-secure-inner-events.md b/.changeset/very-very-secure-inner-events.md index 1b66738..148d1ad 100644 --- a/.changeset/very-very-secure-inner-events.md +++ b/.changeset/very-very-secure-inner-events.md @@ -2,4 +2,4 @@ "@contextvm/sdk": patch --- -fix(nostr): verify decrypted inner event signatures, dedupe inner event ids, and drop uncorrelated payment_required notifications +fix(nostr): verify signatures for decrypted and unencrypted events and dedupe inner event ids diff --git a/src/transport/nostr-client-transport.ts b/src/transport/nostr-client-transport.ts index 812fdbd..3a44d98 100644 --- a/src/transport/nostr-client-transport.ts +++ b/src/transport/nostr-client-transport.ts @@ -647,6 +647,22 @@ export class NostrClientTransport return; } + if ( + event.kind !== GIFT_WRAP_KIND && + event.kind !== EPHEMERAL_GIFT_WRAP_KIND + ) { + if (!verifyEvent(nostrEvent)) { + this.logger.error( + 'Rejecting unencrypted event with invalid signature', + { + eventId: nostrEvent.id, + pubkey: nostrEvent.pubkey, + }, + ); + return; + } + } + this.learnServerDiscovery(nostrEvent); const eTag = getNostrEventTag(nostrEvent.tags, 'e'); diff --git a/src/transport/nostr-server-transport.inner-event-verification.test.ts b/src/transport/nostr-server-transport.inner-event-verification.test.ts index d911c62..6681634 100644 --- a/src/transport/nostr-server-transport.inner-event-verification.test.ts +++ b/src/transport/nostr-server-transport.inner-event-verification.test.ts @@ -38,20 +38,16 @@ function createValidInnerEvent( } /** - * Helper: creates a forged inner event with a spoofed pubkey and garbage sig. + * Helper: creates a forged inner event with a valid id but garbage signature. */ function createForgedInnerEvent( - spoofedPubkey: string, + secretKey: Uint8Array, content: string, serverPubkey: string, ): NostrEvent { + const valid = createValidInnerEvent(secretKey, content, serverPubkey); return { - id: 'forged-id-' + Math.random().toString(36).slice(2), - kind: 25910, - pubkey: spoofedPubkey, - created_at: Math.floor(Date.now() / 1000), - tags: [['p', serverPubkey]], - content, + ...valid, sig: '0'.repeat(128), }; } @@ -61,10 +57,14 @@ describe.serial('Inner event signature verification (fixes #64)', () => { const serverSk = generateSecretKey(); const serverPubkey = getPublicKey(serverSk); + const whitelistedSk = generateSecretKey(); + const whitelistedPubkey = getPublicKey(whitelistedSk); + const transport = new NostrServerTransport({ signer: new PrivateKeySigner(Buffer.from(serverSk).toString('hex')), relayHandler: makeNoopRelayHandler(), encryptionMode: EncryptionMode.REQUIRED, + allowedPublicKeys: [whitelistedPubkey], }); // Track onmessage calls — should never fire for a forged event. @@ -72,9 +72,8 @@ describe.serial('Inner event signature verification (fixes #64)', () => { transport.onmessage = onmessageSpy; // Forge an inner event with a whitelisted pubkey but garbage signature. - const whitelistedPubkey = 'c'.repeat(64); const forgedInner = createForgedInnerEvent( - whitelistedPubkey, + whitelistedSk, JSON.stringify({ jsonrpc: '2.0', id: 1, diff --git a/src/transport/nostr-server-transport.ts b/src/transport/nostr-server-transport.ts index 992b6e1..33ee187 100644 --- a/src/transport/nostr-server-transport.ts +++ b/src/transport/nostr-server-transport.ts @@ -935,6 +935,13 @@ export class NostrServerTransport ); return; } + if (!verifyEvent(event)) { + this.logger.error('Rejecting unencrypted event with invalid signature', { + eventId: event.id, + pubkey: event.pubkey, + }); + return; + } await this.authorizeAndProcessEvent(event, false); }