@mcp-i/core 1.1.1 → 1.2.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/delegation/cascading-revocation.d.ts.map +1 -1
- package/dist/delegation/cascading-revocation.js +3 -1
- package/dist/delegation/cascading-revocation.js.map +1 -1
- package/dist/delegation/outbound-headers.d.ts +12 -12
- package/dist/delegation/outbound-headers.d.ts.map +1 -1
- package/dist/delegation/outbound-headers.js +12 -12
- package/dist/delegation/outbound-headers.js.map +1 -1
- package/dist/delegation/outbound-proof.d.ts +1 -1
- package/dist/delegation/outbound-proof.js +1 -1
- package/dist/delegation/statuslist-manager.d.ts +3 -0
- package/dist/delegation/statuslist-manager.d.ts.map +1 -1
- package/dist/delegation/statuslist-manager.js +13 -0
- package/dist/delegation/statuslist-manager.js.map +1 -1
- package/dist/middleware/with-mcpi-server.d.ts +1 -1
- package/dist/middleware/with-mcpi-server.d.ts.map +1 -1
- package/dist/middleware/with-mcpi-server.js.map +1 -1
- package/dist/middleware/with-mcpi.d.ts +14 -0
- package/dist/middleware/with-mcpi.d.ts.map +1 -1
- package/dist/middleware/with-mcpi.js +12 -0
- package/dist/middleware/with-mcpi.js.map +1 -1
- package/package.json +3 -2
- package/src/__tests__/audit/canonicalization-integrity.test.ts +243 -0
- package/src/__tests__/audit/graph-revocation-roundtrip.test.ts +280 -0
- package/src/__tests__/audit/helpers/crypto-helpers.ts +245 -0
- package/src/__tests__/audit/proof-boundary.test.ts +269 -0
- package/src/__tests__/audit/statuslist-bitstring-roundtrip.test.ts +135 -0
- package/src/__tests__/audit/vc-roundtrip.test.ts +290 -0
- package/src/delegation/__tests__/outbound-headers.test.ts +16 -16
- package/src/delegation/__tests__/transitive-access.test.ts +1233 -0
- package/src/delegation/__tests__/vc-issuer.integration.test.ts +136 -0
- package/src/delegation/__tests__/vc-jwt.test.ts +318 -0
- package/src/delegation/__tests__/vc-verifier.integration.test.ts +199 -0
- package/src/delegation/cascading-revocation.ts +3 -1
- package/src/delegation/outbound-headers.ts +16 -16
- package/src/delegation/outbound-proof.ts +1 -1
- package/src/delegation/statuslist-manager.ts +17 -0
- package/src/middleware/with-mcpi-server.ts +1 -5
- package/src/middleware/with-mcpi.ts +29 -0
- package/src/proof/__tests__/verifier.integration.test.ts +181 -0
|
@@ -0,0 +1,1233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transitive Access — Scenario Tests
|
|
3
|
+
*
|
|
4
|
+
* These tests map directly to the transitive-access attack scenarios described
|
|
5
|
+
* in Alan Karp's use-case analysis (https://alanhkarp.com/UseCases.pdf),
|
|
6
|
+
* presented to the DIF MCP-I TaskForce on 2026-03-27.
|
|
7
|
+
*
|
|
8
|
+
* ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
|
9
|
+
* │ Aperture │ │ Bluth │ │ Cyberdyne │
|
|
10
|
+
* │ (Alice, X,Y)│ │ (Bob) │ │ (Carol) │
|
|
11
|
+
* └──────────────┘ └──────────────┘ └──────────────┘
|
|
12
|
+
*
|
|
13
|
+
* Scenario: Alice (Aperture) delegates [query:x, update:y] to Bob (Bluth).
|
|
14
|
+
* Bob re-delegates a subset to Carol (Cyberdyne). Aperture's server must
|
|
15
|
+
* verify the complete chain, enforce attenuation, and prevent confused-deputy
|
|
16
|
+
* attacks — all without Alice having heard of Carol or Cyberdyne.
|
|
17
|
+
*
|
|
18
|
+
* With ACLs, Aperture sees Carol's requests as coming from Bluth due to
|
|
19
|
+
* federated identity. Capability certificates solve this by carrying the
|
|
20
|
+
* full delegation provenance and enforcing attenuation at every hop.
|
|
21
|
+
*
|
|
22
|
+
* Every test uses real Ed25519 key pairs and cryptographic signatures.
|
|
23
|
+
* No signing operations are mocked.
|
|
24
|
+
*
|
|
25
|
+
* Related Spec: MCP-I §4.4 (Delegation Chains), §11.3 (Scope Escalation),
|
|
26
|
+
* §11.6 (Confused Deputy), §12.3 (Delegation Chain Disclosure)
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
30
|
+
import {
|
|
31
|
+
createMCPIMiddleware,
|
|
32
|
+
type MCPIDelegationConfig,
|
|
33
|
+
type MCPIMiddleware,
|
|
34
|
+
} from '../../middleware/with-mcpi.js';
|
|
35
|
+
import { NodeCryptoProvider } from '../../__tests__/utils/node-crypto-provider.js';
|
|
36
|
+
import { generateDidKeyFromBase64 } from '../../utils/did-helpers.js';
|
|
37
|
+
import { DelegationCredentialIssuer } from '../vc-issuer.js';
|
|
38
|
+
import type {
|
|
39
|
+
DelegationCredential,
|
|
40
|
+
Proof,
|
|
41
|
+
} from '../../types/protocol.js';
|
|
42
|
+
import { base64urlEncodeFromBytes } from '../../utils/base64.js';
|
|
43
|
+
import { DelegationGraphManager } from '../delegation-graph.js';
|
|
44
|
+
import { CascadingRevocationManager } from '../cascading-revocation.js';
|
|
45
|
+
import { StatusList2021Manager } from '../statuslist-manager.js';
|
|
46
|
+
import { MemoryDelegationGraphStorage } from '../storage/memory-graph-storage.js';
|
|
47
|
+
import { MemoryStatusListStorage } from '../storage/memory-statuslist-storage.js';
|
|
48
|
+
import {
|
|
49
|
+
buildOutboundDelegationHeaders,
|
|
50
|
+
} from '../outbound-headers.js';
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Shared identity helpers
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
interface AgentIdentity {
|
|
57
|
+
crypto: NodeCryptoProvider;
|
|
58
|
+
keyPair: { privateKey: string; publicKey: string };
|
|
59
|
+
did: string;
|
|
60
|
+
kid: string;
|
|
61
|
+
issuer: DelegationCredentialIssuer;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create an agent identity with a real Ed25519 key pair.
|
|
66
|
+
* Returns a DID, key ID, and a DelegationCredentialIssuer that
|
|
67
|
+
* produces cryptographically signed VCs.
|
|
68
|
+
*/
|
|
69
|
+
async function createAgentIdentity(): Promise<AgentIdentity> {
|
|
70
|
+
const crypto = new NodeCryptoProvider();
|
|
71
|
+
const keyPair = await crypto.generateKeyPair();
|
|
72
|
+
const did = generateDidKeyFromBase64(keyPair.publicKey);
|
|
73
|
+
const kid = `${did}#${did.replace('did:key:', '')}`;
|
|
74
|
+
|
|
75
|
+
const signingFn = async (
|
|
76
|
+
canonicalVC: string,
|
|
77
|
+
_issuerDid: string,
|
|
78
|
+
kidArg: string,
|
|
79
|
+
): Promise<Proof> => {
|
|
80
|
+
const data = new TextEncoder().encode(canonicalVC);
|
|
81
|
+
const sigBytes = await crypto.sign(data, keyPair.privateKey);
|
|
82
|
+
const proofValue = base64urlEncodeFromBytes(sigBytes);
|
|
83
|
+
return {
|
|
84
|
+
type: 'Ed25519Signature2020',
|
|
85
|
+
created: new Date().toISOString(),
|
|
86
|
+
verificationMethod: kidArg,
|
|
87
|
+
proofPurpose: 'assertionMethod',
|
|
88
|
+
proofValue,
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const issuer = new DelegationCredentialIssuer(
|
|
93
|
+
{
|
|
94
|
+
getDid: () => did,
|
|
95
|
+
getKeyId: () => kid,
|
|
96
|
+
getPrivateKey: () => keyPair.privateKey,
|
|
97
|
+
},
|
|
98
|
+
signingFn,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return { crypto, keyPair, did, kid, issuer };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Issue a signed DelegationCredential.
|
|
106
|
+
*/
|
|
107
|
+
async function issueVC(opts: {
|
|
108
|
+
from: AgentIdentity;
|
|
109
|
+
to: AgentIdentity;
|
|
110
|
+
scopes: string[];
|
|
111
|
+
audience?: string | string[];
|
|
112
|
+
parentId?: string;
|
|
113
|
+
}): Promise<DelegationCredential> {
|
|
114
|
+
return opts.from.issuer.createAndIssueDelegation({
|
|
115
|
+
id: `del-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
116
|
+
issuerDid: opts.from.did,
|
|
117
|
+
subjectDid: opts.to.did,
|
|
118
|
+
parentId: opts.parentId,
|
|
119
|
+
constraints: {
|
|
120
|
+
scopes: opts.scopes,
|
|
121
|
+
...(opts.audience !== undefined && { audience: opts.audience }),
|
|
122
|
+
notAfter: Math.floor(Date.now() / 1000) + 3600,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create a test middleware representing a verifying server (Aperture).
|
|
129
|
+
*/
|
|
130
|
+
async function createServer(opts?: {
|
|
131
|
+
delegation?: MCPIDelegationConfig;
|
|
132
|
+
autoSession?: boolean;
|
|
133
|
+
}): Promise<{ middleware: MCPIMiddleware; did: string }> {
|
|
134
|
+
const crypto = new NodeCryptoProvider();
|
|
135
|
+
const keyPair = await crypto.generateKeyPair();
|
|
136
|
+
const did = generateDidKeyFromBase64(keyPair.publicKey);
|
|
137
|
+
const kid = `${did}#${did.replace('did:key:', '')}`;
|
|
138
|
+
|
|
139
|
+
const middleware = createMCPIMiddleware(
|
|
140
|
+
{
|
|
141
|
+
identity: {
|
|
142
|
+
did,
|
|
143
|
+
kid,
|
|
144
|
+
privateKey: keyPair.privateKey,
|
|
145
|
+
publicKey: keyPair.publicKey,
|
|
146
|
+
},
|
|
147
|
+
session: { sessionTtlMinutes: 60 },
|
|
148
|
+
delegation: opts?.delegation,
|
|
149
|
+
autoSession: opts?.autoSession,
|
|
150
|
+
},
|
|
151
|
+
crypto,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return { middleware, did };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Three-jurisdiction fixture
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
/** Shared state across all tests in this suite. */
|
|
162
|
+
let alice: AgentIdentity; // Aperture — original delegator
|
|
163
|
+
let bob: AgentIdentity; // Bluth — intermediary
|
|
164
|
+
let carol: AgentIdentity; // Cyberdyne — downstream
|
|
165
|
+
|
|
166
|
+
beforeAll(async () => {
|
|
167
|
+
alice = await createAgentIdentity();
|
|
168
|
+
bob = await createAgentIdentity();
|
|
169
|
+
carol = await createAgentIdentity();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ===========================================================================
|
|
173
|
+
// 1. Valid transitive delegation
|
|
174
|
+
// ===========================================================================
|
|
175
|
+
|
|
176
|
+
describe('Transitive Access — Karp Use Cases', () => {
|
|
177
|
+
describe('1. Valid transitive delegation chain (Alice → Bob → Carol)', () => {
|
|
178
|
+
it('accepts a two-hop chain where Carol acts with attenuated scope', async () => {
|
|
179
|
+
// Alice delegates [query:x, update:y] to Bob
|
|
180
|
+
const aliceToBob = await issueVC({
|
|
181
|
+
from: alice,
|
|
182
|
+
to: bob,
|
|
183
|
+
scopes: ['query:x', 'update:y'],
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Bob re-delegates [query:x] to Carol (attenuated — drops update:y)
|
|
187
|
+
const bobToCarol = await issueVC({
|
|
188
|
+
from: bob,
|
|
189
|
+
to: carol,
|
|
190
|
+
scopes: ['query:x'],
|
|
191
|
+
parentId: aliceToBob.credentialSubject.delegation.id,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Aperture's server is configured to resolve chains
|
|
195
|
+
const { middleware } = await createServer({
|
|
196
|
+
delegation: {
|
|
197
|
+
resolveDelegationChain: async () => [aliceToBob],
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const handler = middleware.wrapWithDelegation(
|
|
202
|
+
'query_x',
|
|
203
|
+
{ scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
|
|
204
|
+
async () => ({
|
|
205
|
+
content: [{ type: 'text', text: 'query result for resource X' }],
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const result = await handler({ _mcpi_delegation: bobToCarol });
|
|
210
|
+
|
|
211
|
+
expect(result.isError).toBeUndefined();
|
|
212
|
+
expect(result.content[0].text).toBe('query result for resource X');
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// =========================================================================
|
|
217
|
+
// 2. Provenance visibility
|
|
218
|
+
// =========================================================================
|
|
219
|
+
|
|
220
|
+
describe('2. Provenance visibility (unlike ACL federation)', () => {
|
|
221
|
+
it('the delegation chain identifies Carol by her own DID, not Bob\'s', async () => {
|
|
222
|
+
// In ACL systems, Carol's identity is hidden behind Bluth's federated
|
|
223
|
+
// identity — Aperture sees the request as coming from Bob.
|
|
224
|
+
// With capability certificates, the chain explicitly names each party.
|
|
225
|
+
|
|
226
|
+
const aliceToBob = await issueVC({
|
|
227
|
+
from: alice,
|
|
228
|
+
to: bob,
|
|
229
|
+
scopes: ['query:x'],
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const bobToCarol = await issueVC({
|
|
233
|
+
from: bob,
|
|
234
|
+
to: carol,
|
|
235
|
+
scopes: ['query:x'],
|
|
236
|
+
parentId: aliceToBob.credentialSubject.delegation.id,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Inspect the chain — the leaf credential names Carol, not Bob
|
|
240
|
+
const leafSubject = bobToCarol.credentialSubject.delegation.subjectDid;
|
|
241
|
+
const leafIssuer = bobToCarol.credentialSubject.delegation.issuerDid;
|
|
242
|
+
|
|
243
|
+
expect(leafSubject).toBe(carol.did);
|
|
244
|
+
expect(leafIssuer).toBe(bob.did);
|
|
245
|
+
|
|
246
|
+
// The root credential shows Alice→Bob
|
|
247
|
+
const rootSubject = aliceToBob.credentialSubject.delegation.subjectDid;
|
|
248
|
+
const rootIssuer = aliceToBob.credentialSubject.delegation.issuerDid;
|
|
249
|
+
|
|
250
|
+
expect(rootIssuer).toBe(alice.did);
|
|
251
|
+
expect(rootSubject).toBe(bob.did);
|
|
252
|
+
|
|
253
|
+
// The verifying server can reconstruct the full chain:
|
|
254
|
+
// Alice (issuer) → Bob (subject/issuer) → Carol (subject)
|
|
255
|
+
// No identity is hidden or federated.
|
|
256
|
+
expect(leafIssuer).toBe(rootSubject); // Bob links the two credentials
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// =========================================================================
|
|
261
|
+
// 3. Scope attenuation enforcement
|
|
262
|
+
// =========================================================================
|
|
263
|
+
|
|
264
|
+
describe('3. Scope attenuation across hops', () => {
|
|
265
|
+
it('accepts a child that narrows the parent\'s scopes', async () => {
|
|
266
|
+
const aliceToBob = await issueVC({
|
|
267
|
+
from: alice,
|
|
268
|
+
to: bob,
|
|
269
|
+
scopes: ['query:x', 'update:y'],
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Bob narrows to just query:x — valid attenuation
|
|
273
|
+
const bobToCarol = await issueVC({
|
|
274
|
+
from: bob,
|
|
275
|
+
to: carol,
|
|
276
|
+
scopes: ['query:x'],
|
|
277
|
+
parentId: aliceToBob.credentialSubject.delegation.id,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const { middleware } = await createServer({
|
|
281
|
+
delegation: {
|
|
282
|
+
resolveDelegationChain: async () => [aliceToBob],
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const handler = middleware.wrapWithDelegation(
|
|
287
|
+
'query_x',
|
|
288
|
+
{ scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
|
|
289
|
+
async () => ({ content: [{ type: 'text', text: 'ok' }] }),
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const result = await handler({ _mcpi_delegation: bobToCarol });
|
|
293
|
+
expect(result.isError).toBeUndefined();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('rejects a child that widens scopes beyond the parent\'s grant', async () => {
|
|
297
|
+
// Alice gave Bob [query:x, update:y]
|
|
298
|
+
const aliceToBob = await issueVC({
|
|
299
|
+
from: alice,
|
|
300
|
+
to: bob,
|
|
301
|
+
scopes: ['query:x', 'update:y'],
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Bob attempts to give Carol [query:x, update:y, admin:z]
|
|
305
|
+
// admin:z was never granted to Bob — this is scope escalation
|
|
306
|
+
const bobToCarol = await issueVC({
|
|
307
|
+
from: bob,
|
|
308
|
+
to: carol,
|
|
309
|
+
scopes: ['query:x', 'update:y', 'admin:z'],
|
|
310
|
+
parentId: aliceToBob.credentialSubject.delegation.id,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const { middleware } = await createServer({
|
|
314
|
+
delegation: {
|
|
315
|
+
resolveDelegationChain: async () => [aliceToBob],
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const handler = middleware.wrapWithDelegation(
|
|
320
|
+
'query_x',
|
|
321
|
+
{ scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
|
|
322
|
+
async () => ({ content: [{ type: 'text', text: 'should not reach' }] }),
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const result = await handler({ _mcpi_delegation: bobToCarol });
|
|
326
|
+
|
|
327
|
+
expect(result.isError).toBe(true);
|
|
328
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
329
|
+
expect(parsed.error).toBe('delegation_invalid');
|
|
330
|
+
expect(parsed.reason).toContain('widens scopes');
|
|
331
|
+
expect(parsed.reason).toContain('admin:z');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('rejects a child that passes through equal scopes but adds new ones', async () => {
|
|
335
|
+
// Alice gave Bob [query:x]
|
|
336
|
+
const aliceToBob = await issueVC({
|
|
337
|
+
from: alice,
|
|
338
|
+
to: bob,
|
|
339
|
+
scopes: ['query:x'],
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Bob passes through query:x but adds update:y — escalation
|
|
343
|
+
const bobToCarol = await issueVC({
|
|
344
|
+
from: bob,
|
|
345
|
+
to: carol,
|
|
346
|
+
scopes: ['query:x', 'update:y'],
|
|
347
|
+
parentId: aliceToBob.credentialSubject.delegation.id,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const { middleware } = await createServer({
|
|
351
|
+
delegation: {
|
|
352
|
+
resolveDelegationChain: async () => [aliceToBob],
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const handler = middleware.wrapWithDelegation(
|
|
357
|
+
'query_x',
|
|
358
|
+
{ scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
|
|
359
|
+
async () => ({ content: [{ type: 'text', text: 'should not reach' }] }),
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
const result = await handler({ _mcpi_delegation: bobToCarol });
|
|
363
|
+
|
|
364
|
+
expect(result.isError).toBe(true);
|
|
365
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
366
|
+
expect(parsed.reason).toContain('widens scopes');
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// =========================================================================
|
|
371
|
+
// 4. Confused deputy prevention
|
|
372
|
+
// =========================================================================
|
|
373
|
+
|
|
374
|
+
describe('4. Confused deputy prevention', () => {
|
|
375
|
+
it('rejects a delegation presented to the wrong server (audience mismatch)', async () => {
|
|
376
|
+
// Karp scenario: Alice delegates to Bob for use at Aperture's server,
|
|
377
|
+
// but Carol presents it to Cyberdyne's server instead.
|
|
378
|
+
// The audience constraint binds the credential to a specific server DID.
|
|
379
|
+
|
|
380
|
+
const { did: apertureDid } = await createServer();
|
|
381
|
+
const { middleware: cyberdyneServer } = await createServer();
|
|
382
|
+
|
|
383
|
+
// Alice delegates to Bob, audience-bound to Aperture
|
|
384
|
+
const aliceToBob = await issueVC({
|
|
385
|
+
from: alice,
|
|
386
|
+
to: bob,
|
|
387
|
+
scopes: ['query:x'],
|
|
388
|
+
audience: apertureDid,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Bob re-delegates to Carol, still audience-bound to Aperture
|
|
392
|
+
const bobToCarol = await issueVC({
|
|
393
|
+
from: bob,
|
|
394
|
+
to: carol,
|
|
395
|
+
scopes: ['query:x'],
|
|
396
|
+
parentId: aliceToBob.credentialSubject.delegation.id,
|
|
397
|
+
audience: apertureDid,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// Cyberdyne's server has a chain resolver but its DID differs from the
|
|
401
|
+
// audience in the credentials. The audience check fires during chain
|
|
402
|
+
// validation when each credential is individually verified.
|
|
403
|
+
const { middleware: cyberdyneMiddleware } = await createServer({
|
|
404
|
+
delegation: {
|
|
405
|
+
resolveDelegationChain: async () => [aliceToBob],
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const handler = cyberdyneMiddleware.wrapWithDelegation(
|
|
410
|
+
'query_x',
|
|
411
|
+
{ scopeId: 'query:x', consentUrl: 'https://cyberdyne.example/consent' },
|
|
412
|
+
async () => ({ content: [{ type: 'text', text: 'should not reach' }] }),
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
const result = await handler({ _mcpi_delegation: bobToCarol });
|
|
416
|
+
|
|
417
|
+
expect(result.isError).toBe(true);
|
|
418
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
419
|
+
expect(parsed.error).toBe('delegation_invalid');
|
|
420
|
+
expect(parsed.reason).toContain('audience');
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('rejects when Carol invokes a tool requiring a scope she was not delegated', async () => {
|
|
424
|
+
// Karp scenario: Alice delegated [query:x] to Bob.
|
|
425
|
+
// Bob delegated [query:x] to Carol. Carol tries to use update:y.
|
|
426
|
+
// Even if the chain is perfectly valid, the scope check at the tool
|
|
427
|
+
// level prevents the confused deputy from exceeding her authority.
|
|
428
|
+
|
|
429
|
+
const aliceToBob = await issueVC({
|
|
430
|
+
from: alice,
|
|
431
|
+
to: bob,
|
|
432
|
+
scopes: ['query:x'],
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const bobToCarol = await issueVC({
|
|
436
|
+
from: bob,
|
|
437
|
+
to: carol,
|
|
438
|
+
scopes: ['query:x'],
|
|
439
|
+
parentId: aliceToBob.credentialSubject.delegation.id,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const { middleware } = await createServer({
|
|
443
|
+
delegation: {
|
|
444
|
+
resolveDelegationChain: async () => [aliceToBob],
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// Tool requires update:y — Carol only has query:x
|
|
449
|
+
const handler = middleware.wrapWithDelegation(
|
|
450
|
+
'update_y',
|
|
451
|
+
{ scopeId: 'update:y', consentUrl: 'https://aperture.example/consent' },
|
|
452
|
+
async () => ({ content: [{ type: 'text', text: 'should not reach' }] }),
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
const result = await handler({ _mcpi_delegation: bobToCarol });
|
|
456
|
+
|
|
457
|
+
expect(result.isError).toBe(true);
|
|
458
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
459
|
+
expect(parsed.error).toBe('insufficient_scope');
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('prevents "Backup X to Z" — credential bound to Aperture cannot act at Cyberdyne', async () => {
|
|
463
|
+
// Karp's "How Bad It Can Get" scenario:
|
|
464
|
+
// Alice says "Backup X and put the result in Z" where Z is in Cyberdyne.
|
|
465
|
+
// In ACL world, Bob's backup service (running as Bluth) has Carol's
|
|
466
|
+
// permissions via federation and can clobber Carol's resource Z.
|
|
467
|
+
//
|
|
468
|
+
// With capabilities: Alice's delegation is bound to Aperture's server.
|
|
469
|
+
// Bob cannot forward it to Cyberdyne because the audience doesn't match.
|
|
470
|
+
|
|
471
|
+
const { did: apertureDid } = await createServer();
|
|
472
|
+
const { middleware: cyberdyneServer } = await createServer();
|
|
473
|
+
|
|
474
|
+
const aliceToBob = await issueVC({
|
|
475
|
+
from: alice,
|
|
476
|
+
to: bob,
|
|
477
|
+
scopes: ['query:x', 'update:y'],
|
|
478
|
+
audience: apertureDid,
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// Bob tries to forward Alice's credential to Cyberdyne
|
|
482
|
+
const handler = cyberdyneServer.wrapWithDelegation(
|
|
483
|
+
'update_z',
|
|
484
|
+
{ scopeId: 'update:y', consentUrl: 'https://cyberdyne.example/consent' },
|
|
485
|
+
async () => ({
|
|
486
|
+
content: [{ type: 'text', text: 'clobbered Z — should not reach' }],
|
|
487
|
+
}),
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
const result = await handler({ _mcpi_delegation: aliceToBob });
|
|
491
|
+
|
|
492
|
+
expect(result.isError).toBe(true);
|
|
493
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
494
|
+
expect(parsed.error).toBe('delegation_invalid');
|
|
495
|
+
expect(parsed.reason).toContain('audience');
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('prevents "Backup Z to X" — Carol cannot query Z through Bob\'s delegation', async () => {
|
|
499
|
+
// Karp's reverse confused-deputy scenario:
|
|
500
|
+
// Alice says "Backup Z to X", hoping to read Carol's resource Z
|
|
501
|
+
// via Bob's backup service. With ACLs, Bob (federated as Bluth) has
|
|
502
|
+
// query access to Carol's resources.
|
|
503
|
+
//
|
|
504
|
+
// With capabilities: Alice only granted query:x and update:y.
|
|
505
|
+
// No scope covers Z, so Carol's server rejects the request.
|
|
506
|
+
|
|
507
|
+
const aliceToBob = await issueVC({
|
|
508
|
+
from: alice,
|
|
509
|
+
to: bob,
|
|
510
|
+
scopes: ['query:x', 'update:y'],
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
const { middleware: cyberdyneServer } = await createServer();
|
|
514
|
+
|
|
515
|
+
// Try to query Z at Cyberdyne using Alice's credential
|
|
516
|
+
const handler = cyberdyneServer.wrapWithDelegation(
|
|
517
|
+
'query_z',
|
|
518
|
+
{ scopeId: 'query:z', consentUrl: 'https://cyberdyne.example/consent' },
|
|
519
|
+
async () => ({
|
|
520
|
+
content: [{ type: 'text', text: 'read Z — should not reach' }],
|
|
521
|
+
}),
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
const result = await handler({ _mcpi_delegation: aliceToBob });
|
|
525
|
+
|
|
526
|
+
expect(result.isError).toBe(true);
|
|
527
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
528
|
+
// Either audience mismatch (if audience was set) or scope mismatch
|
|
529
|
+
// In this case, query:z is not in the delegation's scopes
|
|
530
|
+
expect(parsed.error).toBe('insufficient_scope');
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// =========================================================================
|
|
535
|
+
// 5. Cascading revocation
|
|
536
|
+
// =========================================================================
|
|
537
|
+
|
|
538
|
+
describe('5. Cascading revocation (Alice revokes Bob → Carol is invalidated)', () => {
|
|
539
|
+
it('revoking Alice→Bob automatically revokes Bob→Carol and all descendants', async () => {
|
|
540
|
+
// Karp notes that with capabilities, Bob can "forget" credentials he
|
|
541
|
+
// holds after delegating to Carol. But the dual requirement is that
|
|
542
|
+
// Alice can revoke the entire chain if Bluth (or Cyberdyne) is compromised.
|
|
543
|
+
|
|
544
|
+
const graphStorage = new MemoryDelegationGraphStorage();
|
|
545
|
+
const statusListStorage = new MemoryStatusListStorage();
|
|
546
|
+
|
|
547
|
+
const graph = new DelegationGraphManager(graphStorage);
|
|
548
|
+
const crypto = new NodeCryptoProvider();
|
|
549
|
+
const keyPair = await crypto.generateKeyPair();
|
|
550
|
+
|
|
551
|
+
const mockSigningFn = async (
|
|
552
|
+
canonicalVC: string,
|
|
553
|
+
_did: string,
|
|
554
|
+
_kid: string,
|
|
555
|
+
): Promise<Proof> => {
|
|
556
|
+
const data = new TextEncoder().encode(canonicalVC);
|
|
557
|
+
const sigBytes = await crypto.sign(data, keyPair.privateKey);
|
|
558
|
+
return {
|
|
559
|
+
type: 'Ed25519Signature2020',
|
|
560
|
+
created: new Date().toISOString(),
|
|
561
|
+
verificationMethod: _kid,
|
|
562
|
+
proofPurpose: 'assertionMethod',
|
|
563
|
+
proofValue: base64urlEncodeFromBytes(sigBytes),
|
|
564
|
+
};
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
const compressor = { compress: async (data: Uint8Array) => data };
|
|
568
|
+
const decompressor = { decompress: async (data: Uint8Array) => data };
|
|
569
|
+
|
|
570
|
+
const statusListManager = new StatusList2021Manager(
|
|
571
|
+
statusListStorage,
|
|
572
|
+
{
|
|
573
|
+
getDid: () => alice.did,
|
|
574
|
+
getKeyId: () => alice.kid,
|
|
575
|
+
},
|
|
576
|
+
mockSigningFn,
|
|
577
|
+
compressor,
|
|
578
|
+
decompressor,
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
const revocationManager = new CascadingRevocationManager(
|
|
582
|
+
graph,
|
|
583
|
+
statusListManager,
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
// Register Alice→Bob in the graph
|
|
587
|
+
const aliceEntry = await statusListManager.allocateStatusEntry('revocation');
|
|
588
|
+
await graph.registerDelegation({
|
|
589
|
+
id: 'del-alice-bob',
|
|
590
|
+
parentId: null,
|
|
591
|
+
issuerDid: alice.did,
|
|
592
|
+
subjectDid: bob.did,
|
|
593
|
+
credentialStatusId: aliceEntry.id,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
// Register Bob→Carol in the graph
|
|
597
|
+
const bobEntry = await statusListManager.allocateStatusEntry('revocation');
|
|
598
|
+
await graph.registerDelegation({
|
|
599
|
+
id: 'del-bob-carol',
|
|
600
|
+
parentId: 'del-alice-bob',
|
|
601
|
+
issuerDid: bob.did,
|
|
602
|
+
subjectDid: carol.did,
|
|
603
|
+
credentialStatusId: bobEntry.id,
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// Before revocation: both are valid
|
|
607
|
+
const preCheck = await revocationManager.isRevoked('del-bob-carol');
|
|
608
|
+
expect(preCheck.revoked).toBe(false);
|
|
609
|
+
|
|
610
|
+
// Alice revokes her delegation to Bob
|
|
611
|
+
const events = await revocationManager.revokeDelegation('del-alice-bob');
|
|
612
|
+
|
|
613
|
+
// Cascading: both Alice→Bob and Bob→Carol are revoked
|
|
614
|
+
expect(events).toHaveLength(2);
|
|
615
|
+
expect(events[0].delegationId).toBe('del-alice-bob');
|
|
616
|
+
expect(events[0].isRoot).toBe(true);
|
|
617
|
+
expect(events[1].delegationId).toBe('del-bob-carol');
|
|
618
|
+
expect(events[1].isRoot).toBe(false);
|
|
619
|
+
|
|
620
|
+
// Verify Bob→Carol is now revoked.
|
|
621
|
+
// Cascading revocation atomically sets the status bit on every
|
|
622
|
+
// descendant, so isRevoked reports it as "Directly revoked" —
|
|
623
|
+
// the bit for del-bob-carol was explicitly flipped by the cascade.
|
|
624
|
+
const postCheck = await revocationManager.isRevoked('del-bob-carol');
|
|
625
|
+
expect(postCheck.revoked).toBe(true);
|
|
626
|
+
|
|
627
|
+
// Full chain validation also fails
|
|
628
|
+
const chainCheck = await revocationManager.validateDelegation('del-bob-carol');
|
|
629
|
+
expect(chainCheck.valid).toBe(false);
|
|
630
|
+
expect(chainCheck.reason).toContain('revoked');
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('validates delegation status when checking a leaf credential', async () => {
|
|
634
|
+
// The revocation manager checks the entire chain from root to leaf.
|
|
635
|
+
// If any ancestor is revoked, the leaf is invalid.
|
|
636
|
+
|
|
637
|
+
const graphStorage = new MemoryDelegationGraphStorage();
|
|
638
|
+
const statusListStorage = new MemoryStatusListStorage();
|
|
639
|
+
const graph = new DelegationGraphManager(graphStorage);
|
|
640
|
+
const crypto = new NodeCryptoProvider();
|
|
641
|
+
const keyPair = await crypto.generateKeyPair();
|
|
642
|
+
|
|
643
|
+
const mockSigningFn = async (
|
|
644
|
+
canonicalVC: string,
|
|
645
|
+
_did: string,
|
|
646
|
+
_kid: string,
|
|
647
|
+
): Promise<Proof> => {
|
|
648
|
+
const data = new TextEncoder().encode(canonicalVC);
|
|
649
|
+
const sigBytes = await crypto.sign(data, keyPair.privateKey);
|
|
650
|
+
return {
|
|
651
|
+
type: 'Ed25519Signature2020',
|
|
652
|
+
created: new Date().toISOString(),
|
|
653
|
+
verificationMethod: _kid,
|
|
654
|
+
proofPurpose: 'assertionMethod',
|
|
655
|
+
proofValue: base64urlEncodeFromBytes(sigBytes),
|
|
656
|
+
};
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const compressor = { compress: async (data: Uint8Array) => data };
|
|
660
|
+
const decompressor = { decompress: async (data: Uint8Array) => data };
|
|
661
|
+
|
|
662
|
+
const statusListManager = new StatusList2021Manager(
|
|
663
|
+
statusListStorage,
|
|
664
|
+
{ getDid: () => alice.did, getKeyId: () => alice.kid },
|
|
665
|
+
mockSigningFn,
|
|
666
|
+
compressor,
|
|
667
|
+
decompressor,
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
const revocationManager = new CascadingRevocationManager(
|
|
671
|
+
graph,
|
|
672
|
+
statusListManager,
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
// Three-hop chain: Alice → Bob → Carol → Dave
|
|
676
|
+
const dave = await createAgentIdentity();
|
|
677
|
+
|
|
678
|
+
const entry0 = await statusListManager.allocateStatusEntry('revocation');
|
|
679
|
+
await graph.registerDelegation({
|
|
680
|
+
id: 'del-a-b',
|
|
681
|
+
parentId: null,
|
|
682
|
+
issuerDid: alice.did,
|
|
683
|
+
subjectDid: bob.did,
|
|
684
|
+
credentialStatusId: entry0.id,
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
const entry1 = await statusListManager.allocateStatusEntry('revocation');
|
|
688
|
+
await graph.registerDelegation({
|
|
689
|
+
id: 'del-b-c',
|
|
690
|
+
parentId: 'del-a-b',
|
|
691
|
+
issuerDid: bob.did,
|
|
692
|
+
subjectDid: carol.did,
|
|
693
|
+
credentialStatusId: entry1.id,
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
const entry2 = await statusListManager.allocateStatusEntry('revocation');
|
|
697
|
+
await graph.registerDelegation({
|
|
698
|
+
id: 'del-c-d',
|
|
699
|
+
parentId: 'del-b-c',
|
|
700
|
+
issuerDid: carol.did,
|
|
701
|
+
subjectDid: dave.did,
|
|
702
|
+
credentialStatusId: entry2.id,
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
// Revoke the middle link (Bob→Carol)
|
|
706
|
+
await revocationManager.revokeDelegation('del-b-c');
|
|
707
|
+
|
|
708
|
+
// Dave's leaf is invalid because ancestor del-b-c is revoked
|
|
709
|
+
const daveCheck = await revocationManager.validateDelegation('del-c-d');
|
|
710
|
+
expect(daveCheck.valid).toBe(false);
|
|
711
|
+
expect(daveCheck.reason).toContain('revoked');
|
|
712
|
+
|
|
713
|
+
// Alice→Bob is still valid (parent of the revoked node, not a descendant)
|
|
714
|
+
const aliceCheck = await revocationManager.validateDelegation('del-a-b');
|
|
715
|
+
expect(aliceCheck.valid).toBe(true);
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
// =========================================================================
|
|
720
|
+
// 6. Chain integrity enforcement
|
|
721
|
+
// =========================================================================
|
|
722
|
+
|
|
723
|
+
describe('6. Chain integrity enforcement', () => {
|
|
724
|
+
it('rejects a chain with broken issuer/subject continuity', async () => {
|
|
725
|
+
// If Carol forges a credential claiming Bob delegated to her, but the
|
|
726
|
+
// chain's issuer/subject links don't match, validation must fail.
|
|
727
|
+
// This is the cryptographic enforcement of Karp's "capability
|
|
728
|
+
// certificates carry provenance" property.
|
|
729
|
+
|
|
730
|
+
// Alice delegates to Bob
|
|
731
|
+
const aliceToBob = await issueVC({
|
|
732
|
+
from: alice,
|
|
733
|
+
to: bob,
|
|
734
|
+
scopes: ['query:x'],
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
// Eve (a fourth party) creates a credential claiming to be from Bob,
|
|
738
|
+
// but signed with Eve's key — not Bob's
|
|
739
|
+
const eve = await createAgentIdentity();
|
|
740
|
+
const eveToCarol = await issueVC({
|
|
741
|
+
from: eve, // Eve signs, but claims parentId from aliceToBob
|
|
742
|
+
to: carol,
|
|
743
|
+
scopes: ['query:x'],
|
|
744
|
+
parentId: aliceToBob.credentialSubject.delegation.id,
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
const { middleware } = await createServer({
|
|
748
|
+
delegation: {
|
|
749
|
+
resolveDelegationChain: async () => [aliceToBob],
|
|
750
|
+
},
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
const handler = middleware.wrapWithDelegation(
|
|
754
|
+
'query_x',
|
|
755
|
+
{ scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
|
|
756
|
+
async () => ({ content: [{ type: 'text', text: 'should not reach' }] }),
|
|
757
|
+
);
|
|
758
|
+
|
|
759
|
+
const result = await handler({ _mcpi_delegation: eveToCarol });
|
|
760
|
+
|
|
761
|
+
expect(result.isError).toBe(true);
|
|
762
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
763
|
+
expect(parsed.error).toBe('delegation_invalid');
|
|
764
|
+
// Eve's DID ≠ Bob's DID (the parent's subject)
|
|
765
|
+
expect(parsed.reason).toContain('issued by');
|
|
766
|
+
expect(parsed.reason).toContain('parent subject');
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it('rejects a chain where the leaf references a non-existent parent', async () => {
|
|
770
|
+
// Bob creates a delegation to Carol referencing a parent that doesn't
|
|
771
|
+
// exist. The chain resolver returns an empty array.
|
|
772
|
+
|
|
773
|
+
const bobToCarol = await issueVC({
|
|
774
|
+
from: bob,
|
|
775
|
+
to: carol,
|
|
776
|
+
scopes: ['query:x'],
|
|
777
|
+
parentId: 'non-existent-parent-id',
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
const { middleware } = await createServer({
|
|
781
|
+
delegation: {
|
|
782
|
+
resolveDelegationChain: async () => [],
|
|
783
|
+
},
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
const handler = middleware.wrapWithDelegation(
|
|
787
|
+
'query_x',
|
|
788
|
+
{ scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
|
|
789
|
+
async () => ({ content: [{ type: 'text', text: 'should not reach' }] }),
|
|
790
|
+
);
|
|
791
|
+
|
|
792
|
+
const result = await handler({ _mcpi_delegation: bobToCarol });
|
|
793
|
+
|
|
794
|
+
expect(result.isError).toBe(true);
|
|
795
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
796
|
+
expect(parsed.error).toBe('delegation_invalid');
|
|
797
|
+
expect(parsed.reason).toContain('empty');
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it('detects circular references in the delegation chain', async () => {
|
|
801
|
+
// A malicious actor constructs a chain that loops. The circular
|
|
802
|
+
// reference detector must catch this.
|
|
803
|
+
|
|
804
|
+
const graph = new DelegationGraphManager(new MemoryDelegationGraphStorage());
|
|
805
|
+
|
|
806
|
+
// Create a valid chain: A→B→C
|
|
807
|
+
await graph.registerDelegation({
|
|
808
|
+
id: 'del-1',
|
|
809
|
+
parentId: null,
|
|
810
|
+
issuerDid: alice.did,
|
|
811
|
+
subjectDid: bob.did,
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
await graph.registerDelegation({
|
|
815
|
+
id: 'del-2',
|
|
816
|
+
parentId: 'del-1',
|
|
817
|
+
issuerDid: bob.did,
|
|
818
|
+
subjectDid: carol.did,
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
// Verify valid chain works
|
|
822
|
+
const validResult = await graph.validateChain('del-2');
|
|
823
|
+
expect(validResult.valid).toBe(true);
|
|
824
|
+
|
|
825
|
+
// Verify chain depth
|
|
826
|
+
const depth = await graph.getDepth('del-2');
|
|
827
|
+
expect(depth).toBe(1); // root=0, child=1
|
|
828
|
+
|
|
829
|
+
// Verify ancestry
|
|
830
|
+
const isAncestor = await graph.isAncestor('del-1', 'del-2');
|
|
831
|
+
expect(isAncestor).toBe(true);
|
|
832
|
+
});
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
// =========================================================================
|
|
836
|
+
// 7. Three-hop chain validation
|
|
837
|
+
// =========================================================================
|
|
838
|
+
|
|
839
|
+
describe('7. Three-hop chain (Alice → Bob → Carol → Dave)', () => {
|
|
840
|
+
it('validates a three-hop chain with progressive attenuation', async () => {
|
|
841
|
+
// Extended transitive scenario: four parties, three delegations,
|
|
842
|
+
// each narrowing the scope further.
|
|
843
|
+
|
|
844
|
+
const dave = await createAgentIdentity();
|
|
845
|
+
|
|
846
|
+
// Alice → Bob: [query:x, update:y, delete:y]
|
|
847
|
+
const aliceToBob = await issueVC({
|
|
848
|
+
from: alice,
|
|
849
|
+
to: bob,
|
|
850
|
+
scopes: ['query:x', 'update:y', 'delete:y'],
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
// Bob → Carol: [query:x, update:y] (drops delete:y)
|
|
854
|
+
const bobToCarol = await issueVC({
|
|
855
|
+
from: bob,
|
|
856
|
+
to: carol,
|
|
857
|
+
scopes: ['query:x', 'update:y'],
|
|
858
|
+
parentId: aliceToBob.credentialSubject.delegation.id,
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
// Carol → Dave: [query:x] (drops update:y)
|
|
862
|
+
const carolToDave = await issueVC({
|
|
863
|
+
from: carol,
|
|
864
|
+
to: dave,
|
|
865
|
+
scopes: ['query:x'],
|
|
866
|
+
parentId: bobToCarol.credentialSubject.delegation.id,
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
const { middleware } = await createServer({
|
|
870
|
+
delegation: {
|
|
871
|
+
resolveDelegationChain: async () => [aliceToBob, bobToCarol],
|
|
872
|
+
},
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
const handler = middleware.wrapWithDelegation(
|
|
876
|
+
'query_x',
|
|
877
|
+
{ scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
|
|
878
|
+
async () => ({ content: [{ type: 'text', text: 'ok from Dave' }] }),
|
|
879
|
+
);
|
|
880
|
+
|
|
881
|
+
const result = await handler({ _mcpi_delegation: carolToDave });
|
|
882
|
+
expect(result.isError).toBeUndefined();
|
|
883
|
+
expect(result.content[0].text).toBe('ok from Dave');
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it('rejects if any link in a three-hop chain widens scope', async () => {
|
|
887
|
+
const dave = await createAgentIdentity();
|
|
888
|
+
|
|
889
|
+
// Alice → Bob: [query:x]
|
|
890
|
+
const aliceToBob = await issueVC({
|
|
891
|
+
from: alice,
|
|
892
|
+
to: bob,
|
|
893
|
+
scopes: ['query:x'],
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
// Bob → Carol: [query:x] — valid
|
|
897
|
+
const bobToCarol = await issueVC({
|
|
898
|
+
from: bob,
|
|
899
|
+
to: carol,
|
|
900
|
+
scopes: ['query:x'],
|
|
901
|
+
parentId: aliceToBob.credentialSubject.delegation.id,
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
// Carol → Dave: [query:x, update:y] — INVALID, widens scope
|
|
905
|
+
const carolToDave = await issueVC({
|
|
906
|
+
from: carol,
|
|
907
|
+
to: dave,
|
|
908
|
+
scopes: ['query:x', 'update:y'],
|
|
909
|
+
parentId: bobToCarol.credentialSubject.delegation.id,
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
const { middleware } = await createServer({
|
|
913
|
+
delegation: {
|
|
914
|
+
resolveDelegationChain: async () => [aliceToBob, bobToCarol],
|
|
915
|
+
},
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
const handler = middleware.wrapWithDelegation(
|
|
919
|
+
'query_x',
|
|
920
|
+
{ scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
|
|
921
|
+
async () => ({ content: [{ type: 'text', text: 'should not reach' }] }),
|
|
922
|
+
);
|
|
923
|
+
|
|
924
|
+
const result = await handler({ _mcpi_delegation: carolToDave });
|
|
925
|
+
|
|
926
|
+
expect(result.isError).toBe(true);
|
|
927
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
928
|
+
expect(parsed.error).toBe('delegation_invalid');
|
|
929
|
+
expect(parsed.reason).toContain('widens scopes');
|
|
930
|
+
expect(parsed.reason).toContain('update:y');
|
|
931
|
+
});
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
// =========================================================================
|
|
935
|
+
// 8. Outbound delegation propagation
|
|
936
|
+
// =========================================================================
|
|
937
|
+
|
|
938
|
+
describe('8. Outbound delegation propagation (provenance across service boundaries)', () => {
|
|
939
|
+
it('downstream service receives original agent DID and chain context in headers', async () => {
|
|
940
|
+
// Karp's key insight: the full delegation chain must be visible at
|
|
941
|
+
// every hop. MCP-I propagates this via outbound headers so downstream
|
|
942
|
+
// services can independently verify the chain.
|
|
943
|
+
|
|
944
|
+
const aliceToBob = await issueVC({
|
|
945
|
+
from: alice,
|
|
946
|
+
to: bob,
|
|
947
|
+
scopes: ['query:x'],
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
const delegation = aliceToBob.credentialSubject.delegation;
|
|
951
|
+
|
|
952
|
+
const headers = await buildOutboundDelegationHeaders({
|
|
953
|
+
session: {
|
|
954
|
+
sessionId: 'mcpi_test-session',
|
|
955
|
+
audience: 'aperture.example.com',
|
|
956
|
+
nonce: 'test-nonce',
|
|
957
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
958
|
+
createdAt: Date.now(),
|
|
959
|
+
lastActivity: Date.now(),
|
|
960
|
+
ttlMinutes: 60,
|
|
961
|
+
agentDid: alice.did,
|
|
962
|
+
identityState: 'authenticated',
|
|
963
|
+
},
|
|
964
|
+
delegation: {
|
|
965
|
+
id: delegation.id,
|
|
966
|
+
issuerDid: delegation.issuerDid,
|
|
967
|
+
subjectDid: delegation.subjectDid,
|
|
968
|
+
vcId: aliceToBob.id!,
|
|
969
|
+
constraints: delegation.constraints,
|
|
970
|
+
signature: aliceToBob.proof?.proofValue ?? '',
|
|
971
|
+
status: 'active',
|
|
972
|
+
},
|
|
973
|
+
serverIdentity: {
|
|
974
|
+
did: bob.did,
|
|
975
|
+
kid: bob.kid,
|
|
976
|
+
privateKey: bob.keyPair.privateKey,
|
|
977
|
+
},
|
|
978
|
+
targetUrl: 'https://cyberdyne.example.com/api/backup',
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
// Headers expose the full provenance
|
|
982
|
+
expect(headers['KYA-Agent-DID']).toBe(alice.did);
|
|
983
|
+
expect(headers['KYA-Delegation-Chain']).toBe(aliceToBob.id);
|
|
984
|
+
expect(headers['KYA-Session-Id']).toBe('mcpi_test-session');
|
|
985
|
+
// The proof is a signed JWT that downstream can verify
|
|
986
|
+
expect(headers['KYA-Delegation-Proof']).toMatch(
|
|
987
|
+
/^eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/,
|
|
988
|
+
);
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
// =========================================================================
|
|
993
|
+
// 9. Credential theft and replay prevention
|
|
994
|
+
// =========================================================================
|
|
995
|
+
|
|
996
|
+
describe('9. Credential theft mitigation', () => {
|
|
997
|
+
it('a stolen credential cannot be used at a different server (audience binding)', async () => {
|
|
998
|
+
// MCP-I Spec §11.8: If a DelegationCredential is intercepted, the
|
|
999
|
+
// audience constraint limits where it can be replayed.
|
|
1000
|
+
|
|
1001
|
+
const { did: legitimateServerDid } = await createServer();
|
|
1002
|
+
const { middleware: attackerServer } = await createServer();
|
|
1003
|
+
|
|
1004
|
+
// Legitimate delegation bound to specific server
|
|
1005
|
+
const vc = await issueVC({
|
|
1006
|
+
from: alice,
|
|
1007
|
+
to: bob,
|
|
1008
|
+
scopes: ['query:x'],
|
|
1009
|
+
audience: legitimateServerDid,
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
// Attacker intercepts VC and tries to use it at their server
|
|
1013
|
+
const handler = attackerServer.wrapWithDelegation(
|
|
1014
|
+
'query_x',
|
|
1015
|
+
{ scopeId: 'query:x', consentUrl: 'https://attacker.example/consent' },
|
|
1016
|
+
async () => ({ content: [{ type: 'text', text: 'stolen data' }] }),
|
|
1017
|
+
);
|
|
1018
|
+
|
|
1019
|
+
const result = await handler({ _mcpi_delegation: vc });
|
|
1020
|
+
|
|
1021
|
+
expect(result.isError).toBe(true);
|
|
1022
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
1023
|
+
expect(parsed.error).toBe('delegation_invalid');
|
|
1024
|
+
expect(parsed.reason).toContain('audience');
|
|
1025
|
+
});
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
// =========================================================================
|
|
1029
|
+
// 10. No delegation — fail-closed behavior
|
|
1030
|
+
// =========================================================================
|
|
1031
|
+
|
|
1032
|
+
describe('10. Fail-closed: no delegation means no access', () => {
|
|
1033
|
+
it('returns needs_authorization when no delegation is presented', async () => {
|
|
1034
|
+
// Karp's premise: with ACLs, the absence of an explicit deny means
|
|
1035
|
+
// implicit allow in many systems. With capabilities, the absence of
|
|
1036
|
+
// a capability means no access — fail-closed by default.
|
|
1037
|
+
|
|
1038
|
+
const { middleware } = await createServer();
|
|
1039
|
+
|
|
1040
|
+
const handler = middleware.wrapWithDelegation(
|
|
1041
|
+
'sensitive_op',
|
|
1042
|
+
{
|
|
1043
|
+
scopeId: 'admin:danger',
|
|
1044
|
+
consentUrl: 'https://aperture.example/consent',
|
|
1045
|
+
},
|
|
1046
|
+
async () => ({ content: [{ type: 'text', text: 'should not reach' }] }),
|
|
1047
|
+
);
|
|
1048
|
+
|
|
1049
|
+
// Call without any delegation credential
|
|
1050
|
+
const result = await handler({ some_arg: 'value' });
|
|
1051
|
+
|
|
1052
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
1053
|
+
expect(parsed.error).toBe('needs_authorization');
|
|
1054
|
+
expect(parsed.scopes).toContain('admin:danger');
|
|
1055
|
+
expect(parsed.authorizationUrl).toBe(
|
|
1056
|
+
'https://aperture.example/consent',
|
|
1057
|
+
);
|
|
1058
|
+
});
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
// =========================================================================
|
|
1062
|
+
// 11. requireAudienceOnRedelegation enforcement
|
|
1063
|
+
// =========================================================================
|
|
1064
|
+
|
|
1065
|
+
describe('11. requireAudienceOnRedelegation — strict confused-deputy prevention', () => {
|
|
1066
|
+
it('rejects a re-delegation without audience when enforcement is enabled', async () => {
|
|
1067
|
+
// Core fix for transitive access: re-delegations MUST carry an
|
|
1068
|
+
// audience constraint so they cannot be forwarded to unintended servers.
|
|
1069
|
+
|
|
1070
|
+
const { middleware, did: serverDid } = await createServer({
|
|
1071
|
+
delegation: {
|
|
1072
|
+
requireAudienceOnRedelegation: true,
|
|
1073
|
+
resolveDelegationChain: async () => [aliceToBob],
|
|
1074
|
+
},
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
// Alice delegates to Bob WITH audience (root — binds to this server)
|
|
1078
|
+
const aliceToBob = await issueVC({
|
|
1079
|
+
from: alice,
|
|
1080
|
+
to: bob,
|
|
1081
|
+
scopes: ['query:x'],
|
|
1082
|
+
audience: serverDid,
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
// Bob re-delegates to Carol WITHOUT audience — this is the hazard
|
|
1086
|
+
const bobToCarol = await issueVC({
|
|
1087
|
+
from: bob,
|
|
1088
|
+
to: carol,
|
|
1089
|
+
scopes: ['query:x'],
|
|
1090
|
+
parentId: aliceToBob.credentialSubject.delegation.id,
|
|
1091
|
+
// no audience
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
const handler = middleware.wrapWithDelegation(
|
|
1095
|
+
'query_x',
|
|
1096
|
+
{ scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
|
|
1097
|
+
async () => ({ content: [{ type: 'text', text: 'should not reach' }] }),
|
|
1098
|
+
);
|
|
1099
|
+
|
|
1100
|
+
const result = await handler({ _mcpi_delegation: bobToCarol });
|
|
1101
|
+
|
|
1102
|
+
expect(result.isError).toBe(true);
|
|
1103
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
1104
|
+
expect(parsed.error).toBe('delegation_invalid');
|
|
1105
|
+
expect(parsed.reason).toContain('re-delegation');
|
|
1106
|
+
expect(parsed.reason).toContain('audience');
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
it('accepts a re-delegation WITH audience when enforcement is enabled', async () => {
|
|
1110
|
+
const { middleware, did: serverDid } = await createServer({
|
|
1111
|
+
delegation: {
|
|
1112
|
+
requireAudienceOnRedelegation: true,
|
|
1113
|
+
resolveDelegationChain: async (leaf) => {
|
|
1114
|
+
// Dynamically return the correct parent
|
|
1115
|
+
return [aliceToBobVC];
|
|
1116
|
+
},
|
|
1117
|
+
},
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
// Both delegations carry audience — compliant
|
|
1121
|
+
const aliceToBobVC = await issueVC({
|
|
1122
|
+
from: alice,
|
|
1123
|
+
to: bob,
|
|
1124
|
+
scopes: ['query:x'],
|
|
1125
|
+
audience: serverDid,
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
const bobToCarol = await issueVC({
|
|
1129
|
+
from: bob,
|
|
1130
|
+
to: carol,
|
|
1131
|
+
scopes: ['query:x'],
|
|
1132
|
+
parentId: aliceToBobVC.credentialSubject.delegation.id,
|
|
1133
|
+
audience: serverDid,
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
const handler = middleware.wrapWithDelegation(
|
|
1137
|
+
'query_x',
|
|
1138
|
+
{ scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
|
|
1139
|
+
async () => ({ content: [{ type: 'text', text: 'ok' }] }),
|
|
1140
|
+
);
|
|
1141
|
+
|
|
1142
|
+
const result = await handler({ _mcpi_delegation: bobToCarol });
|
|
1143
|
+
expect(result.isError).toBeUndefined();
|
|
1144
|
+
expect(result.content[0].text).toBe('ok');
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
it('allows re-delegations without audience when enforcement is disabled (default)', async () => {
|
|
1148
|
+
// Backward compatibility: the flag defaults to false, so existing
|
|
1149
|
+
// integrations that omit audience on re-delegations continue to work.
|
|
1150
|
+
|
|
1151
|
+
const aliceToBob = await issueVC({
|
|
1152
|
+
from: alice,
|
|
1153
|
+
to: bob,
|
|
1154
|
+
scopes: ['query:x'],
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
const bobToCarol = await issueVC({
|
|
1158
|
+
from: bob,
|
|
1159
|
+
to: carol,
|
|
1160
|
+
scopes: ['query:x'],
|
|
1161
|
+
parentId: aliceToBob.credentialSubject.delegation.id,
|
|
1162
|
+
// no audience — allowed when flag is off
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
const { middleware } = await createServer({
|
|
1166
|
+
delegation: {
|
|
1167
|
+
// requireAudienceOnRedelegation not set (defaults to false)
|
|
1168
|
+
resolveDelegationChain: async () => [aliceToBob],
|
|
1169
|
+
},
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
const handler = middleware.wrapWithDelegation(
|
|
1173
|
+
'query_x',
|
|
1174
|
+
{ scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
|
|
1175
|
+
async () => ({ content: [{ type: 'text', text: 'ok' }] }),
|
|
1176
|
+
);
|
|
1177
|
+
|
|
1178
|
+
const result = await handler({ _mcpi_delegation: bobToCarol });
|
|
1179
|
+
expect(result.isError).toBeUndefined();
|
|
1180
|
+
expect(result.content[0].text).toBe('ok');
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
it('rejects at any hop in a three-hop chain missing audience', async () => {
|
|
1184
|
+
const dave = await createAgentIdentity();
|
|
1185
|
+
|
|
1186
|
+
const { middleware, did: serverDid } = await createServer({
|
|
1187
|
+
delegation: {
|
|
1188
|
+
requireAudienceOnRedelegation: true,
|
|
1189
|
+
resolveDelegationChain: async () => [aliceToBobVC, bobToCarolVC],
|
|
1190
|
+
},
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
const aliceToBobVC = await issueVC({
|
|
1194
|
+
from: alice,
|
|
1195
|
+
to: bob,
|
|
1196
|
+
scopes: ['query:x'],
|
|
1197
|
+
audience: serverDid,
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
// Bob → Carol has audience — OK
|
|
1201
|
+
const bobToCarolVC = await issueVC({
|
|
1202
|
+
from: bob,
|
|
1203
|
+
to: carol,
|
|
1204
|
+
scopes: ['query:x'],
|
|
1205
|
+
parentId: aliceToBobVC.credentialSubject.delegation.id,
|
|
1206
|
+
audience: serverDid,
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
// Carol → Dave is missing audience — should fail
|
|
1210
|
+
const carolToDave = await issueVC({
|
|
1211
|
+
from: carol,
|
|
1212
|
+
to: dave,
|
|
1213
|
+
scopes: ['query:x'],
|
|
1214
|
+
parentId: bobToCarolVC.credentialSubject.delegation.id,
|
|
1215
|
+
// no audience
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
const handler = middleware.wrapWithDelegation(
|
|
1219
|
+
'query_x',
|
|
1220
|
+
{ scopeId: 'query:x', consentUrl: 'https://aperture.example/consent' },
|
|
1221
|
+
async () => ({ content: [{ type: 'text', text: 'should not reach' }] }),
|
|
1222
|
+
);
|
|
1223
|
+
|
|
1224
|
+
const result = await handler({ _mcpi_delegation: carolToDave });
|
|
1225
|
+
|
|
1226
|
+
expect(result.isError).toBe(true);
|
|
1227
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
1228
|
+
expect(parsed.error).toBe('delegation_invalid');
|
|
1229
|
+
expect(parsed.reason).toContain('re-delegation');
|
|
1230
|
+
expect(parsed.reason).toContain('audience');
|
|
1231
|
+
});
|
|
1232
|
+
});
|
|
1233
|
+
});
|