@ixo/ucan 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/.turbo/turbo-build.log +1 -1
- package/README.md +47 -5
- 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 +1 -0
- 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 +1 -0
- package/dist/validator/validator.d.ts.map +1 -1
- package/dist/validator/validator.js +150 -6
- package/dist/validator/validator.js.map +1 -1
- package/package.json +18 -7
- package/src/did/web-resolver.ts +140 -0
- package/src/index.ts +5 -0
- package/src/validator/validator.test.ts +336 -0
- package/src/validator/validator.ts +239 -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';
|
|
@@ -139,6 +139,27 @@ export interface UCANValidator {
|
|
|
139
139
|
resource: string,
|
|
140
140
|
): Promise<ValidateResult>;
|
|
141
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
|
+
|
|
142
163
|
/**
|
|
143
164
|
* The server's public DID (as provided in options)
|
|
144
165
|
*/
|
|
@@ -187,15 +208,20 @@ export async function createUCANValidator(
|
|
|
187
208
|
const invocationStore =
|
|
188
209
|
options.invocationStore ?? new InMemoryInvocationStore();
|
|
189
210
|
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
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
|
+
}
|
|
193
224
|
|
|
194
|
-
if (options.serverDid.startsWith('did:key:')) {
|
|
195
|
-
// did:key can be parsed directly (contains the public key)
|
|
196
|
-
serverVerifier = ed25519.Verifier.parse(options.serverDid);
|
|
197
|
-
} else {
|
|
198
|
-
// Non-did:key requires resolution
|
|
199
225
|
if (!options.didResolver) {
|
|
200
226
|
throw new Error(
|
|
201
227
|
`Cannot use ${options.serverDid} as server DID without a didResolver. ` +
|
|
@@ -220,13 +246,13 @@ export async function createUCANValidator(
|
|
|
220
246
|
);
|
|
221
247
|
}
|
|
222
248
|
|
|
223
|
-
// Use the first key (primary key)
|
|
224
249
|
const keyDid = resolved.ok[0];
|
|
225
250
|
if (!keyDid) {
|
|
226
251
|
throw new Error(`No valid key found for server DID ${options.serverDid}`);
|
|
227
252
|
}
|
|
228
253
|
|
|
229
254
|
serverVerifier = ed25519.Verifier.parse(keyDid);
|
|
255
|
+
return serverVerifier;
|
|
230
256
|
}
|
|
231
257
|
|
|
232
258
|
// Create DID resolver for use during validation (for issuers in delegation chain)
|
|
@@ -312,6 +338,109 @@ export async function createUCANValidator(
|
|
|
312
338
|
return exp ?? parentExp;
|
|
313
339
|
}
|
|
314
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
|
+
|
|
315
444
|
return {
|
|
316
445
|
serverDid: options.serverDid,
|
|
317
446
|
|
|
@@ -376,13 +505,16 @@ export async function createUCANValidator(
|
|
|
376
505
|
}
|
|
377
506
|
|
|
378
507
|
// 6. Use ucanto's claim() to validate
|
|
379
|
-
//
|
|
508
|
+
// Server verifier is resolved lazily (first call resolves, subsequent calls use cache)
|
|
509
|
+
const resolvedVerifier = await getServerVerifier();
|
|
380
510
|
const claimResult = claim(capabilityDef, [invocation], {
|
|
381
|
-
authority:
|
|
511
|
+
authority: resolvedVerifier,
|
|
382
512
|
principal: ed25519.Verifier,
|
|
383
513
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ucanto claim() expects a specific DID resolver signature incompatible with our async resolver
|
|
384
514
|
resolveDIDKey: resolveDIDKey as any,
|
|
385
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;
|
|
386
518
|
// Root issuers can issue any capability
|
|
387
519
|
if (options.rootIssuers.includes(issuer)) return true;
|
|
388
520
|
// Allow self-issued capabilities where resource contains issuer DID
|
|
@@ -469,5 +601,100 @@ export async function createUCANValidator(
|
|
|
469
601
|
return { ok: false, error: { code: 'INVALID_FORMAT', message } };
|
|
470
602
|
}
|
|
471
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,
|
|
693
|
+
};
|
|
694
|
+
} catch (err) {
|
|
695
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
696
|
+
return { ok: false, error: { code: 'INVALID_FORMAT', message } };
|
|
697
|
+
}
|
|
698
|
+
},
|
|
472
699
|
};
|
|
473
700
|
}
|