iso-ucan 0.5.2 → 2.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/src/envelope.js CHANGED
@@ -3,12 +3,63 @@ import { isObject } from './utils.js'
3
3
  import * as varsig from './varsig.js'
4
4
 
5
5
  /**
6
- * @import { Envelope, PayloadTag, EnvelopeDecodeOptions, EnvelopeEncodeOptions, EnvelopeSignOptions, SignaturePayload, PayloadSpec, DecodedEnvelope, DelegationPayload, InvocationPayload, Payload} from './types.js'
7
- * @import { ByteView } from 'multiformats'
6
+ * @import { PayloadTag, EnvelopeDecodeOptions, EnvelopeEncodeOptions, EnvelopeSignOptions, SignaturePayload, PayloadSpec, DecodedEnvelope, DelegationPayload, InvocationPayload, Payload} from './types.js'
8
7
  */
9
8
 
10
- /** Version */
11
- export const VERSION = '1.0.0-rc.1'
9
+ /**
10
+ * Payload tag version used for new tokens (`ucan/dlg@1.0.0`, `ucan/inv@1.0.0`)
11
+ *
12
+ * @see https://github.com/ucan-wg/delegation#type-tag
13
+ * @see https://github.com/ucan-wg/invocation#type-tag
14
+ */
15
+ export const VERSION = '1.0.0'
16
+
17
+ /**
18
+ * Payload tag versions accepted on decode.
19
+ *
20
+ * `1.0.0-rc.1` is still accepted so tokens issued by iso-ucan <= 1.0.0 keep
21
+ * verifying. New tokens are always encoded with {@link VERSION}.
22
+ *
23
+ * @type {readonly string[]}
24
+ */
25
+ export const SUPPORTED_VERSIONS = Object.freeze([VERSION, '1.0.0-rc.1'])
26
+
27
+ /**
28
+ * Payload specs
29
+ *
30
+ * @type {readonly PayloadSpec[]}
31
+ */
32
+ export const SPECS = Object.freeze(/** @type {const} */ (['dlg', 'inv']))
33
+
34
+ const PAYLOAD_TAG_REGEX = /^ucan\/([^@/]+)@(.+)$/
35
+
36
+ /**
37
+ * Parse and validate a payload tag (`ucan/<spec>@<version>`)
38
+ *
39
+ * @param {string} tag
40
+ */
41
+ export function parsePayloadTag(tag) {
42
+ const match = PAYLOAD_TAG_REGEX.exec(tag)
43
+ if (!match) {
44
+ throw new TypeError(
45
+ `Invalid payload tag "${tag}" expected "ucan/<spec>@<version>"`
46
+ )
47
+ }
48
+ const spec = /** @type {PayloadSpec} */ (match[1])
49
+ const version = match[2]
50
+ if (!SPECS.includes(spec)) {
51
+ throw new TypeError(
52
+ `Unsupported payload tag spec "${spec}" in "${tag}" expected one of: ${SPECS.join(', ')}`
53
+ )
54
+ }
55
+ if (!SUPPORTED_VERSIONS.includes(version)) {
56
+ throw new TypeError(
57
+ `Unsupported payload tag version "${version}" in "${tag}" expected one of: ${SUPPORTED_VERSIONS.join(', ')}`
58
+ )
59
+ }
60
+
61
+ return { tag: /** @type {PayloadTag} */ (tag), spec, version }
62
+ }
12
63
 
13
64
  /**
14
65
  * Get the signature payload for a given payload
@@ -22,7 +73,7 @@ export const VERSION = '1.0.0-rc.1'
22
73
  export function getSignaturePayload(options) {
23
74
  const { spec, version, signatureType, payload } = options
24
75
 
25
- const payloadTag = /** @type {PayloadTag} */ (
76
+ const { tag: payloadTag } = parsePayloadTag(
26
77
  `ucan/${spec}@${version ?? VERSION}`
27
78
  )
28
79
 
@@ -37,44 +88,70 @@ export function getSignaturePayload(options) {
37
88
  }
38
89
 
39
90
  /**
40
- * Decode a signature payload
91
+ * Validate a decoded signature payload (`{ h, "ucan/<spec>@<version>": payload }`)
41
92
  *
42
- * @param {Uint8Array} bytes
93
+ * @see https://github.com/ucan-wg/spec#envelope
94
+ * @param {unknown} sigPayload
43
95
  */
44
- export function decodeSignaturePayload(bytes) {
45
- const decode = /** @type typeof cbor.decode<SignaturePayload> **/ (
46
- cbor.decode
47
- )
48
- const decoded = decode(bytes)
49
-
50
- if (!isObject(decoded)) {
96
+ function parseSignaturePayload(sigPayload) {
97
+ if (!isObject(sigPayload)) {
51
98
  throw new TypeError(
52
- `Invalid signature payload expected object got ${typeof decoded}`
99
+ `Invalid signature payload expected object got ${Array.isArray(sigPayload) ? 'array' : typeof sigPayload}`
53
100
  )
54
101
  }
55
- if (!('h' in decoded)) {
102
+ const keys = Object.keys(sigPayload)
103
+ if (keys.length !== 2) {
104
+ throw new TypeError(
105
+ `Invalid signature payload expected 2 keys (h and payload tag) got ${keys.length}: ${keys.join(', ')}`
106
+ )
107
+ }
108
+ if (!keys.includes('h')) {
56
109
  throw new TypeError('Invalid signature payload missing h')
57
110
  }
58
- const keys = Object.keys(decoded)
59
- if (keys.length !== 2) {
111
+ const { tag, spec, version } = parsePayloadTag(
112
+ /** @type {string} */ (keys.find((key) => key !== 'h'))
113
+ )
114
+ // DAG-CBOR sorts map keys by length first, so `h` must be the first key.
115
+ if (keys[0] !== 'h') {
60
116
  throw new TypeError(
61
- `Invalid signature payload expected 2 keys got ${keys.length}`
117
+ 'Invalid signature payload keys are not in canonical DAG-CBOR order'
62
118
  )
63
119
  }
64
- if (!keys[1].startsWith('ucan/dlg') && !keys[1].startsWith('ucan/inv')) {
65
- throw new TypeError('Invalid signature payload missing payload')
120
+
121
+ const header = sigPayload.h
122
+ if (!(header instanceof Uint8Array)) {
123
+ throw new TypeError('Invalid signature payload h expected bytes')
124
+ }
125
+ const { alg, enc } = varsig.decode(header)
126
+ if (enc !== 'DAG-CBOR') {
127
+ throw new TypeError(
128
+ `Unsupported varsig payload encoding ${enc} expected DAG-CBOR`
129
+ )
130
+ }
131
+
132
+ const payload = sigPayload[tag]
133
+ if (!isObject(payload)) {
134
+ throw new TypeError(`Invalid signature payload ${tag} expected object`)
66
135
  }
67
136
 
68
- const payloadTag = /** @type {PayloadTag} */ (keys[1])
69
- const { alg, enc } = varsig.decode(decoded.h)
70
- const payload = decoded[payloadTag]
71
137
  return {
72
138
  alg,
73
139
  enc,
74
- payload,
140
+ spec,
141
+ version,
142
+ payload: /** @type {Payload} */ (/** @type {unknown} */ (payload)),
75
143
  }
76
144
  }
77
145
 
146
+ /**
147
+ * Decode and validate a signature payload
148
+ *
149
+ * @param {Uint8Array} bytes
150
+ */
151
+ export function decodeSignaturePayload(bytes) {
152
+ return parseSignaturePayload(cbor.decode(bytes))
153
+ }
154
+
78
155
  /**
79
156
  * Check if a payload is an invocation payload
80
157
  *
@@ -141,20 +218,20 @@ export function encode(options) {
141
218
  export function decode(options) {
142
219
  const { envelope } = options
143
220
 
144
- const decoded = /** @type typeof cbor.decode<Envelope> **/ (cbor.decode)(
145
- /** @type {ByteView<Envelope>} */ (envelope)
146
- )
221
+ const decoded = cbor.decode(envelope)
147
222
 
148
- const [signature, sigPayload] = decoded
149
- const [_h, payloadTag] = Object.keys(sigPayload)
150
- const header = sigPayload.h
151
- /** @type {DelegationPayload | InvocationPayload} */
152
- const payload = sigPayload[/** @type {PayloadTag} */ (payloadTag)]
223
+ if (!Array.isArray(decoded) || decoded.length !== 2) {
224
+ throw new TypeError(
225
+ `Invalid envelope expected array of 2 elements [signature, signature payload] got ${Array.isArray(decoded) ? `array of ${decoded.length} elements` : typeof decoded}`
226
+ )
227
+ }
153
228
 
154
- const [_ucan, specVersion] = payloadTag.split('/')
155
- const [spec, version] = specVersion.split('@')
229
+ const [signature, sigPayload] = decoded
230
+ if (!(signature instanceof Uint8Array)) {
231
+ throw new TypeError('Invalid envelope signature expected bytes')
232
+ }
156
233
 
157
- const { alg, enc } = varsig.decode(header)
234
+ const { alg, enc, spec, version, payload } = parseSignaturePayload(sigPayload)
158
235
 
159
236
  return {
160
237
  alg,
package/src/invocation.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parse as didParse } from 'iso-did'
2
2
  import { randomBytes } from 'iso-web/crypto'
3
+ import { CID } from 'multiformats/cid'
3
4
  import * as Envelope from './envelope.js'
4
5
  import { validate } from './policy.js'
5
6
  import {
@@ -8,9 +9,11 @@ import {
8
9
  assertIsValidCommand,
9
10
  assertMeta,
10
11
  assertNonce,
12
+ assertTimestamp,
11
13
  cid,
12
14
  commandCovers,
13
15
  expOrTtl,
16
+ isObject,
14
17
  nowInSeconds,
15
18
  replayKey,
16
19
  verifySignature,
@@ -18,7 +21,6 @@ import {
18
21
 
19
22
  /**
20
23
  * @import {Delegation} from './delegation.js'
21
- * @import {CID} from 'multiformats'
22
24
  */
23
25
 
24
26
  /**
@@ -79,18 +81,15 @@ export class Invocation {
79
81
  `Invalid envelope type. Expected "inv" but got "${envelope.spec}"`
80
82
  )
81
83
  }
82
- // Audience or subject must match receiver
83
- if (
84
- audience &&
85
- envelope.payload.aud !== audience.did &&
86
- envelope.payload.sub !== audience.did
87
- ) {
84
+ // The executor is `aud` when present, otherwise `sub`
85
+ const executor = envelope.payload.aud ?? envelope.payload.sub
86
+ if (audience && executor !== audience.did) {
88
87
  throw new TypeError(
89
- `UCAN Invocation audience or subject does not match receiver. Expected: ${audience.did} but got: ${envelope.payload.aud ?? envelope.payload.sub ?? 'null'}`
88
+ `UCAN Invocation audience does not match receiver. Expected: ${audience.did} but got: ${executor ?? 'null'}`
90
89
  )
91
90
  }
92
91
 
93
- assertStructure(envelope.payload)
92
+ assertStructure(envelope.payload, options.now)
94
93
  await verifySignature(
95
94
  envelope,
96
95
  options.verifierResolver,
@@ -104,6 +103,13 @@ export class Invocation {
104
103
  if (iss.did !== envelope.payload.sub) {
105
104
  for (const proof of envelope.payload.prf) {
106
105
  const delegation = await options.resolveProof(proof)
106
+ // The signature covers the proof CIDs, so the resolved delegation
107
+ // must be exactly that one (not an ECDSA-equivalent re-encoding)
108
+ if (!delegation.cid.equals(proof)) {
109
+ throw new Error(
110
+ `UCAN Invocation proof CID mismatch, expected ${proof} but resolver returned ${delegation.cid}`
111
+ )
112
+ }
107
113
  await delegation.validate(options)
108
114
  proofs.push(delegation)
109
115
  }
@@ -140,7 +146,6 @@ export class Invocation {
140
146
  /** @type {import("./types.js").InvocationPayload} */
141
147
  const payload = {
142
148
  iss: options.iss.toString(),
143
- aud: options.aud ? options.aud : options.sub,
144
149
  sub: options.sub,
145
150
  cmd: options.cmd,
146
151
  nonce,
@@ -148,20 +153,22 @@ export class Invocation {
148
153
  args: options.args,
149
154
  prf: options.prf.map((p) => p.cid),
150
155
  }
156
+ // `aud` MUST be omitted when the executor is the subject
157
+ if (options.aud && options.aud !== options.sub) {
158
+ payload.aud = options.aud
159
+ }
151
160
  if (options.cause) {
152
161
  payload.cause = options.cause
153
162
  }
154
- if (options.meta) {
163
+ // `meta` MUST be omitted when empty
164
+ if (options.meta && Object.keys(options.meta).length > 0) {
155
165
  payload.meta = options.meta
156
166
  }
157
- if (options.iat) {
167
+ if (options.iat !== undefined) {
158
168
  payload.iat = options.iat
159
169
  }
160
- if (options.nbf) {
161
- payload.nbf = options.nbf
162
- }
163
170
 
164
- assertStructure(payload)
171
+ assertStructure(payload, options.now)
165
172
 
166
173
  if (options.iss.did !== options.sub) {
167
174
  for (const proof of options.prf) {
@@ -219,20 +226,48 @@ function assertMaxTtl(exp, maxTtl, now = nowInSeconds()) {
219
226
 
220
227
  /**
221
228
  * @param {import('./types.js').InvocationPayload} payload
229
+ * @param {number} [now]
222
230
  */
223
- function assertStructure(payload) {
231
+ function assertStructure(payload, now) {
224
232
  didParse(payload.iss)
225
233
  didParse(payload.sub)
226
234
 
227
235
  if (payload.aud) {
228
236
  didParse(payload.aud)
237
+ // Spec: `aud` MUST be omitted when the executor is the subject
238
+ if (payload.aud === payload.sub) {
239
+ throw new TypeError(
240
+ 'UCAN Invocation audience must be omitted when it equals the subject'
241
+ )
242
+ }
229
243
  }
230
244
 
231
245
  assertIsValidCommand(payload.cmd)
232
246
  assertArgs(payload.args)
233
247
  assertMeta(payload.meta)
248
+ // Spec: `meta` MUST be a non-empty map, and omitted when empty
249
+ if (
250
+ payload.meta !== undefined &&
251
+ (!isObject(payload.meta) || Object.keys(payload.meta).length === 0)
252
+ ) {
253
+ throw new TypeError(
254
+ 'UCAN Invocation meta must be a non-empty map, omit it when empty'
255
+ )
256
+ }
234
257
  assertNonce(payload.nonce)
235
- assertExpiration(payload.exp)
258
+ assertExpiration(payload.exp, now)
259
+ assertTimestamp(payload.iat, 'iat')
260
+ // `nbf` is a delegation field, an invoker could wrongly expect it to delay execution
261
+ if ('nbf' in payload) {
262
+ throw new TypeError('UCAN Invocation must not have nbf')
263
+ }
264
+ // `prf` is required, even when empty for a self-issued invocation
265
+ if (
266
+ !Array.isArray(payload.prf) ||
267
+ !payload.prf.every((proof) => CID.asCID(proof) !== null)
268
+ ) {
269
+ throw new TypeError('UCAN Invocation prf must be an array of CIDs')
270
+ }
236
271
  }
237
272
 
238
273
  /**