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.
@@ -41,10 +41,6 @@ export interface PayloadBase {
41
41
  * Expiration UTC Unix Timestamp in seconds (valid until)
42
42
  */
43
43
  exp: number | null
44
- /**
45
- * "Not before" UTC Unix Timestamp in seconds (valid from)
46
- */
47
- nbf?: number
48
44
  }
49
45
 
50
46
  /**
@@ -83,6 +79,10 @@ export interface DelegationPayload<Args = unknown> extends PayloadBase {
83
79
  */
84
80
  sub: DID | null
85
81
  pol: Policy<Args>
82
+ /**
83
+ * "Not before" UTC Unix Timestamp in seconds (valid from)
84
+ */
85
+ nbf?: number
86
86
  }
87
87
 
88
88
  export type Payload<Args = unknown> =
@@ -24,7 +24,6 @@ type PathsToStringProps<T> = T extends
24
24
  | `[${number}:${number}]`
25
25
  | `[${number}:]`
26
26
  | `[:${number}]`
27
- | `[:]`
28
27
  | PathsToStringProps<U>
29
28
  : T extends object
30
29
  ? {
@@ -73,7 +72,11 @@ type SelectorValue<T, S extends string> = S extends `.${infer K}`
73
72
  export type EqualityOp = '==' | '!='
74
73
  export type Equality<Args = unknown> = [EqualityOp, Selector<Args>, unknown]
75
74
  export type InequalityOp = '<' | '<=' | '>' | '>='
76
- export type Inequality<Args = unknown> = [InequalityOp, Selector<Args>, number]
75
+ export type Inequality<Args = unknown> = [
76
+ InequalityOp,
77
+ Selector<Args>,
78
+ number | bigint,
79
+ ]
77
80
 
78
81
  export type NegateOp = 'not'
79
82
  export type Negate<Args = unknown> = [NegateOp, Statement<Args>]
@@ -11,12 +11,23 @@ export type VarsigEncoding = 'RAW' | 'DAG-CBOR'
11
11
 
12
12
  /**
13
13
  * Supported signature types
14
+ *
15
+ * RSA (`RS256`) is not supported.
14
16
  */
15
- export type VarsigAlgorithm = SignatureType
17
+ export type VarsigAlgorithm = Exclude<SignatureType, 'RS256'>
16
18
 
17
19
  export interface VarsigOptions {
18
20
  enc: VarsigEncoding
19
21
  alg: VarsigAlgorithm
20
22
  }
21
23
 
24
+ /**
25
+ * Options for varsig `encode`. Accepts any signer type so unsupported
26
+ * algorithms (e.g. `RS256`) fail at runtime with a clear error.
27
+ */
28
+ export interface VarsigEncodeOptions {
29
+ enc: VarsigEncoding
30
+ alg: SignatureType
31
+ }
32
+
22
33
  export type DecodeOutput = VarsigOptions
package/src/types.ts CHANGED
@@ -19,13 +19,6 @@ export * from './types/policy'
19
19
  export * from './types/varsig'
20
20
 
21
21
  export type ResolveProof = (proof: CID) => Promise<Delegation>
22
- /**
23
- * Check whether a delegation CID is revoked.
24
- *
25
- * Called once for each CID the delegation could have been issued under
26
- * (up to 2 for ECDSA and 4 for EIP-191 signatures), since those signatures
27
- * can be re-encoded into another valid signature with a different CID.
28
- */
29
22
  export type IsRevoked = (cid: CID) => Promise<boolean>
30
23
  export type VerifierResolver = Resolver
31
24
  export type DidResolver = _DidResolver
@@ -123,14 +116,22 @@ export interface InvocationOptions extends DelegationValidateOptions {
123
116
  * Issued at time in seconds
124
117
  */
125
118
  iat?: number
126
- nbf?: number
127
119
  nonce?: Uint8Array
128
120
  cause?: CID
121
+ /**
122
+ * Metadata, omitted from the payload when empty
123
+ */
129
124
  meta?: CborObject
130
125
  }
131
126
 
132
127
  export interface InvocationFromOptions extends DelegationValidateOptions {
133
128
  bytes: Uint8Array
129
+ /**
130
+ * The current time in seconds, used for the invocation's own `exp`, the time
131
+ * bounds of its proofs and `maxTtl`. Mostly used for testing.
132
+ * @default Math.floor(Date.now() / 1000)
133
+ */
134
+ now?: number
134
135
  audience?: VerifiableDID
135
136
  resolveProof: ResolveProof
136
137
  /**
@@ -156,7 +157,7 @@ export interface ReplayStore {
156
157
  * Atomically record `key` and resolve `true`, or resolve `false` if `key`
157
158
  * was already recorded and has not expired.
158
159
  *
159
- * @param key - Replay key of the invocation, see {@link Invocation.replayKey}
160
+ * @param key - CID of the invocation, see {@link Invocation.cid}
160
161
  * @param expiration - Unix time in seconds after which the key can be forgotten, or `null` to keep it forever
161
162
  */
162
163
  checkAndSet(key: string, expiration: number | null): Promise<boolean>
package/src/utils.js CHANGED
@@ -6,6 +6,7 @@ import { sha256 } from 'multiformats/hashes/sha2'
6
6
  import { z } from 'zod/v4'
7
7
 
8
8
  import * as Envelope from './envelope.js'
9
+ import { parseSelector } from './policy.js'
9
10
  import * as varsig from './varsig.js'
10
11
 
11
12
  /**
@@ -52,19 +53,26 @@ export async function cid(envelope) {
52
53
  }
53
54
 
54
55
  /**
55
- * Replay key of a UCAN: the CID of its signature payload, the exact bytes the
56
- * signature covers.
56
+ * Assert that `bytes` is the canonical DAG-CBOR encoding of `envelope`.
57
57
  *
58
- * Unlike {@link cid}, it does not depend on the signature bytes, so a second
59
- * valid signature over the same payload (for example a malleated ECDSA
60
- * signature) gets the same key.
58
+ * {@link cid} hashes the re-encoded envelope, and the DAG-CBOR decoder accepts
59
+ * some non-canonical input (e.g. unsorted map keys or short floats). Without
60
+ * this check, different byte strings would share a CID.
61
61
  *
62
62
  * @param {import('./types.js').DecodedEnvelope<PayloadSpec>} envelope
63
+ * @param {Uint8Array} bytes
63
64
  */
64
- export async function replayKey(envelope) {
65
- const hash = await sha256.digest(dagCbor.encode(signaturePayload(envelope)))
66
-
67
- return CID.create(1, dagCbor.code, hash).toString()
65
+ export function assertCanonical(envelope, bytes) {
66
+ const encoded = Envelope.encode({
67
+ signature: envelope.signature,
68
+ // @ts-expect-error
69
+ signaturePayload: signaturePayload(envelope),
70
+ })
71
+ if (!equals(encoded, bytes)) {
72
+ throw new TypeError(
73
+ 'UCAN envelope is not canonical DAG-CBOR, re-encoding does not match the received bytes'
74
+ )
75
+ }
68
76
  }
69
77
 
70
78
  /**
@@ -94,9 +102,6 @@ export function isSigAndDidCompatible(did, sigType) {
94
102
  return true
95
103
  }
96
104
 
97
- if (did.verifiableDid.type === 'RSA' && sigType === 'RS256') {
98
- return true
99
- }
100
105
  if (did.verifiableDid.type === 'secp256k1' && sigType === 'EIP191') {
101
106
  return true
102
107
  }
@@ -124,6 +129,21 @@ export function assertExpiration(exp, now = nowInSeconds()) {
124
129
  }
125
130
  }
126
131
 
132
+ /**
133
+ * Validate an optional UCAN timestamp (e.g. `nbf` or `iat`): when present it
134
+ * must be an integer number of seconds within ±(2^53 − 1).
135
+ *
136
+ * @param {unknown} value
137
+ * @param {string} name - Field name used in the error message
138
+ */
139
+ export function assertTimestamp(value, name) {
140
+ if (value !== undefined && !Number.isSafeInteger(value)) {
141
+ throw new TypeError(
142
+ `UCAN ${name} must be a safe integer. Received: ${value}`
143
+ )
144
+ }
145
+ }
146
+
127
147
  /**
128
148
  * Validate the not before time of a UCAN
129
149
  *
@@ -131,7 +151,7 @@ export function assertExpiration(exp, now = nowInSeconds()) {
131
151
  * @param {number} [now]
132
152
  */
133
153
  export function assertNotBefore(nbf, now = nowInSeconds()) {
134
- if (nbf && nbf > now) {
154
+ if (nbf !== undefined && nbf > now) {
135
155
  throw new Error('UCAN not valid yet')
136
156
  }
137
157
  }
@@ -173,6 +193,9 @@ export async function verifySignature(
173
193
  message: dagCbor.encode(signaturePayload(envelope)),
174
194
  did: issuer,
175
195
  type: envelope.alg,
196
+ // Accept only canonical signatures, such as low-S ECDSA, so each UCAN
197
+ // has one CID for replay and revocation checks
198
+ strict: true,
176
199
  })
177
200
 
178
201
  if (!isVerified) {
@@ -310,146 +333,15 @@ export function assertMeta(meta) {
310
333
  }
311
334
  }
312
335
 
313
- /**
314
- * ECDSA curve orders by signature algorithm, with the byte length of each
315
- * signature component.
316
- *
317
- * @type {Record<string, {n: bigint, size: number}>}
318
- */
319
- const ECDSA_CURVES = {
320
- ES256: {
321
- n: 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n,
322
- size: 32,
323
- },
324
- ES384: {
325
- n: 0xffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf581a0db248b0a77aecec196accc52973n,
326
- size: 48,
327
- },
328
- ES512: {
329
- n: 0x01fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa51868783bf2f966b7fcc0148f709a5d03bb5c9b8899c47aebb6fb71e91386409n,
330
- size: 66,
331
- },
332
- ES256K: {
333
- n: 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n,
334
- size: 32,
335
- },
336
- EIP191: {
337
- n: 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n,
338
- size: 32,
339
- },
340
- }
341
-
342
- /**
343
- * @param {Uint8Array} bytes
344
- */
345
- function bytesToBigInt(bytes) {
346
- let n = 0n
347
- for (const b of bytes) n = (n << 8n) | BigInt(b)
348
- return n
349
- }
350
-
351
- /**
352
- * @param {bigint} n
353
- * @param {number} size
354
- */
355
- function bigIntToBytes(n, size) {
356
- const out = new Uint8Array(size)
357
- for (let i = size - 1; i >= 0; i--) {
358
- out[i] = Number(n & 0xffn)
359
- n >>= 8n
360
- }
361
- return out
362
- }
363
-
364
- /**
365
- * Signatures a signer could have issued that verify the same as `signature`.
366
- *
367
- * ECDSA signatures are malleable: `(r, s)` and `(r, n - s)` are both valid.
368
- * EIP-191 signatures additionally carry a recovery byte `v` that wallets
369
- * return as 27/28 or 0/1. Verifiers also accept EIP-155 style `v >= 35`,
370
- * but no signer issues those for `personal_sign`, so they are not listed.
371
- *
372
- * The result always includes `signature` itself. Other algorithms (Ed25519,
373
- * RSA) have a single valid signature.
374
- *
375
- * @param {import('iso-signatures/types').SignatureType} alg
376
- * @param {Uint8Array} signature
377
- * @returns {Uint8Array[]}
378
- */
379
- export function equivalentSignatures(alg, signature) {
380
- const curve = ECDSA_CURVES[alg]
381
- if (!curve) {
382
- return [signature]
383
- }
384
-
385
- const { n, size } = curve
386
- const isEip191 = alg === 'EIP191'
387
- if (signature.length !== size * 2 + (isEip191 ? 1 : 0)) {
388
- return [signature]
389
- }
390
-
391
- const r = signature.subarray(0, size)
392
- const s = bytesToBigInt(signature.subarray(size, size * 2))
393
- if (s === 0n || s >= n) {
394
- return [signature]
395
- }
396
- const sFlipped = bigIntToBytes(n - s, size)
397
-
398
- if (!isEip191) {
399
- return [signature, new Uint8Array([...r, ...sFlipped])]
400
- }
401
-
402
- const v = signature[size * 2]
403
- /** @type {number} */
404
- let parity
405
- if (v === 0 || v === 27) parity = 0
406
- else if (v === 1 || v === 28) parity = 1
407
- else if (v >= 35) parity = v % 2 === 0 ? 1 : 0
408
- else return [signature]
409
-
410
- const sBytes = signature.subarray(size, size * 2)
411
- const candidates = [
412
- new Uint8Array([...r, ...sBytes, parity]),
413
- new Uint8Array([...r, ...sBytes, parity + 27]),
414
- new Uint8Array([...r, ...sFlipped, 1 - parity]),
415
- new Uint8Array([...r, ...sFlipped, 28 - parity]),
416
- ]
417
- return [signature, ...candidates.filter((c) => !equals(c, signature))]
418
- }
419
-
420
- /**
421
- * CIDs of every envelope that verifies the same as `envelope`: one per
422
- * signature in {@link equivalentSignatures}, starting with the CID of
423
- * `envelope` itself.
424
- *
425
- * @param {import('./types.js').DecodedEnvelope<PayloadSpec>} envelope
426
- */
427
- export async function equivalentCids(envelope) {
428
- return await Promise.all(
429
- equivalentSignatures(envelope.alg, envelope.signature).map((signature) =>
430
- cid({ ...envelope, signature })
431
- )
432
- )
433
- }
434
-
435
336
  /**
436
337
  * Assert that the input is not revoked.
437
338
  *
438
- * Checks every equivalent CID of the envelope (see {@link equivalentCids}),
439
- * so re-encoding a revoked UCAN with another valid signature does not get it
440
- * past a CID-based revocation list.
441
- *
442
- * @param {import('./types.js').DecodedEnvelope<PayloadSpec>} envelope
339
+ * @param {CID} cid
443
340
  * @param {(cid: CID) => Promise<boolean>} [isRevokedFn]
444
341
  */
445
- export async function assertNotRevoked(envelope, isRevokedFn) {
446
- if (!isRevokedFn) {
447
- return
448
- }
449
- for (const c of await equivalentCids(envelope)) {
450
- if (await isRevokedFn(c)) {
451
- throw new Error('UCAN revoked')
452
- }
342
+ export async function assertNotRevoked(cid, isRevokedFn) {
343
+ if (isRevokedFn && (await isRevokedFn(cid))) {
344
+ throw new Error('UCAN revoked')
453
345
  }
454
346
  }
455
347
 
@@ -488,17 +380,32 @@ export const cborValue =
488
380
  })
489
381
  export const cborObject = z.record(z.string(), cborValue)
490
382
 
491
- export const selector = z.union([
492
- z.literal('.'),
493
- z.templateLiteral([z.literal('.'), z.string()]),
494
- ])
383
+ /**
384
+ * Policy selector, validated with {@link parseSelector}.
385
+ *
386
+ * @see https://github.com/ucan-wg/delegation#selectors
387
+ */
388
+ export const selector = z.string().superRefine((value, ctx) => {
389
+ try {
390
+ parseSelector(value)
391
+ } catch (error) {
392
+ ctx.addIssue({
393
+ code: 'custom',
394
+ message: /** @type {Error} */ (error).message,
395
+ })
396
+ }
397
+ })
495
398
 
496
399
  /**
497
400
  * @typedef {z.infer<typeof selector>} Selector
498
401
  */
499
402
 
500
403
  /**
501
- * Statement
404
+ * Statement, as defined by the spec's IPLD schema. Every statement is a tuple
405
+ * of exact arity. Inequality values are integers or floats; large integers
406
+ * decode from dag-cbor as `bigint`.
407
+ *
408
+ * @see https://github.com/ucan-wg/delegation#policy
502
409
  */
503
410
  export const statement =
504
411
  /** @type {typeof z.lazy<z.ZodType<import('../src/types.js').Statement<unknown>>>} */ (
@@ -509,7 +416,7 @@ export const statement =
509
416
  z.tuple([
510
417
  z.union([z.literal('=='), z.literal('!=')]),
511
418
  selector,
512
- z.unknown(),
419
+ cborValue,
513
420
  ]), // Equality
514
421
  z.tuple([
515
422
  z.union([
@@ -519,14 +426,14 @@ export const statement =
519
426
  z.literal('>='),
520
427
  ]),
521
428
  selector,
522
- z.number(),
429
+ z.union([z.number(), z.bigint()]),
523
430
  ]), // Inequality
524
431
  z.tuple([z.literal('like'), selector, z.string()]), // Like
525
432
  z.tuple([z.literal('not'), statement]),
526
433
  z.tuple([z.literal('and'), z.array(statement)]),
527
434
  z.tuple([z.literal('or'), z.array(statement)]),
528
- z.tuple([z.literal('all'), z.string(), statement]),
529
- z.tuple([z.literal('any'), z.string(), statement]),
435
+ z.tuple([z.literal('all'), selector, statement]),
436
+ z.tuple([z.literal('any'), selector, statement]),
530
437
  ])
531
438
  })
532
439
 
package/src/varsig.js CHANGED
@@ -19,7 +19,6 @@ export const CODE_ENCODING = /** @types {const} */ {
19
19
  * @type {Record<number, string>}
20
20
  */
21
21
  export const SIG_PREFIX = /** @type {const} */ ({
22
- 0x12_05: 'RSA',
23
22
  0xed: 'EdDSA',
24
23
  0xec: 'ECDSA',
25
24
  })
@@ -37,8 +36,6 @@ const VARINT = {
37
36
  'DAG-CBOR': [113],
38
37
 
39
38
  /** Presets */
40
- // rsa(2b)+SHA2-256(1b)+256(1b)
41
- RS256: [133, 36, 18, 128, 2],
42
39
  // eddsa(2b)+edwards25519(2b)+SHA2-512(1b)
43
40
  Ed25519: [237, 1, 237, 1, 19],
44
41
  // ECDSA(2b) + p256(2b) + SHA2-256(1b)
@@ -54,7 +51,7 @@ const VARINT = {
54
51
  }
55
52
 
56
53
  /**
57
- * @param {import('./types.js').VarsigOptions} options
54
+ * @param {import('./types.js').VarsigEncodeOptions} options
58
55
  */
59
56
  export function encode(options) {
60
57
  const enc = VARINT[options.enc]
@@ -62,7 +59,9 @@ export function encode(options) {
62
59
  throw new TypeError(`Unsupported encoding ${options.enc}`)
63
60
  }
64
61
 
65
- const alg = VARINT[options.alg]
62
+ const alg = /** @type {Partial<Record<string, number[]>>} */ (VARINT)[
63
+ options.alg
64
+ ]
66
65
  if (!alg) {
67
66
  throw new TypeError(`Unsupported algorithm ${options.alg}`)
68
67
  }
@@ -95,7 +94,6 @@ function getPreset(bytes, presets) {
95
94
  throw new TypeError(`Unsupported preset ${bytes}`)
96
95
  }
97
96
 
98
- /**
99
97
  /**
100
98
  * Decode varsig header
101
99
  *
@@ -119,39 +117,44 @@ export function decode(buf) {
119
117
  }
120
118
  const [code] = varint.decode(buf, 2)
121
119
 
120
+ /** @type {import('./types.js').DecodeOutput} */
121
+ let output
122
122
  switch (SIG_PREFIX[code]) {
123
- case 'RSA': {
124
- return {
125
- alg: getPreset(bytes.readBytes(5), ['RS256']),
126
- enc: getEncoding(bytes.readBytes(1)),
127
- }
128
- }
129
123
  case 'EdDSA': {
130
- return {
124
+ output = {
131
125
  alg: getPreset(bytes.readBytes(5), ['Ed25519']),
132
126
  enc: getEncoding(bytes.readBytes(1)),
133
127
  }
128
+ break
134
129
  }
135
130
  case 'ECDSA': {
136
131
  const isEIP191 = varint.decode(buf, 2 + 5)[0] === EIP191_ENCODING
137
- if (isEIP191) {
138
- return {
139
- alg: getPreset(bytes.readBytes(8), ['EIP191']),
140
- enc: getEncoding(bytes.readBytes(1)),
141
- }
142
- }
143
-
144
- return {
145
- alg: getPreset(bytes.readBytes(5), [
146
- 'ES256',
147
- 'ES256K',
148
- 'ES384',
149
- 'ES512',
150
- ]),
151
- enc: getEncoding(bytes.readBytes(1)),
152
- }
132
+ output = isEIP191
133
+ ? {
134
+ alg: getPreset(bytes.readBytes(8), ['EIP191']),
135
+ enc: getEncoding(bytes.readBytes(1)),
136
+ }
137
+ : {
138
+ alg: getPreset(bytes.readBytes(5), [
139
+ 'ES256',
140
+ 'ES256K',
141
+ 'ES384',
142
+ 'ES512',
143
+ ]),
144
+ enc: getEncoding(bytes.readBytes(1)),
145
+ }
146
+ break
153
147
  }
154
148
  default:
155
149
  throw new TypeError(`Unsupported algorithm ${code}`)
156
150
  }
151
+
152
+ const trailing = buf.length - bytes.offset
153
+ if (trailing !== 0) {
154
+ throw new TypeError(
155
+ `Invalid varsig header ${trailing} trailing byte(s) after the payload encoding`
156
+ )
157
+ }
158
+
159
+ return output
157
160
  }