@signet-auth/mcp-server 0.3.0

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 @@
1
+ export { verifyRequest, type VerifyOptions, type VerifyResult } from './verify-request.js';
@@ -0,0 +1 @@
1
+ export { verifyRequest } from './verify-request.js';
@@ -0,0 +1,20 @@
1
+ export interface VerifyOptions {
2
+ /** List of trusted "ed25519:<base64>" pubkeys.
3
+ * If omitted and requireSignature=true, ALL signed requests are rejected. */
4
+ trustedKeys?: string[];
5
+ /** Reject unsigned requests. Default: true. */
6
+ requireSignature?: boolean;
7
+ /** Max age of receipt in seconds. Default: 300 (5 min). */
8
+ maxAge?: number;
9
+ /** If set, receipt.action.target must match this value. */
10
+ expectedTarget?: string;
11
+ }
12
+ export interface VerifyResult {
13
+ ok: boolean;
14
+ signerName?: string;
15
+ signerPubkey?: string;
16
+ error?: string;
17
+ }
18
+ export declare function verifyRequest(request: {
19
+ params?: Record<string, unknown>;
20
+ }, options: VerifyOptions): VerifyResult;
@@ -0,0 +1,84 @@
1
+ import { verify } from '@signet-auth/core';
2
+ const CLOCK_SKEW_TOLERANCE_MS = 30_000; // 30 seconds
3
+ export function verifyRequest(request, options) {
4
+ const requireSig = options.requireSignature ?? true;
5
+ const maxAgeMs = (options.maxAge ?? 300) * 1000;
6
+ // 1. Extract _meta._signet
7
+ const meta = request.params?._meta;
8
+ const signet = meta?._signet;
9
+ // 2. Check presence
10
+ if (!signet) {
11
+ if (requireSig) {
12
+ return { ok: false, error: 'unsigned request' };
13
+ }
14
+ return { ok: true };
15
+ }
16
+ // 3. Validate receipt shape
17
+ const s = signet;
18
+ if (!s['v'] || !s['sig'] || !s['action'] || !s['signer'] || !s['ts']) {
19
+ return { ok: false, error: 'malformed receipt' };
20
+ }
21
+ const receipt = signet;
22
+ // 4. Verify signature
23
+ // receipt.signer.pubkey has "ed25519:" prefix; verify() takes bare base64
24
+ const prefixedPubkey = receipt.signer.pubkey;
25
+ const barePubkey = prefixedPubkey.startsWith('ed25519:')
26
+ ? prefixedPubkey.slice('ed25519:'.length)
27
+ : prefixedPubkey;
28
+ try {
29
+ const valid = verify(receipt, barePubkey);
30
+ if (!valid) {
31
+ return { ok: false, error: 'invalid signature' };
32
+ }
33
+ }
34
+ catch {
35
+ return { ok: false, error: 'invalid signature' };
36
+ }
37
+ // 5. Check trusted keys (using prefixed format to match receipt.signer.pubkey)
38
+ const trustedKeys = options.trustedKeys ?? [];
39
+ if (!trustedKeys.includes(prefixedPubkey)) {
40
+ return { ok: false, error: `untrusted signer: ${prefixedPubkey}` };
41
+ }
42
+ // 6. Check freshness (ts is guaranteed by shape check above)
43
+ const receiptTime = new Date(receipt.ts).getTime();
44
+ const now = Date.now();
45
+ if (isNaN(receiptTime)) {
46
+ return { ok: false, error: 'invalid receipt timestamp' };
47
+ }
48
+ if (receiptTime < now - maxAgeMs) {
49
+ return { ok: false, error: 'receipt too old' };
50
+ }
51
+ if (receiptTime > now + CLOCK_SKEW_TOLERANCE_MS) {
52
+ return { ok: false, error: 'receipt from future' };
53
+ }
54
+ // 7. Check target
55
+ if (options.expectedTarget && receipt.action.target !== options.expectedTarget) {
56
+ return {
57
+ ok: false,
58
+ error: `target mismatch: expected ${options.expectedTarget}, got ${receipt.action.target}`,
59
+ };
60
+ }
61
+ // 8. Anti-staple: receipt.action.tool must match request.params.name
62
+ const requestTool = request.params?.['name'];
63
+ if (requestTool !== undefined && receipt.action.tool !== requestTool) {
64
+ return {
65
+ ok: false,
66
+ error: `tool mismatch: receipt signed for "${receipt.action.tool}", request is for "${requestTool}"`,
67
+ };
68
+ }
69
+ // 9. Anti-staple: receipt.action.params must match request.params.arguments
70
+ const requestArgs = request.params?.['arguments'];
71
+ if (requestArgs !== undefined || receipt.action.params !== undefined) {
72
+ const signedParams = JSON.stringify(receipt.action.params ?? null);
73
+ const actualParams = JSON.stringify(requestArgs ?? null);
74
+ if (signedParams !== actualParams) {
75
+ return { ok: false, error: 'params mismatch: signed params differ from request arguments' };
76
+ }
77
+ }
78
+ // All checks pass
79
+ return {
80
+ ok: true,
81
+ signerName: receipt.signer.name,
82
+ signerPubkey: receipt.signer.pubkey,
83
+ };
84
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,179 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { generateKeypair, sign } from '@signet-auth/core';
4
+ import { verifyRequest } from '../src/index.js';
5
+ describe('@signet-auth/mcp-server verifyRequest', () => {
6
+ const kp = generateKeypair();
7
+ const kp2 = generateKeypair();
8
+ function makeAction(tool, args) {
9
+ return {
10
+ tool,
11
+ params: args,
12
+ params_hash: '',
13
+ target: 'mcp://test-server',
14
+ transport: 'stdio',
15
+ };
16
+ }
17
+ function signedRequest(tool, args) {
18
+ const action = makeAction(tool, args);
19
+ const receipt = sign(kp.secretKey, action, 'test-agent', 'owner');
20
+ return {
21
+ params: {
22
+ name: tool,
23
+ arguments: args,
24
+ _meta: { _signet: receipt },
25
+ },
26
+ };
27
+ }
28
+ // Use prefixed format (matching receipt.signer.pubkey) for trustedKeys
29
+ function trustedKey(receipt) {
30
+ return receipt.signer.pubkey;
31
+ }
32
+ it('test_verify_valid_signature — trusted key, valid sig → ok: true', () => {
33
+ const req = signedRequest('echo', { message: 'hello' });
34
+ const receipt = (req.params._meta._signet);
35
+ const result = verifyRequest(req, { trustedKeys: [trustedKey(receipt)] });
36
+ assert.strictEqual(result.ok, true, `Expected ok: true, got error: ${result.error}`);
37
+ });
38
+ it('test_verify_untrusted_key — valid sig but key not in trustedKeys → ok: false', () => {
39
+ const req = signedRequest('echo', { message: 'hello' });
40
+ // Use kp2 pubkey (prefixed) as trusted — won't match kp's pubkey
41
+ const fakeReceipt = sign(kp2.secretKey, makeAction('echo', {}), 'other', 'owner');
42
+ const result = verifyRequest(req, { trustedKeys: [trustedKey(fakeReceipt)] });
43
+ assert.strictEqual(result.ok, false);
44
+ assert(result.error?.includes('untrusted'), `Expected 'untrusted' in error, got: ${result.error}`);
45
+ });
46
+ it('test_verify_invalid_signature — tampered _signet.sig → ok: false', () => {
47
+ const req = signedRequest('echo', { message: 'hello' });
48
+ const receipt = req.params._meta._signet;
49
+ // Tamper with the signature
50
+ const tamperedReceipt = { ...receipt, sig: 'ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' };
51
+ const tamperedReq = {
52
+ params: {
53
+ ...req.params,
54
+ _meta: { _signet: tamperedReceipt },
55
+ },
56
+ };
57
+ const result = verifyRequest(tamperedReq, { trustedKeys: [receipt.signer.pubkey] });
58
+ assert.strictEqual(result.ok, false);
59
+ assert(result.error?.includes('invalid signature'), `Expected 'invalid signature', got: ${result.error}`);
60
+ });
61
+ it('test_verify_unsigned_required — no _signet + requireSignature=true → ok: false', () => {
62
+ const req = { params: { name: 'echo', arguments: { message: 'hello' } } };
63
+ const result = verifyRequest(req, { requireSignature: true, trustedKeys: [] });
64
+ assert.strictEqual(result.ok, false);
65
+ assert(result.error?.includes('unsigned'), `Expected 'unsigned' in error, got: ${result.error}`);
66
+ });
67
+ it('test_verify_unsigned_optional — no _signet + requireSignature=false → ok: true', () => {
68
+ const req = { params: { name: 'echo', arguments: { message: 'hello' } } };
69
+ const result = verifyRequest(req, { requireSignature: false, trustedKeys: [] });
70
+ assert.strictEqual(result.ok, true);
71
+ });
72
+ it('test_verify_returns_signer_info — ok: true has signerName + signerPubkey', () => {
73
+ const req = signedRequest('echo', { message: 'hello' });
74
+ const receipt = req.params._meta._signet;
75
+ const result = verifyRequest(req, { trustedKeys: [trustedKey(receipt)] });
76
+ assert.strictEqual(result.ok, true);
77
+ assert.strictEqual(result.signerName, 'test-agent');
78
+ assert(result.signerPubkey?.startsWith('ed25519:'), `Expected prefixed pubkey, got: ${result.signerPubkey}`);
79
+ });
80
+ it('test_verify_expired_receipt — maxAge=0 causes "receipt too old"', async () => {
81
+ const req = signedRequest('echo', { message: 'hello' });
82
+ const receipt = req.params._meta._signet;
83
+ // Use maxAge=0 so any receipt is immediately expired (0 seconds allowed)
84
+ // Wait a tiny bit to ensure time has passed
85
+ await new Promise((resolve) => setTimeout(resolve, 5));
86
+ const result = verifyRequest(req, { trustedKeys: [trustedKey(receipt)], maxAge: 0 });
87
+ assert.strictEqual(result.ok, false);
88
+ assert(result.error?.includes('receipt too old'), `Expected 'receipt too old', got: ${result.error}`);
89
+ });
90
+ it('test_verify_target_mismatch — expectedTarget differs → ok: false', () => {
91
+ const req = signedRequest('echo', { message: 'hello' });
92
+ const receipt = req.params._meta._signet;
93
+ const result = verifyRequest(req, {
94
+ trustedKeys: [trustedKey(receipt)],
95
+ expectedTarget: 'mcp://different-server',
96
+ });
97
+ assert.strictEqual(result.ok, false);
98
+ assert(result.error?.includes('target mismatch'), `Expected 'target mismatch', got: ${result.error}`);
99
+ });
100
+ it('test_verify_malformed_signet — _signet is garbage → ok: false, "malformed"', () => {
101
+ const req = {
102
+ params: {
103
+ name: 'echo',
104
+ arguments: { message: 'hello' },
105
+ _meta: { _signet: { garbage: true, random: 'data' } },
106
+ },
107
+ };
108
+ const result = verifyRequest(req, { trustedKeys: [] });
109
+ assert.strictEqual(result.ok, false);
110
+ assert(result.error?.includes('malformed'), `Expected 'malformed' in error, got: ${result.error}`);
111
+ });
112
+ it('test_verify_tool_mismatch — receipt for "echo" on request for "delete" → ok: false', () => {
113
+ // Sign for "echo" but attach to a "delete" request
114
+ const action = makeAction('echo', { message: 'hello' });
115
+ const receipt = sign(kp.secretKey, action, 'test-agent', 'owner');
116
+ const req = {
117
+ params: {
118
+ name: 'delete', // different tool
119
+ arguments: { message: 'hello' },
120
+ _meta: { _signet: receipt },
121
+ },
122
+ };
123
+ const result = verifyRequest(req, { trustedKeys: [receipt.signer.pubkey] });
124
+ assert.strictEqual(result.ok, false);
125
+ assert(result.error?.includes('tool mismatch'), `Expected 'tool mismatch', got: ${result.error}`);
126
+ });
127
+ it('test_verify_params_mismatch — receipt with {a:1} but request has {a:2} → ok: false', () => {
128
+ // Sign for {a: 1} but attach to request with {a: 2}
129
+ const action = makeAction('echo', { a: 1 });
130
+ const receipt = sign(kp.secretKey, action, 'test-agent', 'owner');
131
+ const req = {
132
+ params: {
133
+ name: 'echo',
134
+ arguments: { a: 2 }, // different args
135
+ _meta: { _signet: receipt },
136
+ },
137
+ };
138
+ const result = verifyRequest(req, { trustedKeys: [receipt.signer.pubkey] });
139
+ assert.strictEqual(result.ok, false);
140
+ assert(result.error?.includes('params mismatch'), `Expected 'params mismatch', got: ${result.error}`);
141
+ });
142
+ it('test_verify_no_request_args — receipt signed with params, request has no arguments key → params mismatch', () => {
143
+ // Sign with params {x: 1} but request omits the arguments key entirely
144
+ const action = makeAction('echo', { x: 1 });
145
+ const receipt = sign(kp.secretKey, action, 'test-agent', 'owner');
146
+ const req = {
147
+ params: {
148
+ name: 'echo',
149
+ // no 'arguments' key — requestArgs will be undefined
150
+ _meta: { _signet: receipt },
151
+ },
152
+ };
153
+ const result = verifyRequest(req, { trustedKeys: [receipt.signer.pubkey] });
154
+ // receipt.action.params is {x:1}, requestArgs is undefined → mismatch
155
+ assert.strictEqual(result.ok, false);
156
+ assert(result.error?.includes('params mismatch'), `Expected 'params mismatch', got: ${result.error}`);
157
+ });
158
+ it('test_verify_malformed_no_ts — _signet missing ts field → ok: false, "malformed"', () => {
159
+ // Construct a receipt-like object without ts to exercise the shape check for ts
160
+ const req = {
161
+ params: {
162
+ name: 'echo',
163
+ arguments: { message: 'hello' },
164
+ _meta: {
165
+ _signet: {
166
+ v: 1,
167
+ sig: 'ed25519:AAAA',
168
+ action: { tool: 'echo', params: {}, params_hash: '', target: '', transport: 'stdio' },
169
+ signer: { name: 'agent', pubkey: 'ed25519:AAAA', owner: '' },
170
+ // ts intentionally omitted
171
+ },
172
+ },
173
+ },
174
+ };
175
+ const result = verifyRequest(req, { trustedKeys: [] });
176
+ assert.strictEqual(result.ok, false);
177
+ assert(result.error?.includes('malformed'), `Expected 'malformed' in error, got: ${result.error}`);
178
+ });
179
+ });
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@signet-auth/mcp-server",
3
+ "version": "0.3.0",
4
+ "description": "Server-side verification for Signet cryptographic action receipts",
5
+ "type": "module",
6
+ "main": "dist/src/index.js",
7
+ "types": "dist/src/index.d.ts",
8
+ "files": ["dist/"],
9
+ "scripts": {
10
+ "build": "npx tsc",
11
+ "test": "npx tsc && node --test dist/tests/server.test.js"
12
+ },
13
+ "dependencies": {
14
+ "@signet-auth/core": "file:../signet-core"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^22",
18
+ "typescript": "^5"
19
+ },
20
+ "license": "Apache-2.0 OR MIT"
21
+ }