@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.
@@ -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
- // Resolve server DID to get verifier
191
- // This supports any DID method - did:key is parsed directly, others use the resolver
192
- let serverVerifier: Verifier;
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
- // The serverVerifier was resolved at startup (supports any DID method)
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: serverVerifier,
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
  }