iso-ucan 0.0.1

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,88 @@
1
+ import * as cbor from '@ipld/dag-cbor'
2
+ import * as varsig from './varsig.js'
3
+
4
+ /**
5
+ * @import { Envelope, PayloadTag, EnvelopeDecodeOptions, EnvelopeEncodeOptions, EnvelopeSignOptions, SignaturePayload, PayloadSpec, DecodedEnvelope, DelegationPayload, InvocationPayload} from './types.js'
6
+ * @import { ByteView } from 'multiformats'
7
+ */
8
+
9
+ /** Version */
10
+ export const VERSION = '1.0.0-rc.1'
11
+
12
+ /**
13
+ *
14
+ * @param {EnvelopeSignOptions} options
15
+ */
16
+ export async function sign(options) {
17
+ const { spec, version, enc, signer, payload } = options
18
+
19
+ const payloadTag = /** @type {PayloadTag} */ (
20
+ `ucan/${spec}@${version ?? VERSION}`
21
+ )
22
+ const h = varsig.encode({ enc: enc ?? 'DAG-CBOR', alg: signer.signatureType })
23
+
24
+ const signaturePayload = /** @type {SignaturePayload} */ ({
25
+ h,
26
+ [payloadTag]: payload,
27
+ })
28
+ const signature = await signer.sign(cbor.encode(signaturePayload))
29
+ return {
30
+ signature,
31
+ signaturePayload,
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Encode a UCAN envelope
37
+ *
38
+ * @see https://github.com/ucan-wg/spec#envelope
39
+ * @param {EnvelopeEncodeOptions} options
40
+ *
41
+ */
42
+ export function encode(options) {
43
+ const { signature, signaturePayload } = options
44
+
45
+ const envelope = [signature, signaturePayload]
46
+
47
+ return cbor.encode(envelope)
48
+ }
49
+
50
+ /**
51
+ * Decode a UCAN envelope
52
+ *
53
+ * @template {PayloadSpec} Spec
54
+ * @see https://github.com/ucan-wg/spec#envelope
55
+ * @param {EnvelopeDecodeOptions} options
56
+ * @returns {DecodedEnvelope<Spec>}
57
+ */
58
+ export function decode(options) {
59
+ const { envelope } = options
60
+
61
+ const decoded = /** @type typeof cbor.decode<Envelope> **/ (cbor.decode)(
62
+ /** @type {ByteView<Envelope>} */ (envelope)
63
+ )
64
+
65
+ const [signature, sigPayload] = decoded
66
+ const [_h, payloadTag] = Object.keys(sigPayload)
67
+ const header = sigPayload.h
68
+ /** @type {DelegationPayload | InvocationPayload} */
69
+ const payload = sigPayload[/** @type {PayloadTag} */ (payloadTag)]
70
+
71
+ const [_ucan, specVersion] = payloadTag.split('/')
72
+ const [spec, version] = specVersion.split('@')
73
+
74
+ const { alg, enc } = varsig.decode(header)
75
+
76
+ return {
77
+ alg,
78
+ enc,
79
+ signature,
80
+ // Narrow payload type based on Spec
81
+ payload:
82
+ /** @type {Spec extends "dlg" ? DelegationPayload : InvocationPayload} */ (
83
+ payload
84
+ ),
85
+ spec: /** @type {Spec} */ (spec),
86
+ version,
87
+ }
88
+ }
package/src/index.js ADDED
@@ -0,0 +1 @@
1
+ export * as Varsig from './varsig.js'
@@ -0,0 +1,227 @@
1
+ import { parse as didParse } from 'iso-did'
2
+ import { randomBytes } from 'iso-web/crypto'
3
+ import * as Envelope from './envelope.js'
4
+ import {
5
+ assertArgs,
6
+ assertExpiration,
7
+ assertIsValidCommand,
8
+ assertMeta,
9
+ assertNonce,
10
+ cid,
11
+ expOrTtl,
12
+ verifySignature,
13
+ } from './utils.js'
14
+
15
+ /**
16
+ * @import {Delegation} from './delegation.js'
17
+ * @import {CID} from 'multiformats'
18
+ */
19
+
20
+ /**
21
+ * UCAN Invocation
22
+ */
23
+ export class Invocation {
24
+ /**
25
+ * @type {Delegation[]}
26
+ */
27
+ delegations
28
+
29
+ /**
30
+ * @type {CID}
31
+ */
32
+ cid
33
+
34
+ /**
35
+ *
36
+ * @param {import("./types.js").DecodedEnvelope<'inv'>} envelope
37
+ * @param {Uint8Array} bytes
38
+ * @param {CID} cid
39
+ * @param {Delegation[]} delegations
40
+ */
41
+ constructor(envelope, bytes, cid, delegations) {
42
+ this.envelope = envelope
43
+ this.payload = envelope.payload
44
+ this.bytes = bytes
45
+ this.cid = cid
46
+ this.delegations = delegations
47
+ }
48
+
49
+ /**
50
+ *
51
+ * @param {import("./types.js").InvocationFromOptions} options
52
+ */
53
+ static async from(options) {
54
+ const { bytes, audience } = options
55
+
56
+ const envelope = /** @type {typeof Envelope.decode<"inv">} */ (
57
+ Envelope.decode
58
+ )({ envelope: bytes })
59
+
60
+ if (envelope.spec !== 'inv') {
61
+ throw new Error(
62
+ `Invalid envelope type. Expected: Invocation but got: ${envelope.spec}`
63
+ )
64
+ }
65
+ assertStructure(envelope.payload)
66
+ await verifySignature(
67
+ envelope,
68
+ options.verifierResolver,
69
+ options.didResolver
70
+ )
71
+
72
+ // Audience or subject must match receiver
73
+ if (
74
+ envelope.payload.aud !== audience.did &&
75
+ envelope.payload.sub !== audience.did
76
+ ) {
77
+ throw new Error(
78
+ `UCAN Invocation audience or subject does not match receiver. Expected: ${audience.did} but got: ${envelope.payload.aud ?? envelope.payload.sub ?? 'null'}`
79
+ )
80
+ }
81
+
82
+ // Resolve and validate proofs
83
+
84
+ /** @type {Delegation[]} */
85
+ const proofs = []
86
+ for (const proof of envelope.payload.prf) {
87
+ const delegation = await options.resolveProof(proof)
88
+ await delegation.validate(options)
89
+ proofs.push(delegation)
90
+ }
91
+
92
+ assertProofs(envelope.payload, proofs)
93
+
94
+ const _cid = await cid(envelope)
95
+ return new Invocation(envelope, bytes, _cid, proofs)
96
+ }
97
+
98
+ /**
99
+ * @param {import("./types.js").InvocationOptions} options
100
+ */
101
+ static async create(options) {
102
+ const nonce = options.nonce || randomBytes(12)
103
+
104
+ /** @type {import("./types.js").InvocationPayload} */
105
+ const payload = {
106
+ iss: options.iss.toString(),
107
+ aud: options.aud ? options.aud : options.sub,
108
+ sub: options.sub,
109
+ cmd: options.cmd,
110
+ nonce,
111
+ exp: expOrTtl(options),
112
+ args: options.args,
113
+ prf: options.prf.map((p) => p.cid),
114
+ }
115
+ if (options.cause) {
116
+ payload.cause = options.cause
117
+ }
118
+ if (options.meta) {
119
+ payload.meta = options.meta
120
+ }
121
+ if (options.iat) {
122
+ payload.iat = options.iat
123
+ }
124
+ if (options.nbf) {
125
+ payload.nbf = options.nbf
126
+ }
127
+
128
+ assertStructure(payload)
129
+ assertProofs(payload, options.prf)
130
+
131
+ const { signature, signaturePayload } = await Envelope.sign({
132
+ spec: 'inv',
133
+ signer: options.iss,
134
+ payload,
135
+ })
136
+
137
+ const bytes = Envelope.encode({ signature, signaturePayload })
138
+
139
+ /** @type {import("./types.js").DecodedEnvelope<'inv'>} */
140
+ const envelope = {
141
+ alg: options.iss.signatureType,
142
+ enc: 'DAG-CBOR',
143
+ signature,
144
+ payload,
145
+ spec: 'inv',
146
+ version: Envelope.VERSION,
147
+ }
148
+
149
+ return new Invocation(envelope, bytes, await cid(envelope), options.prf)
150
+ }
151
+ }
152
+
153
+ /**
154
+ * @param {import('./types.js').InvocationPayload} payload
155
+ */
156
+ function assertStructure(payload) {
157
+ didParse(payload.iss)
158
+ didParse(payload.sub)
159
+
160
+ if (payload.aud) {
161
+ didParse(payload.aud)
162
+ }
163
+
164
+ assertIsValidCommand(payload.cmd)
165
+ assertArgs(payload.args)
166
+ assertMeta(payload.meta)
167
+ assertNonce(payload.nonce)
168
+ assertExpiration(payload.exp)
169
+ }
170
+
171
+ /**
172
+ *
173
+ * @param {import("./types.js").InvocationPayload} payload
174
+ * @param {Delegation[]} proofs
175
+ */
176
+ export function assertProofs(payload, proofs) {
177
+ if (payload.prf.length <= 0) {
178
+ throw new Error('UCAN Invocation proofs are required')
179
+ }
180
+
181
+ if (proofs.length !== payload.prf.length) {
182
+ throw new Error("UCAN Invocation couldn't resolve all proofs CIDs")
183
+ }
184
+
185
+ const lastProof = proofs[proofs.length - 1]
186
+ const issuerDid = didParse(lastProof.envelope.payload.iss).did
187
+ if (issuerDid !== lastProof.envelope.payload.sub) {
188
+ throw new Error('UCAN Invocation root proof is not self-signed')
189
+ }
190
+
191
+ for (let index = 0; index < proofs.length; index++) {
192
+ const current = proofs[index].envelope.payload
193
+ const currentAudience = didParse(current.aud)
194
+ const currentSubject = didParse(current.sub ?? payload.sub)
195
+
196
+ const nextProof = proofs[index + 1]
197
+ /** @type {import("./types.js").Payload} */
198
+ let next
199
+ if (nextProof) {
200
+ next = nextProof.envelope.payload
201
+ } else {
202
+ next = payload
203
+ }
204
+
205
+ const nextIssuer = didParse(next.iss)
206
+ const nextSubject = didParse(next.sub ?? payload.sub)
207
+ if (nextIssuer.did !== currentAudience.did) {
208
+ throw new Error(
209
+ `UCAN Invocation principal alignment mismatch, expected ${currentAudience.toString()} but got ${nextIssuer.toString()}`
210
+ )
211
+ }
212
+
213
+ if (nextSubject.did !== currentSubject.did) {
214
+ throw new Error(
215
+ `UCAN Invocation subject alignment mismatch, expected ${currentSubject.toString()} but got ${nextSubject.toString()}`
216
+ )
217
+ }
218
+
219
+ if (!next.cmd.startsWith(current.cmd)) {
220
+ throw new Error(
221
+ `UCAN Invocation command mismatch, expected ${current.cmd} to be a broader than ${next.cmd}`
222
+ )
223
+ }
224
+ }
225
+
226
+ return true
227
+ }
package/src/policy.js ADDED
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Resolves a selector string against a data object, inspired by jq.
3
+ *
4
+ * @param {any} data The data to select from.
5
+ * @param {string} selector The selector string (e.g., ".foo[0].bar").
6
+ * @returns {any} The selected value, or null if the selection fails at an optional part.
7
+ * @throws {Error} If the selection fails at a non-optional part.
8
+ */
9
+ function resolveSelector(data, selector) {
10
+ if (selector === '.') {
11
+ return data
12
+ }
13
+
14
+ // This regex is a simplified parser for the selector syntax.
15
+ const partsRegex = /(\.[\w_]+|\[-?\d*:-?\d*\]|\[-?\d+\]|\[\]|\?)/g
16
+ const parts = selector.match(partsRegex) || []
17
+
18
+ let currentData = data
19
+
20
+ for (let part of parts) {
21
+ let isOptional = false
22
+ if (part.endsWith('?')) {
23
+ isOptional = true
24
+ part = part.slice(0, -1)
25
+ }
26
+
27
+ try {
28
+ if (currentData === undefined || currentData === null) {
29
+ throw new Error(
30
+ `Cannot process part "${part}" on null or undefined value.`
31
+ )
32
+ }
33
+
34
+ if (part.startsWith('.')) {
35
+ const key = part.substring(1)
36
+ if (typeof currentData !== 'object' || Array.isArray(currentData)) {
37
+ throw new Error(`Cannot access key "${key}" on non-object.`)
38
+ }
39
+ currentData = currentData[key]
40
+ } else if (part === '[]') {
41
+ if (Array.isArray(currentData)) {
42
+ // No-op for arrays, the quantifier will iterate over it.
43
+ } else if (typeof currentData === 'object') {
44
+ currentData = Object.values(currentData)
45
+ } else {
46
+ throw new Error('Cannot extract values from non-collection.')
47
+ }
48
+ } else if (part.startsWith('[') && part.includes(':')) {
49
+ // Slice
50
+ if (!Array.isArray(currentData)) {
51
+ throw new Error('Slice can only be applied to arrays.')
52
+ }
53
+ const [start, end] = part
54
+ .substring(1, part.length - 1)
55
+ .split(':')
56
+ .map((s) => (s ? Number.parseInt(s, 10) : undefined))
57
+ currentData = currentData.slice(start, end)
58
+ } else if (part.startsWith('[')) {
59
+ // Index
60
+ if (!Array.isArray(currentData)) {
61
+ throw new Error('Index can only be applied to arrays.')
62
+ }
63
+ const index = Number.parseInt(part.substring(1, part.length - 1), 10)
64
+ currentData =
65
+ currentData[index < 0 ? currentData.length + index : index]
66
+ }
67
+
68
+ if (currentData === undefined) {
69
+ throw new Error(`Path segment returned undefined: ${part}`)
70
+ }
71
+ } catch (e) {
72
+ if (isOptional) {
73
+ return null
74
+ }
75
+ // Re-throw if not optional, to be caught by evaluateStatement
76
+ throw e
77
+ }
78
+ }
79
+
80
+ return currentData
81
+ }
82
+
83
+ /**
84
+ * Performs a deep equality check between two values.
85
+ * @param {any} a
86
+ * @param {any} b
87
+ * @returns {boolean}
88
+ */
89
+ function deepEqual(a, b) {
90
+ if (a === b) return true
91
+ if (a && b && typeof a === 'object' && typeof b === 'object') {
92
+ if (a.constructor !== b.constructor) return false
93
+ if (Array.isArray(a)) {
94
+ if (a.length !== b.length) return false
95
+ for (let i = 0; i < a.length; i++) {
96
+ if (!deepEqual(a[i], b[i])) return false
97
+ }
98
+ return true
99
+ }
100
+ const keys = Object.keys(a)
101
+ if (keys.length !== Object.keys(b).length) return false
102
+ for (const key of keys) {
103
+ // biome-ignore lint/suspicious/noPrototypeBuiltins: needed
104
+ if (!b.hasOwnProperty(key) || !deepEqual(a[key], b[key])) return false
105
+ }
106
+ return true
107
+ }
108
+ return false
109
+ }
110
+
111
+ /**
112
+ * Converts a UCAN glob pattern to a regular expression.
113
+ * @param {string} pattern
114
+ * @returns {RegExp}
115
+ */
116
+ function likeRegex(pattern) {
117
+ // Convert UCAN glob pattern to a regular expression.
118
+ // The glob pattern has `*` as a wildcard (zero or more chars)
119
+ // and `\*` as an escape for a literal asterisk.
120
+ let regexString = '^'
121
+ let i = 0
122
+ while (i < pattern.length) {
123
+ const char = pattern[i]
124
+
125
+ if (char === '\\') {
126
+ // Handle escape sequence
127
+ if (i + 1 < pattern.length) {
128
+ const nextChar = pattern[i + 1]
129
+ if (nextChar === '*') {
130
+ // This is an escaped asterisk `\*`. Match a literal `*`.
131
+ regexString += '\\*'
132
+ i += 2 // Move past both `\` and `*`
133
+ } else {
134
+ // This is another escape, like `\\`. Treat the `\` as a literal character.
135
+ regexString += '\\\\'
136
+ i++
137
+ }
138
+ } else {
139
+ // Trailing backslash. Treat as a literal `\`.
140
+ regexString += '\\\\'
141
+ i++
142
+ }
143
+ } else if (char === '*') {
144
+ // This is a wildcard `*`. Match zero or more characters.
145
+ regexString += '.*'
146
+ i++
147
+ } else {
148
+ // This is a regular character. Escape it if it's a special regex metacharacter.
149
+ regexString += char.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
150
+ i++
151
+ }
152
+ }
153
+ regexString += '$'
154
+
155
+ return new RegExp(regexString)
156
+ }
157
+
158
+ /**
159
+ * Evaluates a single policy statement against the invocation args.
160
+ *
161
+ * @template Args
162
+ * @param {Args} args The invocation arguments.
163
+ * @param {import("./types.js").Statement<Args>} statement The policy statement.
164
+ * @returns {boolean} The result of the evaluation.
165
+ */
166
+ function evaluateStatement(args, statement) {
167
+ const [op, ...params] = statement
168
+
169
+ try {
170
+ switch (op) {
171
+ // Comparisons
172
+ case '==': {
173
+ const [selector, value] = /** @type {[string, unknown]} */ (params)
174
+
175
+ return deepEqual(resolveSelector(args, selector), value)
176
+ }
177
+ case '!=': {
178
+ const [selector, value] = /** @type {[string, unknown]} */ (params)
179
+ return !deepEqual(resolveSelector(args, selector), value)
180
+ }
181
+ case '>':
182
+ case '>=':
183
+ case '<':
184
+ case '<=': {
185
+ const [selector, value] = /** @type {[string, number]} */ (params)
186
+ const selected = resolveSelector(args, selector)
187
+ if (typeof selected !== 'number' || typeof value !== 'number')
188
+ return false
189
+ if (op === '>') return selected > value
190
+ if (op === '>=') return selected >= value
191
+ if (op === '<') return selected < value
192
+ if (op === '<=') return selected <= value
193
+ break
194
+ }
195
+ // Glob Matching
196
+ case 'like': {
197
+ const [selector, pattern] = /** @type {[string, string]} */ (params)
198
+ const selected = resolveSelector(args, selector)
199
+ if (typeof selected !== 'string') return false
200
+ const regex = likeRegex(pattern)
201
+ return regex.test(selected)
202
+ }
203
+ // Connectives
204
+ case 'and': {
205
+ const [statements] = /** @type {[import("./types.js").Statement[]]} */ (
206
+ params
207
+ )
208
+ return statements.every((stmt) => evaluateStatement(args, stmt))
209
+ }
210
+ case 'or': {
211
+ const [statements] = /** @type {[import("./types.js").Statement[]]} */ (
212
+ params
213
+ )
214
+ if (statements.length === 0) return true
215
+ return statements.some((stmt) => evaluateStatement(args, stmt))
216
+ }
217
+ case 'not': {
218
+ const [statement] = /** @type {[import("./types.js").Statement]} */ (
219
+ params
220
+ )
221
+ return !evaluateStatement(args, statement)
222
+ }
223
+ // Quantification
224
+ case 'all':
225
+ case 'any': {
226
+ const [selector, innerStmt] =
227
+ /** @type {[string, import("./types.js").Statement]} */ (params)
228
+ let collection = resolveSelector(args, selector)
229
+
230
+ if (innerStmt[0] === 'any' || innerStmt[0] === 'all') {
231
+ return evaluateStatement(collection, innerStmt)
232
+ }
233
+
234
+ if (
235
+ collection &&
236
+ typeof collection === 'object' &&
237
+ !Array.isArray(collection)
238
+ ) {
239
+ collection = Object.values(collection)
240
+ }
241
+ if (!Array.isArray(collection)) return false
242
+ if (collection.length === 0) return true
243
+ if (op === 'all') {
244
+ return collection.every((item) => {
245
+ return evaluateStatement(item, innerStmt)
246
+ })
247
+ }
248
+ // any
249
+ return collection.some((item) => evaluateStatement(item, innerStmt))
250
+ }
251
+ default:
252
+ return false // Unknown operator
253
+ }
254
+ } catch {
255
+ // As per the spec, if a selector cannot be resolved, the statement MUST return false.
256
+ return false
257
+ }
258
+ return false
259
+ }
260
+
261
+ /**
262
+ * Validates invocation arguments against a UCAN policy.
263
+ *
264
+ * @template {Record<string, unknown>} Args
265
+ * @param {Args} args The arguments of the eventual invocation.
266
+ * @param {import("./types.js").Policy<Args>} policy An array of policy statements.
267
+ * @returns {boolean} True if the args are valid according to the policy, false otherwise.
268
+ */
269
+ export function validate(args, policy) {
270
+ // The top-level policy is an implicit 'and'.
271
+ return policy.every((statement) => evaluateStatement(args, statement))
272
+ }
package/src/server.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec'
2
+ import type { Capability } from './capability.js'
3
+ import type { RouteHandlerOptions, RouteOptions, RouteOutput } from './types.js'
4
+
5
+ export function route<Cap extends Capability<StandardSchemaV1>, Output>(
6
+ options: RouteOptions<Cap, Output>
7
+ ): RouteOutput<Cap, Output> {
8
+ return {
9
+ cap: options.capability,
10
+ fn: async ({ request, issuer, invocation, store }: RouteHandlerOptions) => {
11
+ const { capability, handler } = options
12
+
13
+ const args = await capability.validate(invocation.payload.args)
14
+ if (args.issues) {
15
+ throw new Error(args.issues.map((issue) => issue.message).join(', '))
16
+ }
17
+
18
+ // TODO: handle failure
19
+ const result = await handler({ args: args.value, store })
20
+
21
+ return result
22
+ },
23
+ }
24
+ }