@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.
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/verify-request.d.ts +20 -0
- package/dist/src/verify-request.js +84 -0
- package/dist/tests/server.test.d.ts +1 -0
- package/dist/tests/server.test.js +179 -0
- package/package.json +21 -0
|
@@ -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
|
+
}
|