@ixo/ucan 1.0.0 → 1.1.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,611 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import * as Client from '@ucanto/client';
3
+ import { ed25519 } from '@ucanto/principal';
4
+ import { createUCANValidator } from './validator.js';
5
+ import { defineCapability, Schema } from '../capabilities/capability.js';
6
+ import {
7
+ createDelegation,
8
+ createInvocation,
9
+ serializeInvocation,
10
+ type Capability,
11
+ } from '../client/create-client.js';
12
+
13
+ /**
14
+ * Helper: generate an ed25519 keypair
15
+ */
16
+ async function keygen() {
17
+ const signer = await ed25519.Signer.generate();
18
+ return { signer, did: signer.did() };
19
+ }
20
+
21
+ /**
22
+ * Simple capability without caveats
23
+ */
24
+ const TestRead = defineCapability({
25
+ can: 'test/read',
26
+ protocol: 'ixo:',
27
+ });
28
+
29
+ /**
30
+ * Capability with limit caveat
31
+ */
32
+ const EmployeesRead = defineCapability({
33
+ can: 'employees/read',
34
+ protocol: 'myapp:',
35
+ nb: { limit: Schema.integer().optional() },
36
+ derives: (claimed, delegated) => {
37
+ const claimedLimit = claimed.nb?.limit ?? Infinity;
38
+ const delegatedLimit = delegated.nb?.limit ?? Infinity;
39
+ if (claimedLimit > delegatedLimit) {
40
+ return {
41
+ error: new Error(
42
+ `Cannot request limit=${claimedLimit}, delegation only allows limit=${delegatedLimit}`,
43
+ ),
44
+ };
45
+ }
46
+ return { ok: {} };
47
+ },
48
+ });
49
+
50
+ describe('UCAN Validator', () => {
51
+ describe('proofChain', () => {
52
+ it('should return single-element chain for direct root invocation', async () => {
53
+ const server = await keygen();
54
+ const root = await keygen();
55
+
56
+ const validator = await createUCANValidator({
57
+ serverDid: server.did,
58
+ rootIssuers: [root.did],
59
+ });
60
+
61
+ const invocation = Client.invoke({
62
+ issuer: root.signer,
63
+ audience: ed25519.Verifier.parse(server.did),
64
+ capability: {
65
+ can: 'test/read' as const,
66
+ with: `ixo:resource:123` as const,
67
+ },
68
+ proofs: [],
69
+ });
70
+
71
+ const serialized = await serializeInvocation(invocation);
72
+ const result = await validator.validate(
73
+ serialized,
74
+ TestRead,
75
+ 'ixo:resource:123',
76
+ );
77
+
78
+ expect(result.ok).toBe(true);
79
+ expect(result.proofChain).toEqual([root.did]);
80
+ });
81
+
82
+ it('should return two-element chain for root -> user delegation', async () => {
83
+ const server = await keygen();
84
+ const root = await keygen();
85
+ const user = await keygen();
86
+
87
+ const validator = await createUCANValidator({
88
+ serverDid: server.did,
89
+ rootIssuers: [root.did],
90
+ });
91
+
92
+ const delegation = await Client.delegate({
93
+ issuer: root.signer,
94
+ audience: user.signer,
95
+ capabilities: [
96
+ {
97
+ can: 'test/read' as const,
98
+ with: 'ixo:resource:123' as const,
99
+ },
100
+ ],
101
+ });
102
+
103
+ const invocation = Client.invoke({
104
+ issuer: user.signer,
105
+ audience: ed25519.Verifier.parse(server.did),
106
+ capability: {
107
+ can: 'test/read' as const,
108
+ with: 'ixo:resource:123' as const,
109
+ },
110
+ proofs: [delegation],
111
+ });
112
+
113
+ const serialized = await serializeInvocation(invocation);
114
+ const result = await validator.validate(
115
+ serialized,
116
+ TestRead,
117
+ 'ixo:resource:123',
118
+ );
119
+
120
+ expect(result.ok).toBe(true);
121
+ expect(result.proofChain).toEqual([root.did, user.did]);
122
+ });
123
+
124
+ it('should return three-element chain for root -> alice -> bob', async () => {
125
+ const server = await keygen();
126
+ const root = await keygen();
127
+ const alice = await keygen();
128
+ const bob = await keygen();
129
+
130
+ const validator = await createUCANValidator({
131
+ serverDid: server.did,
132
+ rootIssuers: [root.did],
133
+ });
134
+
135
+ const rootToAlice = await Client.delegate({
136
+ issuer: root.signer,
137
+ audience: alice.signer,
138
+ capabilities: [
139
+ {
140
+ can: 'test/read' as const,
141
+ with: 'ixo:resource:123' as const,
142
+ },
143
+ ],
144
+ });
145
+
146
+ const aliceToBob = await Client.delegate({
147
+ issuer: alice.signer,
148
+ audience: bob.signer,
149
+ capabilities: [
150
+ {
151
+ can: 'test/read' as const,
152
+ with: 'ixo:resource:123' as const,
153
+ },
154
+ ],
155
+ proofs: [rootToAlice],
156
+ });
157
+
158
+ const invocation = Client.invoke({
159
+ issuer: bob.signer,
160
+ audience: ed25519.Verifier.parse(server.did),
161
+ capability: {
162
+ can: 'test/read' as const,
163
+ with: 'ixo:resource:123' as const,
164
+ },
165
+ proofs: [aliceToBob],
166
+ });
167
+
168
+ const serialized = await serializeInvocation(invocation);
169
+ const result = await validator.validate(
170
+ serialized,
171
+ TestRead,
172
+ 'ixo:resource:123',
173
+ );
174
+
175
+ expect(result.ok).toBe(true);
176
+ expect(result.proofChain).toEqual([root.did, alice.did, bob.did]);
177
+ });
178
+ });
179
+
180
+ describe('expiration', () => {
181
+ it('should return undefined expiration when no expiration is set (Infinity)', async () => {
182
+ const server = await keygen();
183
+ const root = await keygen();
184
+ const user = await keygen();
185
+
186
+ const validator = await createUCANValidator({
187
+ serverDid: server.did,
188
+ rootIssuers: [root.did],
189
+ });
190
+
191
+ // Using createDelegation/createInvocation which default to Infinity
192
+ const delegation = await createDelegation({
193
+ issuer: root.signer,
194
+ audience: user.did,
195
+ capabilities: [
196
+ {
197
+ can: 'test/read' as Capability['can'],
198
+ with: 'ixo:resource:123' as Capability['with'],
199
+ },
200
+ ],
201
+ });
202
+
203
+ const invocation = await createInvocation({
204
+ issuer: user.signer,
205
+ audience: server.did,
206
+ capability: {
207
+ can: 'test/read' as Capability['can'],
208
+ with: 'ixo:resource:123' as Capability['with'],
209
+ },
210
+ proofs: [delegation],
211
+ });
212
+
213
+ const serialized = await serializeInvocation(invocation);
214
+ const result = await validator.validate(
215
+ serialized,
216
+ TestRead,
217
+ 'ixo:resource:123',
218
+ );
219
+
220
+ expect(result.ok).toBe(true);
221
+ // No expiration set → defaults to Infinity → filtered out
222
+ expect(result.expiration).toBeUndefined();
223
+ });
224
+
225
+ it('should return delegation expiration when set', async () => {
226
+ const server = await keygen();
227
+ const root = await keygen();
228
+ const user = await keygen();
229
+
230
+ const futureExp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
231
+
232
+ const validator = await createUCANValidator({
233
+ serverDid: server.did,
234
+ rootIssuers: [root.did],
235
+ });
236
+
237
+ const delegation = await Client.delegate({
238
+ issuer: root.signer,
239
+ audience: user.signer,
240
+ capabilities: [
241
+ {
242
+ can: 'test/read' as const,
243
+ with: 'ixo:resource:123' as const,
244
+ },
245
+ ],
246
+ expiration: futureExp,
247
+ });
248
+
249
+ const invocation = Client.invoke({
250
+ issuer: user.signer,
251
+ audience: ed25519.Verifier.parse(server.did),
252
+ capability: {
253
+ can: 'test/read' as const,
254
+ with: 'ixo:resource:123' as const,
255
+ },
256
+ proofs: [delegation],
257
+ });
258
+
259
+ const serialized = await serializeInvocation(invocation);
260
+ const result = await validator.validate(
261
+ serialized,
262
+ TestRead,
263
+ 'ixo:resource:123',
264
+ );
265
+
266
+ expect(result.ok).toBe(true);
267
+ expect(result.expiration).toBeDefined();
268
+ expect(result.expiration).toBeLessThanOrEqual(futureExp);
269
+ });
270
+
271
+ it('should return earliest expiration across the chain', async () => {
272
+ const server = await keygen();
273
+ const root = await keygen();
274
+ const alice = await keygen();
275
+ const bob = await keygen();
276
+
277
+ const laterExp = Math.floor(Date.now() / 1000) + 7200; // 2 hours
278
+ const earlierExp = Math.floor(Date.now() / 1000) + 3600; // 1 hour
279
+
280
+ const validator = await createUCANValidator({
281
+ serverDid: server.did,
282
+ rootIssuers: [root.did],
283
+ });
284
+
285
+ // Root -> Alice with later expiration
286
+ const rootToAlice = await Client.delegate({
287
+ issuer: root.signer,
288
+ audience: alice.signer,
289
+ capabilities: [
290
+ {
291
+ can: 'test/read' as const,
292
+ with: 'ixo:resource:123' as const,
293
+ },
294
+ ],
295
+ expiration: laterExp,
296
+ });
297
+
298
+ // Alice -> Bob with earlier expiration
299
+ const aliceToBob = await Client.delegate({
300
+ issuer: alice.signer,
301
+ audience: bob.signer,
302
+ capabilities: [
303
+ {
304
+ can: 'test/read' as const,
305
+ with: 'ixo:resource:123' as const,
306
+ },
307
+ ],
308
+ expiration: earlierExp,
309
+ proofs: [rootToAlice],
310
+ });
311
+
312
+ const invocation = Client.invoke({
313
+ issuer: bob.signer,
314
+ audience: ed25519.Verifier.parse(server.did),
315
+ capability: {
316
+ can: 'test/read' as const,
317
+ with: 'ixo:resource:123' as const,
318
+ },
319
+ proofs: [aliceToBob],
320
+ });
321
+
322
+ const serialized = await serializeInvocation(invocation);
323
+ const result = await validator.validate(
324
+ serialized,
325
+ TestRead,
326
+ 'ixo:resource:123',
327
+ );
328
+
329
+ expect(result.ok).toBe(true);
330
+ expect(result.expiration).toBeDefined();
331
+ // Should be the earlier expiration (alice->bob's 1 hour, not root->alice's 2 hours)
332
+ expect(result.expiration).toBeLessThanOrEqual(earlierExp);
333
+ });
334
+ });
335
+
336
+ describe('validation failures', () => {
337
+ it('should reject malformed base64 input', async () => {
338
+ const server = await keygen();
339
+ const root = await keygen();
340
+
341
+ const validator = await createUCANValidator({
342
+ serverDid: server.did,
343
+ rootIssuers: [root.did],
344
+ });
345
+
346
+ const result = await validator.validate(
347
+ 'not-valid-base64!!!',
348
+ TestRead,
349
+ 'ixo:resource:123',
350
+ );
351
+
352
+ expect(result.ok).toBe(false);
353
+ expect(result.error?.code).toBe('INVALID_FORMAT');
354
+ });
355
+
356
+ it('should reject invocation with wrong audience', async () => {
357
+ const server = await keygen();
358
+ const wrongServer = await keygen();
359
+ const root = await keygen();
360
+
361
+ const validator = await createUCANValidator({
362
+ serverDid: server.did,
363
+ rootIssuers: [root.did],
364
+ });
365
+
366
+ // Invocation addressed to wrong server
367
+ const invocation = Client.invoke({
368
+ issuer: root.signer,
369
+ audience: ed25519.Verifier.parse(wrongServer.did),
370
+ capability: {
371
+ can: 'test/read' as const,
372
+ with: 'ixo:resource:123' as const,
373
+ },
374
+ proofs: [],
375
+ });
376
+
377
+ const serialized = await serializeInvocation(invocation);
378
+ const result = await validator.validate(
379
+ serialized,
380
+ TestRead,
381
+ 'ixo:resource:123',
382
+ );
383
+
384
+ expect(result.ok).toBe(false);
385
+ expect(result.error?.code).toBe('UNAUTHORIZED');
386
+ });
387
+
388
+ it('should reject invocation with untrusted root', async () => {
389
+ const server = await keygen();
390
+ const trustedRoot = await keygen();
391
+ const untrustedRoot = await keygen();
392
+ const user = await keygen();
393
+
394
+ const validator = await createUCANValidator({
395
+ serverDid: server.did,
396
+ rootIssuers: [trustedRoot.did], // Only trustedRoot is trusted
397
+ });
398
+
399
+ // Delegation from untrusted root
400
+ const delegation = await Client.delegate({
401
+ issuer: untrustedRoot.signer,
402
+ audience: user.signer,
403
+ capabilities: [
404
+ {
405
+ can: 'test/read' as const,
406
+ with: 'ixo:resource:123' as const,
407
+ },
408
+ ],
409
+ });
410
+
411
+ const invocation = Client.invoke({
412
+ issuer: user.signer,
413
+ audience: ed25519.Verifier.parse(server.did),
414
+ capability: {
415
+ can: 'test/read' as const,
416
+ with: 'ixo:resource:123' as const,
417
+ },
418
+ proofs: [delegation],
419
+ });
420
+
421
+ const serialized = await serializeInvocation(invocation);
422
+ const result = await validator.validate(
423
+ serialized,
424
+ TestRead,
425
+ 'ixo:resource:123',
426
+ );
427
+
428
+ expect(result.ok).toBe(false);
429
+ expect(result.error?.code).toBe('UNAUTHORIZED');
430
+ });
431
+
432
+ it('should reject invocation with mismatched resource', async () => {
433
+ const server = await keygen();
434
+ const root = await keygen();
435
+ const user = await keygen();
436
+
437
+ const validator = await createUCANValidator({
438
+ serverDid: server.did,
439
+ rootIssuers: [root.did],
440
+ });
441
+
442
+ const delegation = await Client.delegate({
443
+ issuer: root.signer,
444
+ audience: user.signer,
445
+ capabilities: [
446
+ {
447
+ can: 'test/read' as const,
448
+ with: 'ixo:resource:123' as const,
449
+ },
450
+ ],
451
+ });
452
+
453
+ const invocation = Client.invoke({
454
+ issuer: user.signer,
455
+ audience: ed25519.Verifier.parse(server.did),
456
+ capability: {
457
+ can: 'test/read' as const,
458
+ with: 'ixo:resource:123' as const,
459
+ },
460
+ proofs: [delegation],
461
+ });
462
+
463
+ const serialized = await serializeInvocation(invocation);
464
+ // Validate against a different resource than what was delegated
465
+ const result = await validator.validate(
466
+ serialized,
467
+ TestRead,
468
+ 'ixo:resource:999',
469
+ );
470
+
471
+ expect(result.ok).toBe(false);
472
+ expect(result.error?.code).toBe('UNAUTHORIZED');
473
+ });
474
+ });
475
+
476
+ describe('caveat validation', () => {
477
+ it('should pass when caveats are within bounds', async () => {
478
+ const server = await keygen();
479
+ const root = await keygen();
480
+ const user = await keygen();
481
+
482
+ const resource = `myapp:${server.did}` as const;
483
+
484
+ const validator = await createUCANValidator({
485
+ serverDid: server.did,
486
+ rootIssuers: [root.did],
487
+ });
488
+
489
+ const delegation = await Client.delegate({
490
+ issuer: root.signer,
491
+ audience: user.signer,
492
+ capabilities: [
493
+ {
494
+ can: 'employees/read' as const,
495
+ with: resource,
496
+ nb: { limit: 50 },
497
+ },
498
+ ],
499
+ });
500
+
501
+ const invocation = Client.invoke({
502
+ issuer: user.signer,
503
+ audience: ed25519.Verifier.parse(server.did),
504
+ capability: {
505
+ can: 'employees/read' as const,
506
+ with: resource,
507
+ nb: { limit: 25 },
508
+ },
509
+ proofs: [delegation],
510
+ });
511
+
512
+ const serialized = await serializeInvocation(invocation);
513
+ const result = await validator.validate(
514
+ serialized,
515
+ EmployeesRead,
516
+ resource,
517
+ );
518
+
519
+ expect(result.ok).toBe(true);
520
+ expect(result.capability?.nb?.limit).toBe(25);
521
+ });
522
+
523
+ it('should reject when caveats exceed delegated bounds', async () => {
524
+ const server = await keygen();
525
+ const root = await keygen();
526
+ const user = await keygen();
527
+
528
+ const resource = `myapp:${server.did}` as const;
529
+
530
+ const validator = await createUCANValidator({
531
+ serverDid: server.did,
532
+ rootIssuers: [root.did],
533
+ });
534
+
535
+ const delegation = await Client.delegate({
536
+ issuer: root.signer,
537
+ audience: user.signer,
538
+ capabilities: [
539
+ {
540
+ can: 'employees/read' as const,
541
+ with: resource,
542
+ nb: { limit: 25 },
543
+ },
544
+ ],
545
+ });
546
+
547
+ // User tries to exceed their limit
548
+ const invocation = Client.invoke({
549
+ issuer: user.signer,
550
+ audience: ed25519.Verifier.parse(server.did),
551
+ capability: {
552
+ can: 'employees/read' as const,
553
+ with: resource,
554
+ nb: { limit: 100 },
555
+ },
556
+ proofs: [delegation],
557
+ });
558
+
559
+ const serialized = await serializeInvocation(invocation);
560
+ const result = await validator.validate(
561
+ serialized,
562
+ EmployeesRead,
563
+ resource,
564
+ );
565
+
566
+ expect(result.ok).toBe(false);
567
+ expect(result.error?.code).toBe('CAVEAT_VIOLATION');
568
+ });
569
+ });
570
+
571
+ describe('replay protection', () => {
572
+ it('should reject replayed invocations', async () => {
573
+ const server = await keygen();
574
+ const root = await keygen();
575
+
576
+ const validator = await createUCANValidator({
577
+ serverDid: server.did,
578
+ rootIssuers: [root.did],
579
+ });
580
+
581
+ const invocation = Client.invoke({
582
+ issuer: root.signer,
583
+ audience: ed25519.Verifier.parse(server.did),
584
+ capability: {
585
+ can: 'test/read' as const,
586
+ with: 'ixo:resource:123' as const,
587
+ },
588
+ proofs: [],
589
+ });
590
+
591
+ const serialized = await serializeInvocation(invocation);
592
+
593
+ // First validation should pass
594
+ const result1 = await validator.validate(
595
+ serialized,
596
+ TestRead,
597
+ 'ixo:resource:123',
598
+ );
599
+ expect(result1.ok).toBe(true);
600
+
601
+ // Second validation (replay) should fail
602
+ const result2 = await validator.validate(
603
+ serialized,
604
+ TestRead,
605
+ 'ixo:resource:123',
606
+ );
607
+ expect(result2.ok).toBe(false);
608
+ expect(result2.error?.code).toBe('REPLAY');
609
+ });
610
+ });
611
+ });