@ixo/ucan 1.1.0 → 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/.turbo/turbo-build.log +1 -1
- package/README.md +72 -11
- package/dist/client/create-client.d.ts +4 -2
- package/dist/client/create-client.d.ts.map +1 -1
- package/dist/client/create-client.js +2 -0
- package/dist/client/create-client.js.map +1 -1
- package/dist/did/web-resolver.d.ts +7 -0
- package/dist/did/web-resolver.d.ts.map +1 -0
- package/dist/did/web-resolver.js +78 -0
- package/dist/did/web-resolver.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/validator/validator.d.ts +2 -0
- package/dist/validator/validator.d.ts.map +1 -1
- package/dist/validator/validator.js +152 -6
- package/dist/validator/validator.js.map +1 -1
- package/package.json +18 -7
- package/src/client/create-client.ts +8 -1
- package/src/did/web-resolver.ts +140 -0
- package/src/index.ts +6 -0
- package/src/validator/validator.test.ts +502 -0
- package/src/validator/validator.ts +253 -12
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { ed25519 } from '@ucanto/principal';
|
|
16
|
-
import { Delegation } from '@ucanto/core';
|
|
16
|
+
import { Delegation, UCAN } from '@ucanto/core';
|
|
17
17
|
import { claim } from '@ucanto/validator';
|
|
18
18
|
import { type capability } from '@ucanto/validator';
|
|
19
19
|
import type { DIDKeyResolver, InvocationStore } from '../types.js';
|
|
@@ -92,6 +92,13 @@ export interface ValidateResult {
|
|
|
92
92
|
*/
|
|
93
93
|
proofChain?: string[];
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Facts attached to the invocation (UCAN spec §3.2.4).
|
|
97
|
+
* Verifiable claims and proofs of knowledge supporting the invocation.
|
|
98
|
+
* Empty array if no facts were attached.
|
|
99
|
+
*/
|
|
100
|
+
facts?: Record<string, unknown>[];
|
|
101
|
+
|
|
95
102
|
/** Error details (if invalid) */
|
|
96
103
|
error?: {
|
|
97
104
|
code:
|
|
@@ -132,6 +139,27 @@ export interface UCANValidator {
|
|
|
132
139
|
resource: string,
|
|
133
140
|
): Promise<ValidateResult>;
|
|
134
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Validate a delegation (verify signatures, audience, expiration, and proof chain)
|
|
144
|
+
*
|
|
145
|
+
* Unlike `validate()` which validates invocations against a capability definition,
|
|
146
|
+
* this method validates a standalone delegation token — verifying the cryptographic
|
|
147
|
+
* signature chain, checking audience matches this server, and validating expiration.
|
|
148
|
+
*
|
|
149
|
+
* @param delegationBase64 - Base64-encoded CAR containing the delegation
|
|
150
|
+
* @returns Validation result
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```typescript
|
|
154
|
+
* const result = await validator.validateDelegation(delegationBase64);
|
|
155
|
+
* if (result.ok) {
|
|
156
|
+
* console.log('Delegation from:', result.invoker);
|
|
157
|
+
* console.log('Capabilities:', result.capability);
|
|
158
|
+
* }
|
|
159
|
+
* ```
|
|
160
|
+
*/
|
|
161
|
+
validateDelegation(delegationBase64: string): Promise<ValidateResult>;
|
|
162
|
+
|
|
135
163
|
/**
|
|
136
164
|
* The server's public DID (as provided in options)
|
|
137
165
|
*/
|
|
@@ -180,15 +208,20 @@ export async function createUCANValidator(
|
|
|
180
208
|
const invocationStore =
|
|
181
209
|
options.invocationStore ?? new InMemoryInvocationStore();
|
|
182
210
|
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
211
|
+
// Lazily resolve server DID to a Verifier.
|
|
212
|
+
// Only needed for validate() (invocations), NOT for validateDelegation().
|
|
213
|
+
// This avoids requiring Ed25519 keys on the server DID doc when only
|
|
214
|
+
// delegation validation is needed.
|
|
215
|
+
let serverVerifier: Verifier | undefined;
|
|
216
|
+
|
|
217
|
+
async function getServerVerifier(): Promise<Verifier> {
|
|
218
|
+
if (serverVerifier) return serverVerifier;
|
|
219
|
+
|
|
220
|
+
if (options.serverDid.startsWith('did:key:')) {
|
|
221
|
+
serverVerifier = ed25519.Verifier.parse(options.serverDid);
|
|
222
|
+
return serverVerifier;
|
|
223
|
+
}
|
|
186
224
|
|
|
187
|
-
if (options.serverDid.startsWith('did:key:')) {
|
|
188
|
-
// did:key can be parsed directly (contains the public key)
|
|
189
|
-
serverVerifier = ed25519.Verifier.parse(options.serverDid);
|
|
190
|
-
} else {
|
|
191
|
-
// Non-did:key requires resolution
|
|
192
225
|
if (!options.didResolver) {
|
|
193
226
|
throw new Error(
|
|
194
227
|
`Cannot use ${options.serverDid} as server DID without a didResolver. ` +
|
|
@@ -213,13 +246,13 @@ export async function createUCANValidator(
|
|
|
213
246
|
);
|
|
214
247
|
}
|
|
215
248
|
|
|
216
|
-
// Use the first key (primary key)
|
|
217
249
|
const keyDid = resolved.ok[0];
|
|
218
250
|
if (!keyDid) {
|
|
219
251
|
throw new Error(`No valid key found for server DID ${options.serverDid}`);
|
|
220
252
|
}
|
|
221
253
|
|
|
222
254
|
serverVerifier = ed25519.Verifier.parse(keyDid);
|
|
255
|
+
return serverVerifier;
|
|
223
256
|
}
|
|
224
257
|
|
|
225
258
|
// Create DID resolver for use during validation (for issuers in delegation chain)
|
|
@@ -305,6 +338,109 @@ export async function createUCANValidator(
|
|
|
305
338
|
return exp ?? parentExp;
|
|
306
339
|
}
|
|
307
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Recursively verify signatures across a delegation chain.
|
|
343
|
+
* For each delegation: resolve issuer DID → did:key, verify signature,
|
|
344
|
+
* then check proof chain consistency and recurse into proofs.
|
|
345
|
+
*/
|
|
346
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- delegation type from Delegation.extract() is complex
|
|
347
|
+
async function verifyDelegationChain(delegation: any): Promise<ValidateResult> {
|
|
348
|
+
const issuerDid: string = delegation.issuer.did();
|
|
349
|
+
|
|
350
|
+
// Resolve issuer DID to did:key
|
|
351
|
+
const resolved = await resolveDIDKey(
|
|
352
|
+
issuerDid as `did:${string}:${string}`,
|
|
353
|
+
);
|
|
354
|
+
if ('error' in resolved) {
|
|
355
|
+
return {
|
|
356
|
+
ok: false,
|
|
357
|
+
error: {
|
|
358
|
+
code: 'INVALID_SIGNATURE',
|
|
359
|
+
message: `Cannot resolve issuer DID ${issuerDid}: ${resolved.error?.message ?? 'unknown'}`,
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!resolved.ok || resolved.ok.length === 0) {
|
|
365
|
+
return {
|
|
366
|
+
ok: false,
|
|
367
|
+
error: {
|
|
368
|
+
code: 'INVALID_SIGNATURE',
|
|
369
|
+
message: `No keys found for issuer DID ${issuerDid}`,
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const didKey = resolved.ok[0]!;
|
|
375
|
+
const realVerifier = ed25519.Verifier.parse(didKey);
|
|
376
|
+
|
|
377
|
+
// Get the UCAN View from the delegation for signature verification.
|
|
378
|
+
// delegation.data returns the @ipld/dag-ucan View which has .model and .signature
|
|
379
|
+
const ucanView = delegation.data;
|
|
380
|
+
|
|
381
|
+
// Create a wrapper verifier: uses the issuer's original DID for the
|
|
382
|
+
// DID equality check inside UCAN.verifySignature, but delegates actual
|
|
383
|
+
// cryptographic verification to the resolved did:key verifier.
|
|
384
|
+
// This is necessary because did:ixo issuers sign with Ed25519 keys but
|
|
385
|
+
// the UCAN's iss field contains did:ixo, not did:key.
|
|
386
|
+
const wrappedVerifier = {
|
|
387
|
+
did: () => issuerDid,
|
|
388
|
+
verify: (payload: Uint8Array, signature: unknown) =>
|
|
389
|
+
realVerifier.verify(
|
|
390
|
+
payload,
|
|
391
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SigAlg type mismatch between @ipld/dag-ucan and @ucanto/principal
|
|
392
|
+
signature as any,
|
|
393
|
+
),
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- wrapped verifier satisfies runtime interface
|
|
397
|
+
const sigValid = await UCAN.verifySignature(ucanView, wrappedVerifier as any);
|
|
398
|
+
if (!sigValid) {
|
|
399
|
+
return {
|
|
400
|
+
ok: false,
|
|
401
|
+
error: {
|
|
402
|
+
code: 'INVALID_SIGNATURE',
|
|
403
|
+
message: `Signature verification failed for issuer ${issuerDid}`,
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Recursively verify proofs
|
|
409
|
+
if (delegation.proofs && delegation.proofs.length > 0) {
|
|
410
|
+
for (const proof of delegation.proofs) {
|
|
411
|
+
// Chain consistency: proof's audience should match this delegation's issuer
|
|
412
|
+
const proofAudience: string = proof.audience.did();
|
|
413
|
+
if (proofAudience !== issuerDid) {
|
|
414
|
+
// Allow DID equivalence: proof audience (did:key) may resolve to same key as issuer (did:ixo)
|
|
415
|
+
const proofAudResolved = await resolveDIDKey(
|
|
416
|
+
proofAudience as `did:${string}:${string}`,
|
|
417
|
+
);
|
|
418
|
+
const proofAudKey =
|
|
419
|
+
'ok' in proofAudResolved && proofAudResolved.ok
|
|
420
|
+
? proofAudResolved.ok[0]
|
|
421
|
+
: null;
|
|
422
|
+
|
|
423
|
+
if (didKey !== proofAudKey) {
|
|
424
|
+
return {
|
|
425
|
+
ok: false,
|
|
426
|
+
error: {
|
|
427
|
+
code: 'UNAUTHORIZED',
|
|
428
|
+
message: `Proof chain broken: proof audience ${proofAudience} does not match delegation issuer ${issuerDid}`,
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const proofResult = await verifyDelegationChain(proof);
|
|
435
|
+
if (!proofResult.ok) {
|
|
436
|
+
return proofResult;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return { ok: true };
|
|
442
|
+
}
|
|
443
|
+
|
|
308
444
|
return {
|
|
309
445
|
serverDid: options.serverDid,
|
|
310
446
|
|
|
@@ -369,13 +505,16 @@ export async function createUCANValidator(
|
|
|
369
505
|
}
|
|
370
506
|
|
|
371
507
|
// 6. Use ucanto's claim() to validate
|
|
372
|
-
//
|
|
508
|
+
// Server verifier is resolved lazily (first call resolves, subsequent calls use cache)
|
|
509
|
+
const resolvedVerifier = await getServerVerifier();
|
|
373
510
|
const claimResult = claim(capabilityDef, [invocation], {
|
|
374
|
-
authority:
|
|
511
|
+
authority: resolvedVerifier,
|
|
375
512
|
principal: ed25519.Verifier,
|
|
376
513
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ucanto claim() expects a specific DID resolver signature incompatible with our async resolver
|
|
377
514
|
resolveDIDKey: resolveDIDKey as any,
|
|
378
515
|
canIssue: (cap: { with: string }, issuer: string) => {
|
|
516
|
+
// Wildcard: any DID with a valid signature chain is trusted as root
|
|
517
|
+
if (options.rootIssuers.includes('*')) return true;
|
|
379
518
|
// Root issuers can issue any capability
|
|
380
519
|
if (options.rootIssuers.includes(issuer)) return true;
|
|
381
520
|
// Allow self-issued capabilities where resource contains issuer DID
|
|
@@ -437,6 +576,12 @@ export async function createUCANValidator(
|
|
|
437
576
|
const proofChain = buildProofChain(invocation);
|
|
438
577
|
const expiration = computeEffectiveExpiration(invocation);
|
|
439
578
|
|
|
579
|
+
// 10. Extract facts from the invocation
|
|
580
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- invocation type from Delegation.extract() is complex
|
|
581
|
+
const facts = (invocation as any).facts as
|
|
582
|
+
| Record<string, unknown>[]
|
|
583
|
+
| undefined;
|
|
584
|
+
|
|
440
585
|
return {
|
|
441
586
|
ok: true,
|
|
442
587
|
invoker: invocation.issuer.did(),
|
|
@@ -449,6 +594,102 @@ export async function createUCANValidator(
|
|
|
449
594
|
: undefined,
|
|
450
595
|
expiration,
|
|
451
596
|
proofChain,
|
|
597
|
+
facts: facts && facts.length > 0 ? facts : undefined,
|
|
598
|
+
};
|
|
599
|
+
} catch (err) {
|
|
600
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
601
|
+
return { ok: false, error: { code: 'INVALID_FORMAT', message } };
|
|
602
|
+
}
|
|
603
|
+
},
|
|
604
|
+
|
|
605
|
+
async validateDelegation(
|
|
606
|
+
delegationBase64: string,
|
|
607
|
+
): Promise<ValidateResult> {
|
|
608
|
+
try {
|
|
609
|
+
// 1. Decode the delegation from base64 CAR
|
|
610
|
+
const carBytes = new Uint8Array(
|
|
611
|
+
Buffer.from(delegationBase64, 'base64'),
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
// 2. Extract the delegation from CAR
|
|
615
|
+
const extracted = await Delegation.extract(carBytes);
|
|
616
|
+
if (extracted.error) {
|
|
617
|
+
return {
|
|
618
|
+
ok: false,
|
|
619
|
+
error: {
|
|
620
|
+
code: 'INVALID_FORMAT',
|
|
621
|
+
message: `Failed to decode: ${extracted.error?.message ?? 'unknown'}`,
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const delegation = 'ok' in extracted ? extracted.ok : extracted;
|
|
627
|
+
|
|
628
|
+
// 3. Basic validation
|
|
629
|
+
if (!delegation?.issuer?.did || !delegation?.audience?.did) {
|
|
630
|
+
return {
|
|
631
|
+
ok: false,
|
|
632
|
+
error: {
|
|
633
|
+
code: 'INVALID_FORMAT',
|
|
634
|
+
message: 'Delegation missing issuer or audience',
|
|
635
|
+
},
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// 4. Check audience matches this server's public DID
|
|
640
|
+
const audienceDid = delegation.audience.did();
|
|
641
|
+
if (audienceDid !== options.serverDid) {
|
|
642
|
+
return {
|
|
643
|
+
ok: false,
|
|
644
|
+
error: {
|
|
645
|
+
code: 'UNAUTHORIZED',
|
|
646
|
+
message: `Delegation addressed to ${audienceDid}, not ${options.serverDid}`,
|
|
647
|
+
},
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// 5. Check expiration (effective = earliest across chain)
|
|
652
|
+
const expiration = computeEffectiveExpiration(delegation);
|
|
653
|
+
if (expiration !== undefined) {
|
|
654
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
655
|
+
if (expiration < nowSeconds) {
|
|
656
|
+
return {
|
|
657
|
+
ok: false,
|
|
658
|
+
error: {
|
|
659
|
+
code: 'EXPIRED',
|
|
660
|
+
message: `Delegation expired at ${new Date(expiration * 1000).toISOString()}`,
|
|
661
|
+
},
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// 6. Verify signatures across the entire delegation chain
|
|
667
|
+
const sigResult = await verifyDelegationChain(delegation);
|
|
668
|
+
if (!sigResult.ok) {
|
|
669
|
+
return sigResult;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// 7. Success — return delegation details
|
|
673
|
+
const proofChain = buildProofChain(delegation);
|
|
674
|
+
const cap = delegation.capabilities?.[0];
|
|
675
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- delegation type from Delegation.extract() is complex
|
|
676
|
+
const facts = (delegation as any).facts as
|
|
677
|
+
| Record<string, unknown>[]
|
|
678
|
+
| undefined;
|
|
679
|
+
|
|
680
|
+
return {
|
|
681
|
+
ok: true,
|
|
682
|
+
invoker: delegation.issuer.did(),
|
|
683
|
+
capability: cap
|
|
684
|
+
? {
|
|
685
|
+
can: cap.can,
|
|
686
|
+
with: cap.with as string,
|
|
687
|
+
nb: cap.nb as Record<string, unknown> | undefined,
|
|
688
|
+
}
|
|
689
|
+
: undefined,
|
|
690
|
+
expiration,
|
|
691
|
+
proofChain,
|
|
692
|
+
facts: facts && facts.length > 0 ? facts : undefined,
|
|
452
693
|
};
|
|
453
694
|
} catch (err) {
|
|
454
695
|
const message = err instanceof Error ? err.message : 'Unknown error';
|