@ixo/ucan 1.0.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/.eslintrc.js +9 -0
- package/.prettierignore +3 -0
- package/.prettierrc.js +4 -0
- package/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +0 -0
- package/README.md +189 -0
- package/dist/capabilities/capability.d.ts +33 -0
- package/dist/capabilities/capability.d.ts.map +1 -0
- package/dist/capabilities/capability.js +53 -0
- package/dist/capabilities/capability.js.map +1 -0
- package/dist/client/create-client.d.ts +33 -0
- package/dist/client/create-client.d.ts.map +1 -0
- package/dist/client/create-client.js +104 -0
- package/dist/client/create-client.js.map +1 -0
- package/dist/did/ixo-resolver.d.ts +8 -0
- package/dist/did/ixo-resolver.d.ts.map +1 -0
- package/dist/did/ixo-resolver.js +162 -0
- package/dist/did/ixo-resolver.js.map +1 -0
- package/dist/did/utils.d.ts +4 -0
- package/dist/did/utils.d.ts.map +1 -0
- package/dist/did/utils.js +85 -0
- package/dist/did/utils.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/store/memory.d.ts +25 -0
- package/dist/store/memory.d.ts.map +1 -0
- package/dist/store/memory.js +71 -0
- package/dist/store/memory.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types.d.ts +29 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validator/validator.d.ts +29 -0
- package/dist/validator/validator.d.ts.map +1 -0
- package/dist/validator/validator.js +179 -0
- package/dist/validator/validator.js.map +1 -0
- package/jest.config.js +3 -0
- package/package.json +78 -0
- package/scripts/test-ucan.ts +457 -0
- package/src/capabilities/capability.ts +244 -0
- package/src/client/create-client.ts +329 -0
- package/src/did/ixo-resolver.ts +325 -0
- package/src/did/utils.ts +141 -0
- package/src/index.ts +135 -0
- package/src/store/memory.ts +194 -0
- package/src/types.ts +108 -0
- package/src/validator/validator.ts +399 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Framework-agnostic UCAN validator
|
|
3
|
+
*
|
|
4
|
+
* This module provides a simple validator that can be used in any
|
|
5
|
+
* server framework (Express, Fastify, Hono, raw Node HTTP, etc.)
|
|
6
|
+
* to validate UCAN invocations.
|
|
7
|
+
*
|
|
8
|
+
* Uses ucanto's battle-tested validation under the hood.
|
|
9
|
+
*
|
|
10
|
+
* Supports any DID method (did:key, did:ixo, did:web, etc.) for the server identity.
|
|
11
|
+
* Non-did:key DIDs are resolved at startup using the provided didResolver.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { ed25519 } from '@ucanto/principal';
|
|
15
|
+
import { Delegation } from '@ucanto/core';
|
|
16
|
+
import { claim } from '@ucanto/validator';
|
|
17
|
+
import { capability } from '@ucanto/validator';
|
|
18
|
+
import type { DIDKeyResolver, InvocationStore } from '../types.js';
|
|
19
|
+
import { InMemoryInvocationStore } from '../store/memory.js';
|
|
20
|
+
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
22
|
+
type CapabilityParser = ReturnType<typeof capability<any, any, any>>;
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
+
type Verifier = ReturnType<typeof ed25519.Verifier.parse>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Options for creating a UCAN validator
|
|
28
|
+
*/
|
|
29
|
+
export interface CreateValidatorOptions {
|
|
30
|
+
/**
|
|
31
|
+
* The server's DID (audience for invocations)
|
|
32
|
+
* Invocations must be addressed to this DID.
|
|
33
|
+
*
|
|
34
|
+
* Supports any DID method:
|
|
35
|
+
* - did:key:z6Mk... (parsed directly)
|
|
36
|
+
* - did:ixo:ixo1... (resolved using didResolver at startup)
|
|
37
|
+
* - did:web:example.com (resolved using didResolver at startup)
|
|
38
|
+
*/
|
|
39
|
+
serverDid: string;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* DIDs that are allowed to be root issuers
|
|
43
|
+
* These DIDs can self-issue capabilities without needing a delegation chain
|
|
44
|
+
*/
|
|
45
|
+
rootIssuers: string[];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* DID resolver for non-did:key DIDs.
|
|
49
|
+
* Required if serverDid or any issuer uses a non-did:key method.
|
|
50
|
+
*
|
|
51
|
+
* The resolver should return the did:key(s) associated with the DID.
|
|
52
|
+
*/
|
|
53
|
+
didResolver?: DIDKeyResolver;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Optional invocation store for replay protection
|
|
57
|
+
* If not provided, an in-memory store is used
|
|
58
|
+
*/
|
|
59
|
+
invocationStore?: InvocationStore;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Result of validating an invocation
|
|
64
|
+
*/
|
|
65
|
+
export interface ValidateResult {
|
|
66
|
+
/** Whether validation succeeded */
|
|
67
|
+
ok: boolean;
|
|
68
|
+
|
|
69
|
+
/** The invoker's DID (if valid) */
|
|
70
|
+
invoker?: string;
|
|
71
|
+
|
|
72
|
+
/** The validated capability (if valid) */
|
|
73
|
+
capability?: {
|
|
74
|
+
can: string;
|
|
75
|
+
with: string;
|
|
76
|
+
nb?: Record<string, unknown>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Error details (if invalid) */
|
|
80
|
+
error?: {
|
|
81
|
+
code:
|
|
82
|
+
| 'INVALID_FORMAT'
|
|
83
|
+
| 'INVALID_SIGNATURE'
|
|
84
|
+
| 'UNAUTHORIZED'
|
|
85
|
+
| 'REPLAY'
|
|
86
|
+
| 'EXPIRED'
|
|
87
|
+
| 'CAVEAT_VIOLATION';
|
|
88
|
+
message: string;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A framework-agnostic UCAN validator
|
|
94
|
+
*/
|
|
95
|
+
export interface UCANValidator {
|
|
96
|
+
/**
|
|
97
|
+
* Validate an invocation against a capability definition
|
|
98
|
+
*
|
|
99
|
+
* @param invocationBase64 - Base64-encoded CAR containing the invocation
|
|
100
|
+
* @param capabilityDef - Capability definition from defineCapability()
|
|
101
|
+
* @param resource - The specific resource URI to validate against
|
|
102
|
+
* @returns Validation result
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ```typescript
|
|
106
|
+
* const result = await validator.validate(
|
|
107
|
+
* invocationBase64,
|
|
108
|
+
* EmployeesRead,
|
|
109
|
+
* 'myapp:company/acme'
|
|
110
|
+
* );
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
validate(
|
|
114
|
+
invocationBase64: string,
|
|
115
|
+
capabilityDef: CapabilityParser,
|
|
116
|
+
resource: string,
|
|
117
|
+
): Promise<ValidateResult>;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The server's public DID (as provided in options)
|
|
121
|
+
*/
|
|
122
|
+
readonly serverDid: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Create a UCAN validator (async to support DID resolution at startup)
|
|
127
|
+
*
|
|
128
|
+
* @param options - Validator configuration
|
|
129
|
+
* @returns A validator instance
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* ```typescript
|
|
133
|
+
* import { createUCANValidator, defineCapability, Schema, createIxoDIDResolver } from '@ixo/ucan';
|
|
134
|
+
*
|
|
135
|
+
* // Define capability
|
|
136
|
+
* const EmployeesRead = defineCapability({
|
|
137
|
+
* can: 'employees/read',
|
|
138
|
+
* protocol: 'myapp:',
|
|
139
|
+
* nb: { limit: Schema.integer().optional() },
|
|
140
|
+
* derives: (claimed, delegated) => {
|
|
141
|
+
* const claimedLimit = claimed.nb?.limit ?? Infinity;
|
|
142
|
+
* const delegatedLimit = delegated.nb?.limit ?? Infinity;
|
|
143
|
+
* if (claimedLimit > delegatedLimit) {
|
|
144
|
+
* return { error: new Error(`Limit exceeds delegated`) };
|
|
145
|
+
* }
|
|
146
|
+
* return { ok: {} };
|
|
147
|
+
* }
|
|
148
|
+
* });
|
|
149
|
+
*
|
|
150
|
+
* // Create validator with did:ixo server identity
|
|
151
|
+
* const validator = await createUCANValidator({
|
|
152
|
+
* serverDid: 'did:ixo:ixo1abc...', // Any DID method supported
|
|
153
|
+
* rootIssuers: ['did:ixo:ixo1admin...'],
|
|
154
|
+
* didResolver: createIxoDIDResolver({ indexerUrl: '...' }),
|
|
155
|
+
* });
|
|
156
|
+
*
|
|
157
|
+
* // Validate invocations
|
|
158
|
+
* const result = await validator.validate(invocationBase64, EmployeesRead, 'myapp:server');
|
|
159
|
+
* ```
|
|
160
|
+
*/
|
|
161
|
+
export async function createUCANValidator(
|
|
162
|
+
options: CreateValidatorOptions,
|
|
163
|
+
): Promise<UCANValidator> {
|
|
164
|
+
const invocationStore =
|
|
165
|
+
options.invocationStore ?? new InMemoryInvocationStore();
|
|
166
|
+
|
|
167
|
+
// Resolve server DID to get verifier
|
|
168
|
+
// This supports any DID method - did:key is parsed directly, others use the resolver
|
|
169
|
+
let serverVerifier: Verifier;
|
|
170
|
+
|
|
171
|
+
if (options.serverDid.startsWith('did:key:')) {
|
|
172
|
+
// did:key can be parsed directly (contains the public key)
|
|
173
|
+
serverVerifier = ed25519.Verifier.parse(options.serverDid);
|
|
174
|
+
} else {
|
|
175
|
+
// Non-did:key requires resolution
|
|
176
|
+
if (!options.didResolver) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Cannot use ${options.serverDid} as server DID without a didResolver. ` +
|
|
179
|
+
`Provide a didResolver to resolve non-did:key DIDs, or use a did:key directly.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const resolved = await options.didResolver(
|
|
184
|
+
options.serverDid as `did:${string}:${string}`,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
if ('error' in resolved) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`Failed to resolve server DID ${options.serverDid}: ${resolved.error.message}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!resolved.ok || resolved.ok.length === 0) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`No keys found for server DID ${options.serverDid}. ` +
|
|
196
|
+
`The DID document must have at least one verification method.`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Use the first key (primary key)
|
|
201
|
+
const keyDid = resolved.ok[0];
|
|
202
|
+
if (!keyDid) {
|
|
203
|
+
throw new Error(`No valid key found for server DID ${options.serverDid}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
serverVerifier = ed25519.Verifier.parse(keyDid);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Create DID resolver for use during validation (for issuers in delegation chain)
|
|
210
|
+
const resolveDIDKey = async (did: `did:${string}:${string}`) => {
|
|
211
|
+
// Defensive: ensure did is a string
|
|
212
|
+
if (typeof did !== 'string') {
|
|
213
|
+
console.error('[resolveDIDKey] ERROR: did is not a string!', did);
|
|
214
|
+
return {
|
|
215
|
+
error: {
|
|
216
|
+
name: 'DIDKeyResolutionError' as const,
|
|
217
|
+
did: String(did),
|
|
218
|
+
message: `Expected DID string, got ${typeof did}`,
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// did:key resolves to itself - return as array of DID strings (ucanto iterates over result.ok)
|
|
224
|
+
if (did.startsWith('did:key:')) {
|
|
225
|
+
return { ok: [did] };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Try custom resolver for other DID methods (e.g., did:ixo)
|
|
229
|
+
if (options.didResolver) {
|
|
230
|
+
const result = await options.didResolver(
|
|
231
|
+
did as `did:${string}:${string}`,
|
|
232
|
+
);
|
|
233
|
+
if ('ok' in result && result.ok.length > 0) {
|
|
234
|
+
// Return the array of did:key strings (ucanto will parse them)
|
|
235
|
+
return { ok: result.ok };
|
|
236
|
+
}
|
|
237
|
+
if ('error' in result) {
|
|
238
|
+
return {
|
|
239
|
+
error: {
|
|
240
|
+
name: 'DIDKeyResolutionError' as const,
|
|
241
|
+
did,
|
|
242
|
+
message: result.error.message,
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
error: {
|
|
250
|
+
name: 'DIDKeyResolutionError' as const,
|
|
251
|
+
did,
|
|
252
|
+
message: `Cannot resolve DID: ${did}`,
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
serverDid: options.serverDid,
|
|
259
|
+
|
|
260
|
+
async validate(
|
|
261
|
+
invocationBase64,
|
|
262
|
+
capabilityDef,
|
|
263
|
+
resource,
|
|
264
|
+
): Promise<ValidateResult> {
|
|
265
|
+
try {
|
|
266
|
+
// 1. Decode the invocation from base64 CAR
|
|
267
|
+
const carBytes = new Uint8Array(Buffer.from(invocationBase64, 'base64'));
|
|
268
|
+
|
|
269
|
+
// 2. Extract the invocation from CAR
|
|
270
|
+
const extracted = await Delegation.extract(carBytes);
|
|
271
|
+
if (extracted.error) {
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
error: {
|
|
275
|
+
code: 'INVALID_FORMAT',
|
|
276
|
+
message: `Failed to decode: ${extracted.error?.message ?? 'unknown'}`,
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const invocation = 'ok' in extracted ? extracted.ok : extracted;
|
|
282
|
+
|
|
283
|
+
// 3. Basic validation - check we have required fields
|
|
284
|
+
if (!invocation?.issuer?.did || !invocation?.audience?.did) {
|
|
285
|
+
return {
|
|
286
|
+
ok: false,
|
|
287
|
+
error: {
|
|
288
|
+
code: 'INVALID_FORMAT',
|
|
289
|
+
message: 'Invocation missing issuer or audience',
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 4. Check audience matches this server's public DID
|
|
295
|
+
const audienceDid = invocation.audience.did();
|
|
296
|
+
if (audienceDid !== options.serverDid) {
|
|
297
|
+
return {
|
|
298
|
+
ok: false,
|
|
299
|
+
error: {
|
|
300
|
+
code: 'UNAUTHORIZED',
|
|
301
|
+
message: `Invocation addressed to ${audienceDid}, not ${options.serverDid}`,
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 5. Check replay protection
|
|
307
|
+
const invocationCid = invocation.cid?.toString();
|
|
308
|
+
if (invocationCid && (await invocationStore.has(invocationCid))) {
|
|
309
|
+
return {
|
|
310
|
+
ok: false,
|
|
311
|
+
error: {
|
|
312
|
+
code: 'REPLAY',
|
|
313
|
+
message: 'Invocation has already been used',
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 6. Use ucanto's claim() to validate
|
|
319
|
+
// The serverVerifier was resolved at startup (supports any DID method)
|
|
320
|
+
const claimResult = claim(capabilityDef, [invocation], {
|
|
321
|
+
authority: serverVerifier,
|
|
322
|
+
principal: ed25519.Verifier,
|
|
323
|
+
resolveDIDKey: resolveDIDKey as any,
|
|
324
|
+
canIssue: (cap: any, issuer: string) => {
|
|
325
|
+
// Root issuers can issue any capability
|
|
326
|
+
if (options.rootIssuers.includes(issuer)) return true;
|
|
327
|
+
// Allow self-issued capabilities where resource contains issuer DID
|
|
328
|
+
if (typeof cap.with === 'string' && cap.with.includes(issuer))
|
|
329
|
+
return true;
|
|
330
|
+
return false;
|
|
331
|
+
},
|
|
332
|
+
validateAuthorization: () => ({ ok: {} }),
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const accessResult = await claimResult;
|
|
336
|
+
|
|
337
|
+
if (accessResult.error) {
|
|
338
|
+
// Check if it's a caveat/derives error
|
|
339
|
+
const errorMsg = accessResult.error.message ?? 'Authorization failed';
|
|
340
|
+
const isCaveatError =
|
|
341
|
+
errorMsg.includes('limit') ||
|
|
342
|
+
errorMsg.includes('caveat') ||
|
|
343
|
+
errorMsg.includes('exceeds') ||
|
|
344
|
+
errorMsg.includes('violates');
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
ok: false,
|
|
348
|
+
error: {
|
|
349
|
+
code: isCaveatError ? 'CAVEAT_VIOLATION' : 'UNAUTHORIZED',
|
|
350
|
+
message: errorMsg,
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 7. Verify the resource matches
|
|
356
|
+
const validatedCap = invocation.capabilities?.[0];
|
|
357
|
+
if (validatedCap && validatedCap.with !== resource) {
|
|
358
|
+
// Check if it's a wildcard match
|
|
359
|
+
const capWith = validatedCap.with as string;
|
|
360
|
+
const isWildcardMatch =
|
|
361
|
+
(capWith.endsWith('/*') &&
|
|
362
|
+
resource.startsWith(capWith.slice(0, -1))) ||
|
|
363
|
+
(capWith.endsWith(':*') &&
|
|
364
|
+
resource.startsWith(capWith.slice(0, -1)));
|
|
365
|
+
|
|
366
|
+
if (!isWildcardMatch) {
|
|
367
|
+
return {
|
|
368
|
+
ok: false,
|
|
369
|
+
error: {
|
|
370
|
+
code: 'UNAUTHORIZED',
|
|
371
|
+
message: `Resource ${validatedCap.with} does not match ${resource}`,
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// 8. Success! Mark invocation as used for replay protection
|
|
378
|
+
if (invocationCid) {
|
|
379
|
+
await invocationStore.add(invocationCid);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
ok: true,
|
|
384
|
+
invoker: invocation.issuer.did(),
|
|
385
|
+
capability: validatedCap
|
|
386
|
+
? {
|
|
387
|
+
can: validatedCap.can,
|
|
388
|
+
with: validatedCap.with as string,
|
|
389
|
+
nb: validatedCap.nb as Record<string, unknown> | undefined,
|
|
390
|
+
}
|
|
391
|
+
: undefined,
|
|
392
|
+
};
|
|
393
|
+
} catch (err) {
|
|
394
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
395
|
+
return { ok: false, error: { code: 'INVALID_FORMAT', message } };
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@ixo/typescript-config/nestjs.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"baseUrl": "./",
|
|
5
|
+
"outDir": "./dist",
|
|
6
|
+
"module": "NodeNext",
|
|
7
|
+
"moduleResolution": "NodeNext",
|
|
8
|
+
"target": "ES2022",
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"declarationMap": true,
|
|
11
|
+
"sourceMap": true,
|
|
12
|
+
"strict": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"skipLibCheck": true
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*"],
|
|
17
|
+
"exclude": ["node_modules", "dist"]
|
|
18
|
+
}
|