Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/very-very-secure-inner-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@contextvm/sdk": patch
---

fix(nostr): verify signatures for decrypted and unencrypted events and dedupe inner event ids
32 changes: 32 additions & 0 deletions src/transport/nostr-client-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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',
Comment thread
1amKhush marked this conversation as resolved.
{
innerEventId: nostrEvent.id,
innerPubkey: nostrEvent.pubkey,
outerEventId: event.id,
},
);
return;
}
} catch (decryptError) {
this.logger.error('Failed to decrypt gift-wrapped event', {
error:
Expand Down Expand Up @@ -631,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');
Expand Down
82 changes: 48 additions & 34 deletions src/transport/nostr-server-transport.dedup-response.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 = {
Expand All @@ -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);
},
};

Expand All @@ -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),
};
Expand All @@ -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),
};
Expand All @@ -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: {},
});
Expand All @@ -227,35 +235,41 @@ 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 = {
id: 'gw-ephemeral',
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),
};
Expand Down
159 changes: 159 additions & 0 deletions src/transport/nostr-server-transport.inner-event-verification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
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 valid id but garbage signature.
*/
function createForgedInnerEvent(
secretKey: Uint8Array,
content: string,
serverPubkey: string,
): NostrEvent {
const valid = createValidInnerEvent(secretKey, content, serverPubkey);
return {
...valid,
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 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.
const onmessageSpy = mock(() => {});
transport.onmessage = onmessageSpy;

// Forge an inner event with a whitelisted pubkey but garbage signature.
const forgedInner = createForgedInnerEvent(
whitelistedSk,
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);
});
});
Loading
Loading