iso-ucan 1.0.0 → 3.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/readme.md CHANGED
@@ -115,10 +115,11 @@ const invocation = await Invocation.from({
115
115
  })
116
116
  ```
117
117
 
118
- The store is keyed on `invocation.replayKey`, the CID of the signed payload.
119
- Do not deduplicate on `invocation.cid`: it also covers the signature, and
120
- some signature algorithms (such as ECDSA) accept more than one valid signature
121
- for the same payload.
118
+ The store is keyed on `invocation.cid`. `iso-ucan` verifies signatures in
119
+ strict mode, which accepts only the
120
+ [canonical Varsig encoding](https://github.com/ChainAgnostic/varsig#canonical-signatures),
121
+ for example low-S for ECDSA. An attacker can't re-encode an intercepted
122
+ invocation under a different CID.
122
123
 
123
124
  `KVReplayStore` is atomic within one process only. When several processes or
124
125
  instances execute invocations, implement the `ReplayStore` interface on a
@@ -128,11 +129,58 @@ shared store with an atomic set-if-absent, such as Redis `SET NX` or SQL
128
129
  ## Revocation
129
130
 
130
131
  Pass `isRevoked` to `Delegation.from`, `Delegation#validate` or
131
- `Invocation.from` to reject revoked delegations by CID. Because ECDSA and
132
- EIP-191 signatures can be re-encoded into another valid signature, a revoked
133
- delegation can reappear under a different CID. `iso-ucan` therefore calls
134
- `isRevoked` with every CID the delegation could have been issued under (up to
135
- 2 for ECDSA and 4 for EIP-191), and rejects it if any is revoked.
132
+ `Invocation.from` to reject revoked delegations by CID. Strict verification
133
+ rejects non-canonical signatures, so a revoked delegation can't come back under
134
+ a different CID.
135
+
136
+ ### Migrate revocation lists from earlier versions
137
+
138
+ Earlier versions accepted ECDSA (ES256, ES384, ES512) signatures with high S,
139
+ and EIP-191 signatures with `v` of 0 or 1. If your revocation list holds the
140
+ CID of such a delegation, anyone holding the delegation can convert it to the
141
+ canonical encoding. The canonical CID is not in your list, so the revoked
142
+ delegation passes `isRevoked`.
143
+
144
+ To keep those revocations, add the canonical CID of each revoked delegation to
145
+ your list. Compute it from the stored delegation bytes:
146
+
147
+ ```js
148
+ import { Delegation } from 'iso-ucan/delegation'
149
+ import * as Envelope from 'iso-ucan/envelope'
150
+ import { normalizeS } from 'iso-signatures/signers/ecdsa.js'
151
+ import { normalizeSignature } from 'iso-signatures/signers/eip191.js'
152
+
153
+ const CURVES = { ES256: 'P-256', ES384: 'P-384', ES512: 'P-521' }
154
+
155
+ /**
156
+ * @param {Uint8Array} bytes - Revoked delegation, as stored
157
+ * @param {import('iso-ucan/types').VerifierResolver} verifierResolver
158
+ */
159
+ async function canonicalCid(bytes, verifierResolver) {
160
+ const envelope = Envelope.decode({ envelope: bytes })
161
+ let signature = envelope.signature
162
+ if (envelope.alg === 'EIP191') {
163
+ signature = normalizeSignature(signature)
164
+ } else if (envelope.alg in CURVES) {
165
+ signature = normalizeS(CURVES[envelope.alg], signature)
166
+ }
167
+
168
+ const canonical = Envelope.encode({
169
+ signature,
170
+ signaturePayload: Envelope.getSignaturePayload({
171
+ spec: envelope.spec,
172
+ version: envelope.version,
173
+ signatureType: envelope.alg,
174
+ payload: envelope.payload,
175
+ }),
176
+ })
177
+ const delegation = await Delegation.from({ bytes: canonical, verifierResolver })
178
+ return delegation.cid
179
+ }
180
+ ```
181
+
182
+ Ed25519 and ES256K signatures were already canonical, so their CIDs don't
183
+ change.
136
184
 
137
185
  ## EIP-191 wallet signing
138
186
 
package/src/delegation.js CHANGED
@@ -3,12 +3,15 @@ import { parse as didParse } from 'iso-did'
3
3
  import { randomBytes } from 'iso-web/crypto'
4
4
  import * as Envelope from './envelope.js'
5
5
  import {
6
+ assertCanonical,
6
7
  assertExpiration,
7
8
  assertIsValidCommand,
8
9
  assertMeta,
9
10
  assertNonce,
10
11
  assertNotBefore,
11
12
  assertNotRevoked,
13
+ assertPolicy,
14
+ assertTimestamp,
12
15
  cid,
13
16
  expOrTtl,
14
17
  verifySignature,
@@ -84,12 +87,13 @@ export class Delegation {
84
87
  )
85
88
  }
86
89
 
90
+ assertCanonical(envelope, bytes)
87
91
  assertStructure(envelope.payload, options.now)
88
92
 
89
93
  await verifySignature(envelope, verifierResolver, didResolveOptions)
90
94
 
91
95
  const _cid = await cid(envelope)
92
- await assertNotRevoked(envelope, options.isRevoked)
96
+ await assertNotRevoked(_cid, options.isRevoked)
93
97
 
94
98
  return new Delegation(envelope, bytes, _cid)
95
99
  }
@@ -112,6 +116,7 @@ export class Delegation {
112
116
  )
113
117
  }
114
118
 
119
+ assertCanonical(envelope, bytes)
115
120
  assertStructure(envelope.payload, options.now)
116
121
  const _cid = await cid(envelope)
117
122
  return new Delegation(envelope, bytes, _cid)
@@ -146,7 +151,7 @@ export class Delegation {
146
151
 
147
152
  assertStructure(payload, options.now)
148
153
 
149
- const { signature, signaturePayload } = await Envelope.sign({
154
+ const { alg, signature, signaturePayload } = await Envelope.sign({
150
155
  spec: 'dlg',
151
156
  signer: options.iss,
152
157
  payload,
@@ -156,7 +161,7 @@ export class Delegation {
156
161
 
157
162
  /** @type {import("./types.js").DecodedEnvelope<'dlg'>} */
158
163
  const envelope = {
159
- alg: options.iss.signatureType,
164
+ alg,
160
165
  enc: 'DAG-CBOR',
161
166
  signature,
162
167
  payload,
@@ -182,7 +187,7 @@ export class Delegation {
182
187
  options.verifierResolver,
183
188
  options.didResolver
184
189
  )
185
- await assertNotRevoked(this.envelope, options.isRevoked)
190
+ await assertNotRevoked(this.cid, options.isRevoked)
186
191
 
187
192
  return true
188
193
  }
@@ -232,9 +237,10 @@ function assertStructure(payload, now) {
232
237
 
233
238
  assertIsValidCommand(payload.cmd)
234
239
  assertExpiration(payload.exp, now)
240
+ assertTimestamp(payload.nbf, 'nbf')
235
241
  assertNonce(payload.nonce)
236
242
 
237
- // assertPolicy(payload.pol)
243
+ assertPolicy(payload.pol)
238
244
 
239
245
  assertMeta(payload.meta)
240
246
  }
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
  *
@@ -110,6 +187,10 @@ export async function sign(options) {
110
187
  })
111
188
  const signature = await signer.sign(cbor.encode(signaturePayload))
112
189
  return {
190
+ // getSignaturePayload throws for algorithms varsig does not support
191
+ alg: /** @type {import('./types.js').VarsigAlgorithm} */ (
192
+ signer.signatureType
193
+ ),
113
194
  signature,
114
195
  signaturePayload,
115
196
  }
@@ -141,20 +222,20 @@ export function encode(options) {
141
222
  export function decode(options) {
142
223
  const { envelope } = options
143
224
 
144
- const decoded = /** @type typeof cbor.decode<Envelope> **/ (cbor.decode)(
145
- /** @type {ByteView<Envelope>} */ (envelope)
146
- )
225
+ const decoded = cbor.decode(envelope)
147
226
 
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)]
227
+ if (!Array.isArray(decoded) || decoded.length !== 2) {
228
+ throw new TypeError(
229
+ `Invalid envelope expected array of 2 elements [signature, signature payload] got ${Array.isArray(decoded) ? `array of ${decoded.length} elements` : typeof decoded}`
230
+ )
231
+ }
153
232
 
154
- const [_ucan, specVersion] = payloadTag.split('/')
155
- const [spec, version] = specVersion.split('@')
233
+ const [signature, sigPayload] = decoded
234
+ if (!(signature instanceof Uint8Array)) {
235
+ throw new TypeError('Invalid envelope signature expected bytes')
236
+ }
156
237
 
157
- const { alg, enc } = varsig.decode(header)
238
+ const { alg, enc, spec, version, payload } = parseSignaturePayload(sigPayload)
158
239
 
159
240
  return {
160
241
  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,17 +9,17 @@ 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
- replayKey,
16
18
  verifySignature,
17
19
  } from './utils.js'
18
20
 
19
21
  /**
20
22
  * @import {Delegation} from './delegation.js'
21
- * @import {CID} from 'multiformats'
22
23
  */
23
24
 
24
25
  /**
@@ -35,32 +36,19 @@ export class Invocation {
35
36
  */
36
37
  cid
37
38
 
38
- /**
39
- * Key to deduplicate this invocation on: the CID of its signature payload.
40
- *
41
- * Use this instead of {@link Invocation.cid} to detect replays. The CID
42
- * covers the signature bytes, and some signature algorithms (e.g. ECDSA)
43
- * accept more than one valid signature for the same payload.
44
- *
45
- * @type {string}
46
- */
47
- replayKey
48
-
49
39
  /**
50
40
  *
51
41
  * @param {import("./types.js").DecodedEnvelope<'inv'>} envelope
52
42
  * @param {Uint8Array} bytes
53
43
  * @param {CID} cid
54
44
  * @param {Delegation[]} delegations
55
- * @param {string} replayKey
56
45
  */
57
- constructor(envelope, bytes, cid, delegations, replayKey) {
46
+ constructor(envelope, bytes, cid, delegations) {
58
47
  this.envelope = envelope
59
48
  this.payload = envelope.payload
60
49
  this.bytes = bytes
61
50
  this.cid = cid
62
51
  this.delegations = delegations
63
- this.replayKey = replayKey
64
52
  }
65
53
 
66
54
  /**
@@ -87,7 +75,7 @@ export class Invocation {
87
75
  )
88
76
  }
89
77
 
90
- assertStructure(envelope.payload)
78
+ assertStructure(envelope.payload, options.now)
91
79
  await verifySignature(
92
80
  envelope,
93
81
  options.verifierResolver,
@@ -101,6 +89,13 @@ export class Invocation {
101
89
  if (iss.did !== envelope.payload.sub) {
102
90
  for (const proof of envelope.payload.prf) {
103
91
  const delegation = await options.resolveProof(proof)
92
+ // The signature covers the proof CIDs, so the resolved delegation
93
+ // must be exactly that one
94
+ if (!delegation.cid.equals(proof)) {
95
+ throw new Error(
96
+ `UCAN Invocation proof CID mismatch, expected ${proof} but resolver returned ${delegation.cid}`
97
+ )
98
+ }
104
99
  await delegation.validate(options)
105
100
  proofs.push(delegation)
106
101
  }
@@ -112,20 +107,20 @@ export class Invocation {
112
107
  assertMaxTtl(envelope.payload.exp, options.maxTtl, options.now)
113
108
  }
114
109
 
115
- const _replayKey = await replayKey(envelope)
110
+ const _cid = await cid(envelope)
116
111
 
117
112
  // Must run last, so only fully validated invocations are recorded
118
113
  if (
119
114
  options.replayStore &&
120
- !(await options.replayStore.checkAndSet(_replayKey, envelope.payload.exp))
115
+ !(await options.replayStore.checkAndSet(
116
+ _cid.toString(),
117
+ envelope.payload.exp
118
+ ))
121
119
  ) {
122
- throw new Error(
123
- `UCAN Invocation replay detected, already seen ${_replayKey}`
124
- )
120
+ throw new Error(`UCAN Invocation replay detected, already seen ${_cid}`)
125
121
  }
126
122
 
127
- const _cid = await cid(envelope)
128
- return new Invocation(envelope, bytes, _cid, proofs, _replayKey)
123
+ return new Invocation(envelope, bytes, _cid, proofs)
129
124
  }
130
125
 
131
126
  /**
@@ -151,17 +146,15 @@ export class Invocation {
151
146
  if (options.cause) {
152
147
  payload.cause = options.cause
153
148
  }
154
- if (options.meta) {
149
+ // `meta` MUST be omitted when empty
150
+ if (options.meta && Object.keys(options.meta).length > 0) {
155
151
  payload.meta = options.meta
156
152
  }
157
- if (options.iat) {
153
+ if (options.iat !== undefined) {
158
154
  payload.iat = options.iat
159
155
  }
160
- if (options.nbf) {
161
- payload.nbf = options.nbf
162
- }
163
156
 
164
- assertStructure(payload)
157
+ assertStructure(payload, options.now)
165
158
 
166
159
  if (options.iss.did !== options.sub) {
167
160
  for (const proof of options.prf) {
@@ -171,7 +164,7 @@ export class Invocation {
171
164
  assertProofs(payload, options.prf)
172
165
  }
173
166
 
174
- const { signature, signaturePayload } = await Envelope.sign({
167
+ const { alg, signature, signaturePayload } = await Envelope.sign({
175
168
  spec: 'inv',
176
169
  signer: options.iss,
177
170
  payload,
@@ -181,7 +174,7 @@ export class Invocation {
181
174
 
182
175
  /** @type {import("./types.js").DecodedEnvelope<'inv'>} */
183
176
  const envelope = {
184
- alg: options.iss.signatureType,
177
+ alg,
185
178
  enc: 'DAG-CBOR',
186
179
  signature,
187
180
  payload,
@@ -189,13 +182,7 @@ export class Invocation {
189
182
  version: Envelope.VERSION,
190
183
  }
191
184
 
192
- return new Invocation(
193
- envelope,
194
- bytes,
195
- await cid(envelope),
196
- options.prf,
197
- await replayKey(envelope)
198
- )
185
+ return new Invocation(envelope, bytes, await cid(envelope), options.prf)
199
186
  }
200
187
  }
201
188
 
@@ -219,8 +206,9 @@ function assertMaxTtl(exp, maxTtl, now = nowInSeconds()) {
219
206
 
220
207
  /**
221
208
  * @param {import('./types.js').InvocationPayload} payload
209
+ * @param {number} [now]
222
210
  */
223
- function assertStructure(payload) {
211
+ function assertStructure(payload, now) {
224
212
  didParse(payload.iss)
225
213
  didParse(payload.sub)
226
214
 
@@ -237,8 +225,29 @@ function assertStructure(payload) {
237
225
  assertIsValidCommand(payload.cmd)
238
226
  assertArgs(payload.args)
239
227
  assertMeta(payload.meta)
228
+ // Spec: `meta` MUST be a non-empty map, and omitted when empty
229
+ if (
230
+ payload.meta !== undefined &&
231
+ (!isObject(payload.meta) || Object.keys(payload.meta).length === 0)
232
+ ) {
233
+ throw new TypeError(
234
+ 'UCAN Invocation meta must be a non-empty map, omit it when empty'
235
+ )
236
+ }
240
237
  assertNonce(payload.nonce)
241
- assertExpiration(payload.exp)
238
+ assertExpiration(payload.exp, now)
239
+ assertTimestamp(payload.iat, 'iat')
240
+ // `nbf` is a delegation field, an invoker could wrongly expect it to delay execution
241
+ if ('nbf' in payload) {
242
+ throw new TypeError('UCAN Invocation must not have nbf')
243
+ }
244
+ // `prf` is required, even when empty for a self-issued invocation
245
+ if (
246
+ !Array.isArray(payload.prf) ||
247
+ !payload.prf.every((proof) => CID.asCID(proof) !== null)
248
+ ) {
249
+ throw new TypeError('UCAN Invocation prf must be an array of CIDs')
250
+ }
242
251
  }
243
252
 
244
253
  /**