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.
package/src/utils.js ADDED
@@ -0,0 +1,375 @@
1
+ import * as dagCbor from '@ipld/dag-cbor'
2
+ import { DID } from 'iso-did'
3
+ import { CID } from 'multiformats/cid'
4
+ import { sha256 } from 'multiformats/hashes/sha2'
5
+ import { z } from 'zod'
6
+
7
+ import * as Envelope from './envelope.js'
8
+ import * as varsig from './varsig.js'
9
+
10
+ /**
11
+ * @import {PayloadSpec} from './types.js'
12
+ */
13
+
14
+ /**
15
+ * Create the signature payload for a given envelope
16
+ *
17
+ * @param {import('./types.js').DecodedEnvelope<PayloadSpec>} envelope
18
+ * @returns
19
+ */
20
+ export function signaturePayload(envelope) {
21
+ const payloadTag = /** @type {import('./types.js').PayloadTag} */ (
22
+ `ucan/${envelope.spec}@${envelope.version}`
23
+ )
24
+ const payload = {
25
+ h: varsig.encode({
26
+ enc: envelope.enc,
27
+ alg: envelope.alg,
28
+ }),
29
+ [payloadTag]: envelope.payload,
30
+ }
31
+ return payload
32
+ }
33
+
34
+ export function nowInSeconds() {
35
+ return Math.floor(Date.now() / 1000)
36
+ }
37
+
38
+ /**
39
+ *
40
+ * @param {import('./types.js').DecodedEnvelope<PayloadSpec>} envelope
41
+ */
42
+ export async function cid(envelope) {
43
+ const bytes = Envelope.encode({
44
+ signature: envelope.signature,
45
+ // @ts-ignore
46
+ signaturePayload: signaturePayload(envelope),
47
+ })
48
+ const hash = await sha256.digest(bytes)
49
+
50
+ return CID.create(1, dagCbor.code, hash)
51
+ }
52
+
53
+ /**
54
+ * Check if a DID and signature type are compatible
55
+ *
56
+ * @param {import('iso-did/types').VerifiableDID} did
57
+ * @param {import('iso-signatures/types').SignatureType} sigType
58
+ */
59
+ export function isSigAndDidCompatible(did, sigType) {
60
+ if (did.verifiableDid.type === 'Ed25519' && sigType === 'Ed25519') {
61
+ return true
62
+ }
63
+
64
+ if (did.verifiableDid.type === 'secp256k1' && sigType === 'ES256K') {
65
+ return true
66
+ }
67
+
68
+ if (did.verifiableDid.type === 'P-256' && sigType === 'ES256') {
69
+ return true
70
+ }
71
+
72
+ if (did.verifiableDid.type === 'P-384' && sigType === 'ES384') {
73
+ return true
74
+ }
75
+
76
+ if (did.verifiableDid.type === 'P-521' && sigType === 'ES512') {
77
+ return true
78
+ }
79
+
80
+ if (did.verifiableDid.type === 'RSA' && sigType === 'RS256') {
81
+ return true
82
+ }
83
+ if (did.verifiableDid.type === 'secp256k1' && sigType === 'EIP191') {
84
+ return true
85
+ }
86
+
87
+ return false
88
+ }
89
+
90
+ /**
91
+ * Validate the expiration of a UCAN
92
+ *
93
+ * @param {number | null} exp
94
+ */
95
+ export function assertExpiration(exp) {
96
+ const now = nowInSeconds()
97
+
98
+ if (exp !== null && !Number.isSafeInteger(exp)) {
99
+ throw new TypeError(
100
+ `UCAN expiration must be null or a safe integer. Received: ${exp}`
101
+ )
102
+ }
103
+
104
+ if (exp !== null && exp < now) {
105
+ throw new TypeError(
106
+ `UCAN expiration must be in the future. Received: ${exp} but current time is ${now}`
107
+ )
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Validate the not before time of a UCAN
113
+ *
114
+ * @param {number} [nbf]
115
+ */
116
+ export function assertNotBefore(nbf) {
117
+ if (nbf && nbf > nowInSeconds()) {
118
+ throw new Error('UCAN not valid yet')
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Validate the issuer and signature of a UCAN
124
+ *
125
+ * @param {import('./types.js').DecodedEnvelope<PayloadSpec>} envelope
126
+ * @param {import('iso-did').Resolver} [didResolver]
127
+ */
128
+ export async function validateIssuerAndSignature(envelope, didResolver) {
129
+ const issuer = await DID.fromString(envelope.payload.iss, didResolver)
130
+ if (!isSigAndDidCompatible(issuer, envelope.alg)) {
131
+ throw new Error(
132
+ `UCAN issuer type mismatch: DID ${issuer.verifiableDid.type} and Signature ${envelope.alg} are not compatible`
133
+ )
134
+ }
135
+
136
+ return issuer
137
+ }
138
+
139
+ /**
140
+ * Verify the signature of a UCAN and that issuer is compatible with the signature
141
+ *
142
+ * @param {import('./types.js').DecodedEnvelope<PayloadSpec>} envelope
143
+ * @param {import('iso-signatures/verifiers/resolver.js').Resolver} signatureVerifierResolver
144
+ * @param {import('iso-did').Resolver} [didResolver]
145
+ */
146
+ export async function verifySignature(
147
+ envelope,
148
+ signatureVerifierResolver,
149
+ didResolver
150
+ ) {
151
+ const issuer = await validateIssuerAndSignature(envelope, didResolver)
152
+ const isVerified = await signatureVerifierResolver.verify({
153
+ signature: envelope.signature,
154
+ message: dagCbor.encode(signaturePayload(envelope)),
155
+ did: issuer,
156
+ type: envelope.alg,
157
+ })
158
+
159
+ if (!isVerified) {
160
+ throw new Error('UCAN signature verification failed')
161
+ }
162
+
163
+ return true
164
+ }
165
+
166
+ /**
167
+ * Asserts that a UCAN command string is syntactically valid.
168
+ * If the command is invalid, it throws a descriptive error.
169
+ *
170
+ * Rules:
171
+ * - Commands MUST begin with a slash (/).
172
+ * - Commands MUST be lowercase.
173
+ * - A trailing slash MUST NOT be present, unless the command is exactly "/".
174
+ * - Segments MUST be separated by a slash (e.g., no empty segments like "//").
175
+ *
176
+ * @param {string} command The command string to validate.
177
+ * @throws {Error} If the command is syntactically invalid.
178
+ * @returns {void} Does not return a value on success.
179
+ */
180
+ export function assertIsValidCommand(command) {
181
+ // Rule: Must be a string
182
+ if (typeof command !== 'string') {
183
+ throw new TypeError(
184
+ `Invalid command: Input must be a string, but received type ${typeof command}`
185
+ )
186
+ }
187
+
188
+ // Rule: Must begin with a slash
189
+ if (!command.startsWith('/')) {
190
+ throw new TypeError(
191
+ `Invalid command: Must begin with a slash (/). Received: "${command}"`
192
+ )
193
+ }
194
+
195
+ // Rule: No trailing slash, unless the command is exactly "/"
196
+ if (command.length > 1 && command.endsWith('/')) {
197
+ throw new TypeError(
198
+ `Invalid command: Must not have a trailing slash. Received: "${command}"`
199
+ )
200
+ }
201
+
202
+ // Rule: Must be lowercase
203
+ if (command !== command.toLowerCase()) {
204
+ throw new TypeError(
205
+ `Invalid command: Must be lowercase. Received: "${command}"`
206
+ )
207
+ }
208
+
209
+ // Rule: No empty segments
210
+ if (command.includes('//')) {
211
+ throw new TypeError(
212
+ `Invalid command: Must not contain empty segments (e.g., "//"). Received: "${command}"`
213
+ )
214
+ }
215
+
216
+ // If no error was thrown, the command is valid.
217
+ }
218
+
219
+ /**
220
+ * Assert that the input is a Uint8Array.
221
+ *
222
+ * @param {unknown} nonce The value to check.
223
+ * @throws {Error} If nonce is not a Uint8Array.
224
+ * @returns {void}
225
+ *
226
+ * @example
227
+ * ```ts twoslash
228
+ * import { assertNonce } from 'iso-ucan/utils'
229
+ * assertNonce(new Uint8Array([1,2,3])) // ok
230
+ * assertNonce('foo') // throws
231
+ * ```
232
+ */
233
+ export function assertNonce(nonce) {
234
+ if (!(nonce instanceof Uint8Array)) {
235
+ throw new TypeError(
236
+ `Invalid nonce: Expected Uint8Array, got ${Object.prototype.toString.call(nonce)}`
237
+ )
238
+ }
239
+ }
240
+ /**
241
+ * Assert that args is a JSON serializable object.
242
+ *
243
+ * @param {unknown} args
244
+ */
245
+ export function assertArgs(args) {
246
+ const parsed = cborObject.safeParse(args)
247
+ if (parsed.error) {
248
+ const pretty = z.prettifyError(parsed.error)
249
+
250
+ throw new TypeError(`Invalid args: ${pretty}`, { cause: parsed.error })
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Assert that the input is a Record<PropertyKey, unknown>.
256
+ *
257
+ * @param {unknown} meta - The value to check.
258
+ * @throws {Error} If meta is not a Record<PropertyKey, unknown>.
259
+ * @returns {void}
260
+ */
261
+ export function assertMeta(meta) {
262
+ if (meta) {
263
+ const parsed = cborObject.safeParse(meta)
264
+ if (parsed.error) {
265
+ const pretty = z.prettifyError(parsed.error)
266
+
267
+ throw new TypeError(`Invalid meta: ${pretty}`, { cause: parsed.error })
268
+ }
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Assert that the input is not revoked.
274
+ *
275
+ * @param {import('multiformats/cid').CID} cid
276
+ * @param {(cid: CID) => Promise<boolean>} [isRevokedFn]
277
+ */
278
+ export async function assertNotRevoked(cid, isRevokedFn) {
279
+ isRevokedFn = isRevokedFn ?? (async () => false)
280
+ const isRevoked = await isRevokedFn(cid)
281
+ if (isRevoked) {
282
+ throw new Error('UCAN revoked')
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Expiration or TTL (default 300 seconds)
288
+ *
289
+ * @param {object} options
290
+ * @param {number | null} [options.exp]
291
+ * @param {number} [options.ttl]
292
+ */
293
+ export function expOrTtl({ exp, ttl }) {
294
+ if (exp === null) {
295
+ return exp
296
+ }
297
+ const currentTimeInSeconds = Math.floor(Date.now() / 1000)
298
+ const expiration = exp ?? currentTimeInSeconds + (ttl ?? 300)
299
+
300
+ return expiration
301
+ }
302
+
303
+ export const cborValue =
304
+ /** @type {typeof z.lazy<z.ZodType<import('../src/types.js').CborValue>>} */ (
305
+ z.lazy
306
+ )(() => {
307
+ return z.union([
308
+ z.string(),
309
+ z.number(),
310
+ z.boolean(),
311
+ z.null(),
312
+ z.boolean(),
313
+ z.bigint(),
314
+ z.instanceof(Uint8Array),
315
+ z.array(cborValue),
316
+ z.record(z.string(), cborValue),
317
+ ])
318
+ })
319
+ export const cborObject = z.record(z.string(), cborValue)
320
+
321
+ export const selector = z.union([
322
+ z.literal('.'),
323
+ z.templateLiteral([z.literal('.'), z.string()]),
324
+ ])
325
+
326
+ /**
327
+ * @typedef {z.infer<typeof selector>} Selector
328
+ */
329
+
330
+ /**
331
+ * Statement
332
+ */
333
+ export const statement =
334
+ /** @type {typeof z.lazy<z.ZodType<import('../src/types.js').Statement<unknown>>>} */ (
335
+ z.lazy
336
+ // @ts-ignore
337
+ )(() => {
338
+ return z.union([
339
+ z.tuple([
340
+ z.union([z.literal('=='), z.literal('!=')]),
341
+ selector,
342
+ z.unknown(),
343
+ ]), // Equality
344
+ z.tuple([
345
+ z.union([
346
+ z.literal('<'),
347
+ z.literal('<='),
348
+ z.literal('>'),
349
+ z.literal('>='),
350
+ ]),
351
+ selector,
352
+ z.number(),
353
+ ]), // Inequality
354
+ z.tuple([z.literal('like'), selector, z.string()]), // Like
355
+ z.tuple([z.literal('not'), statement]),
356
+ z.tuple([z.literal('and'), z.array(statement)]),
357
+ z.tuple([z.literal('or'), z.array(statement)]),
358
+ z.tuple([z.literal('all'), z.string(), statement]),
359
+ z.tuple([z.literal('any'), z.string(), statement]),
360
+ ])
361
+ })
362
+
363
+ export const policySchema = z.array(statement)
364
+
365
+ /**
366
+ * @param {unknown} policy
367
+ */
368
+ export function assertPolicy(policy) {
369
+ const parsed = policySchema.safeParse(policy)
370
+ if (parsed.error) {
371
+ const pretty = z.prettifyError(parsed.error)
372
+
373
+ throw new TypeError(`Invalid policy: ${pretty}`, { cause: parsed.error })
374
+ }
375
+ }
package/src/varsig.js ADDED
@@ -0,0 +1,157 @@
1
+ import { IOBuffer } from 'iobuffer'
2
+ import { hex } from 'iso-base/rfc4648'
3
+ import { equals } from 'iso-base/utils'
4
+ import { varint } from 'iso-base/varint'
5
+
6
+ export const VARSIG = 0x34
7
+ export const VERSION = 0x01
8
+ export const EIP191_ENCODING = 0xe191
9
+
10
+ /**
11
+ * @type {Record<number, import('./types.js').VarsigEncoding>}
12
+ */
13
+ export const CODE_ENCODING = /** @types {const} */ {
14
+ 0x5f: 'RAW',
15
+ 0x71: 'DAG-CBOR',
16
+ }
17
+
18
+ /**
19
+ * @type {Record<number, string>}
20
+ */
21
+ export const SIG_PREFIX = /** @type {const} */ ({
22
+ 0x12_05: 'RSA',
23
+ 0xed: 'EdDSA',
24
+ 0xec: 'ECDSA',
25
+ })
26
+
27
+ /**
28
+ * Varint encoding for signature algorithms and encodings.
29
+ *
30
+ * @type {Record<'VARSIG' | 'VERSION' | import('./types.js').VarsigEncoding | import('./types.js').VarsigAlgorithm, number[] >}
31
+ */
32
+ const VARINT = {
33
+ VARSIG: [52],
34
+ VERSION: [1],
35
+
36
+ RAW: [95],
37
+ 'DAG-CBOR': [113],
38
+
39
+ /** Presets */
40
+ // rsa(2b)+SHA2-256(1b)+256(1b)
41
+ RS256: [133, 36, 18, 128, 2],
42
+ // eddsa(2b)+edwards25519(2b)+SHA2-512(1b)
43
+ Ed25519: [237, 1, 237, 1, 19],
44
+ // ECDSA(2b) + p256(2b) + SHA2-256(1b)
45
+ ES256: [236, 1, 128, 36, 18],
46
+ // ECDSA(2b) + p384(2b) + SHA2-384(1b)
47
+ ES384: [236, 1, 129, 36, 32],
48
+ // ECDSA(2b) + p512(2b) + SHA2-512(1b)
49
+ ES512: [236, 1, 130, 36, 19],
50
+ // ECDSA(2b) + secp256k1(2b) + SHA2-256(1b)
51
+ ES256K: [236, 1, 231, 1, 18],
52
+ // ECDSA(2b) + secp256k1(2b) + keccak-256(1b) + eip191-encoding(3b)
53
+ EIP191: [236, 1, 231, 1, 27, 145, 195, 3],
54
+ }
55
+
56
+ /**
57
+ * @param {import('./types.js').VarsigOptions} options
58
+ */
59
+ export function encode(options) {
60
+ const enc = VARINT[options.enc]
61
+ if (!enc) {
62
+ throw new TypeError(`Unsupported encoding ${options.enc}`)
63
+ }
64
+
65
+ const alg = VARINT[options.alg]
66
+ if (!alg) {
67
+ throw new TypeError(`Unsupported algorithm ${options.alg}`)
68
+ }
69
+
70
+ return Uint8Array.from([...VARINT.VARSIG, ...VARINT.VERSION, ...alg, ...enc])
71
+ }
72
+
73
+ /**
74
+ * @param {Uint8Array<ArrayBufferLike>} bytes
75
+ */
76
+ function getEncoding(bytes) {
77
+ const [code] = varint.decode(bytes)
78
+ const enc = CODE_ENCODING[code]
79
+ if (!enc) {
80
+ throw new TypeError(`Unsupported encoding 0x${code.toString(16)}`)
81
+ }
82
+ return enc
83
+ }
84
+
85
+ /**
86
+ * @param {Uint8Array<ArrayBufferLike>} bytes
87
+ * @param {import('./types.js').VarsigAlgorithm[]} presets
88
+ */
89
+ function getPreset(bytes, presets) {
90
+ for (const preset of presets) {
91
+ if (equals(bytes, Uint8Array.from(VARINT[preset]))) {
92
+ return preset
93
+ }
94
+ }
95
+ throw new TypeError(`Unsupported preset ${bytes}`)
96
+ }
97
+
98
+ /**
99
+ /**
100
+ * Decode varsig header
101
+ *
102
+ * @param {Uint8Array} buf
103
+ * @returns {import('./types.js').DecodeOutput}
104
+ */
105
+ export function decode(buf) {
106
+ const bytes = new IOBuffer(buf)
107
+ const varsig = bytes.readBytes(1)
108
+ if (varsig[0] !== VARINT.VARSIG[0]) {
109
+ throw new TypeError(
110
+ `Invalid varsig sigil expected 0x${VARSIG.toString(16)} got 0x${hex.encode(varsig)}`
111
+ )
112
+ }
113
+
114
+ const version = bytes.readBytes(1)
115
+ if (version[0] !== VARINT.VERSION[0]) {
116
+ throw new TypeError(
117
+ `Invalid version sigil expected 0x${VERSION.toString(16)} got 0x${hex.encode(version)}`
118
+ )
119
+ }
120
+ const [code] = varint.decode(buf, 2)
121
+
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
+ case 'EdDSA': {
130
+ return {
131
+ alg: getPreset(bytes.readBytes(5), ['Ed25519']),
132
+ enc: getEncoding(bytes.readBytes(1)),
133
+ }
134
+ }
135
+ case 'ECDSA': {
136
+ 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
+ }
153
+ }
154
+ default:
155
+ throw new TypeError(`Unsupported algorithm ${code}`)
156
+ }
157
+ }