@atproto/jwk 0.4.0 → 0.6.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/key.ts CHANGED
@@ -1,57 +1,92 @@
1
1
  import { jwkAlgorithms } from './alg.js'
2
- import { JwkError } from './errors.js'
3
- import { Jwk, jwkSchema } from './jwk.js'
2
+ import {
3
+ Jwk,
4
+ KeyUsage,
5
+ PUBLIC_KEY_USAGE,
6
+ PrivateJwk,
7
+ PublicJwk,
8
+ PublicKeyUsage,
9
+ hasSharedSecretJwk,
10
+ isEncKeyUsage,
11
+ isPrivateJwk,
12
+ isPublicKeyUsage,
13
+ isSigKeyUsage,
14
+ jwkPubSchema,
15
+ jwkSchema,
16
+ } from './jwk.js'
4
17
  import { VerifyOptions, VerifyResult } from './jwt-verify.js'
5
18
  import { JwtHeader, JwtPayload, SignedJwt } from './jwt.js'
6
19
  import { cachedGetter } from './util.js'
7
20
 
8
- const jwkSchemaReadonly = jwkSchema.readonly()
21
+ export type KeyMatchOptions = {
22
+ usage?: KeyUsage
23
+ kid?: string | string[]
24
+ alg?: string | string[]
25
+ }
26
+
27
+ export type ActivityCheckOptions = {
28
+ allowRevoked?: boolean
29
+ clockTolerance?: number
30
+ currentDate?: Date
31
+ }
9
32
 
10
33
  export abstract class Key<J extends Jwk = Jwk> {
11
- constructor(protected readonly jwk: Readonly<J>) {
12
- // A key should always be used either for signing or encryption.
13
- if (!jwk.use) throw new JwkError('Missing "use" Parameter value')
14
- }
34
+ constructor(readonly jwk: Readonly<J>) {}
15
35
 
36
+ @cachedGetter
16
37
  get isPrivate(): boolean {
17
- const { jwk } = this
18
- if ('d' in jwk && jwk.d !== undefined) return true
19
- if ('k' in jwk && jwk.k !== undefined) return true
20
- return false
38
+ return isPrivateJwk(this.jwk)
21
39
  }
22
40
 
41
+ @cachedGetter
23
42
  get isSymetric(): boolean {
24
- const { jwk } = this
25
- if ('k' in jwk && jwk.k !== undefined) return true
26
- return false
43
+ return hasSharedSecretJwk(this.jwk)
27
44
  }
28
45
 
29
- get privateJwk(): Readonly<J> | undefined {
30
- return this.isPrivate ? this.jwk : undefined
46
+ get privateJwk(): Readonly<PrivateJwk> | undefined {
47
+ if (!this.isPrivate) return undefined
48
+
49
+ return this.jwk as Readonly<PrivateJwk>
31
50
  }
32
51
 
33
52
  @cachedGetter
34
- get publicJwk():
35
- | Readonly<Exclude<J, { kty: 'oct' }> & { d?: never }>
36
- | undefined {
53
+ get publicJwk(): Readonly<PublicJwk> | undefined {
37
54
  if (this.isSymetric) return undefined
55
+ if (!this.isPrivate) return this.jwk as Readonly<PublicJwk>
38
56
 
39
- return jwkSchemaReadonly.parse({
57
+ const validated = jwkPubSchema.safeParse({
40
58
  ...this.jwk,
41
59
  d: undefined,
42
60
  k: undefined,
43
- }) as Exclude<J, { kty: 'oct' }> & { d?: never }
61
+ use: undefined,
62
+ key_ops: buildPublicKeyOps(this.keyOps) ?? PUBLIC_KEY_USAGE,
63
+ })
64
+
65
+ // One reason why the parsing might fail is if key_ops is empty. This check
66
+ // also allows to future proof the code (e.g if another type of private key
67
+ // is added that uses a different property than "d" or "k" to store its
68
+ // private value).
69
+ if (!validated.success) return undefined
70
+
71
+ return Object.freeze(validated.data)
44
72
  }
45
73
 
46
74
  @cachedGetter
47
75
  get bareJwk(): Readonly<Jwk> | undefined {
48
76
  if (this.isSymetric) return undefined
49
77
  const { kty, crv, e, n, x, y } = this.jwk as any
50
- return jwkSchemaReadonly.parse({ crv, e, kty, n, x, y })
78
+ return Object.freeze(jwkSchema.parse({ crv, e, kty, n, x, y }))
79
+ }
80
+
81
+ /**
82
+ * @note Only defined on public keys
83
+ */
84
+ get use(): 'sig' | 'enc' | undefined {
85
+ return this.jwk.use
51
86
  }
52
87
 
53
- get use() {
54
- return this.jwk.use!
88
+ get keyOps(): readonly KeyUsage[] | undefined {
89
+ return this.jwk.key_ops
55
90
  }
56
91
 
57
92
  /**
@@ -81,6 +116,67 @@ export abstract class Key<J extends Jwk = Jwk> {
81
116
  return Object.freeze(Array.from(jwkAlgorithms(this.jwk)))
82
117
  }
83
118
 
119
+ get isRevoked() {
120
+ return this.jwk.revoked != null
121
+ }
122
+
123
+ isActive(options?: ActivityCheckOptions) {
124
+ if (!options?.allowRevoked && this.isRevoked) return false
125
+
126
+ const tolerance = options?.clockTolerance ?? 0
127
+ if (tolerance !== Infinity) {
128
+ const now = options?.currentDate?.getTime() ?? Date.now()
129
+ const { exp, nbf } = this.jwk
130
+
131
+ if (nbf != null && !(now >= nbf * 1e3 - tolerance)) return false
132
+ if (exp != null && !(now < exp * 1e3 + tolerance)) return false
133
+ }
134
+
135
+ return true
136
+ }
137
+
138
+ matches(opts: KeyMatchOptions): boolean {
139
+ if (opts.kid != null) {
140
+ const matchesKid = Array.isArray(opts.kid)
141
+ ? this.kid != null && opts.kid.includes(this.kid)
142
+ : this.kid === opts.kid
143
+ if (!matchesKid) return false
144
+ }
145
+
146
+ if (opts.alg != null) {
147
+ const matchesAlg = Array.isArray(opts.alg)
148
+ ? opts.alg.some((a) => this.algorithms.includes(a))
149
+ : this.algorithms.includes(opts.alg)
150
+ if (!matchesAlg) return false
151
+ }
152
+
153
+ if (opts.usage != null) {
154
+ const matchesOps =
155
+ this.keyOps == null ||
156
+ this.keyOps.includes(opts.usage) ||
157
+ // @NOTE Because this.jwk represents the private key (typically used for
158
+ // private operations), the public counterpart operations are allowed.
159
+ (opts.usage === 'verify' && this.keyOps.includes('sign')) ||
160
+ (opts.usage === 'encrypt' && this.keyOps.includes('decrypt')) ||
161
+ (opts.usage === 'wrapKey' && this.keyOps.includes('unwrapKey'))
162
+ if (!matchesOps) return false
163
+
164
+ const matchesUse =
165
+ this.use == null ||
166
+ (this.use === 'sig' && isSigKeyUsage(opts.usage)) ||
167
+ (this.use === 'enc' && isEncKeyUsage(opts.usage))
168
+ if (!matchesUse) return false
169
+
170
+ // @NOTE This is only relevant when "key_ops" and "use" are undefined.
171
+ // This line also ensures that when "opts.usage" is a private key usage
172
+ // (e.g. "sign"), the key is indeed a private key.
173
+ const matchesKeyType = this.isPrivate || isPublicKeyUsage(opts.usage)
174
+ if (!matchesKeyType) return false
175
+ }
176
+
177
+ return true
178
+ }
179
+
84
180
  /**
85
181
  * Create a signed JWT
86
182
  */
@@ -96,3 +192,20 @@ export abstract class Key<J extends Jwk = Jwk> {
96
192
  options?: VerifyOptions<C>,
97
193
  ): Promise<VerifyResult<C>>
98
194
  }
195
+
196
+ function buildPublicKeyOps(
197
+ keyUsages?: readonly KeyUsage[],
198
+ ): PublicKeyUsage[] | undefined {
199
+ if (keyUsages == null) return undefined
200
+
201
+ // https://datatracker.ietf.org/doc/html/rfc7517#section-4.3
202
+ // > Duplicate key operation values MUST NOT be present in the array.
203
+ const publicOps = new Set(keyUsages.filter(isPublicKeyUsage))
204
+
205
+ // @NOTE Translating private key usage into public key usage
206
+ if (keyUsages.includes('sign')) publicOps.add('verify')
207
+ if (keyUsages.includes('decrypt')) publicOps.add('encrypt')
208
+ if (keyUsages.includes('unwrapKey')) publicOps.add('wrapKey')
209
+
210
+ return Array.from(publicOps)
211
+ }
package/src/keyset.ts CHANGED
@@ -6,37 +6,35 @@ import {
6
6
  JwtCreateError,
7
7
  JwtVerifyError,
8
8
  } from './errors.js'
9
- import { Jwk } from './jwk.js'
10
- import { Jwks, JwksPub } from './jwks.js'
9
+ import { PrivateKeyUsage } from './jwk.js'
10
+ import { JwksPub } from './jwks.js'
11
11
  import { unsafeDecodeJwt } from './jwt-decode.js'
12
12
  import { VerifyOptions, VerifyResult } from './jwt-verify.js'
13
13
  import { JwtHeader, JwtPayload, SignedJwt } from './jwt.js'
14
- import { Key } from './key.js'
14
+ import { ActivityCheckOptions, Key, KeyMatchOptions } from './key.js'
15
15
  import {
16
- DeepReadonly,
17
16
  Override,
18
- UnReadonly,
19
17
  cachedGetter,
20
18
  isDefined,
21
19
  matchesAny,
22
20
  preferredOrderCmp,
23
21
  } from './util.js'
24
22
 
25
- export type JwtSignHeader = Override<JwtHeader, Pick<KeySearch, 'alg' | 'kid'>>
23
+ export type { ActivityCheckOptions, KeyMatchOptions }
24
+ export type FindKeyOptions = KeyMatchOptions & ActivityCheckOptions
25
+
26
+ export type JwtSignHeader = Override<
27
+ JwtHeader,
28
+ Pick<FindKeyOptions, 'alg' | 'kid'>
29
+ >
26
30
 
27
31
  export type JwtPayloadGetter<P = JwtPayload> = (
28
32
  header: JwtHeader,
29
33
  key: Key,
30
34
  ) => P | PromiseLike<P>
31
35
 
32
- export type KeySearch = {
33
- use?: 'sig' | 'enc'
34
- kid?: string | string[]
35
- alg?: string | string[]
36
- }
37
-
38
- const extractPrivateJwk = (key: Key): Jwk | undefined => key.privateJwk
39
- const extractPublicJwk = (key: Key): Jwk | undefined => key.publicJwk
36
+ const extractPrivateJwk = (key: Key) => key.privateJwk
37
+ const extractPublicJwk = (key: Key) => key.publicJwk
40
38
 
41
39
  export class Keyset<K extends Key = Key> implements Iterable<K> {
42
40
  private readonly keys: readonly K[]
@@ -67,15 +65,15 @@ export class Keyset<K extends Key = Key> implements Iterable<K> {
67
65
  ) {
68
66
  const keys: K[] = []
69
67
 
70
- const kids = new Set<string>()
68
+ const keyIds = new Set<string>()
71
69
  for (const key of iterable) {
72
70
  if (!key) continue
73
71
 
74
72
  keys.push(key)
75
73
 
76
74
  if (key.kid) {
77
- if (kids.has(key.kid)) throw new JwkError(`Duplicate key: ${key.kid}`)
78
- else kids.add(key.kid)
75
+ if (keyIds.has(key.kid)) throw new JwkError(`Duplicate key: ${key.kid}`)
76
+ else keyIds.add(key.kid)
79
77
  }
80
78
  }
81
79
 
@@ -101,66 +99,67 @@ export class Keyset<K extends Key = Key> implements Iterable<K> {
101
99
  }
102
100
 
103
101
  @cachedGetter
104
- get publicJwks(): DeepReadonly<JwksPub> {
105
- return {
106
- keys: Array.from(this, extractPublicJwk).filter(isDefined),
107
- }
102
+ get publicJwks() {
103
+ return Object.freeze({
104
+ keys: Object.freeze(Array.from(this, extractPublicJwk).filter(isDefined)),
105
+ })
108
106
  }
109
107
 
110
108
  @cachedGetter
111
- get privateJwks(): DeepReadonly<Jwks> {
112
- return {
113
- keys: Array.from(this, extractPrivateJwk).filter(isDefined),
114
- }
109
+ get privateJwks() {
110
+ return Object.freeze({
111
+ keys: Object.freeze(
112
+ Array.from(this, extractPrivateJwk).filter(isDefined),
113
+ ),
114
+ })
115
115
  }
116
116
 
117
117
  has(kid: string): boolean {
118
118
  return this.keys.some((key) => key.kid === kid)
119
119
  }
120
120
 
121
- get(search: KeySearch): K {
122
- for (const key of this.list(search)) {
123
- return key
124
- }
121
+ get(options: FindKeyOptions): K {
122
+ const key = this.find(options)
123
+ if (key) return key
125
124
 
126
125
  throw new JwkError(
127
- `Key not found ${search.kid || search.alg || '<unknown>'}`,
126
+ `Key not found ${options.kid ?? options.alg ?? options.usage ?? '<unknown>'}`,
128
127
  ERR_JWK_NOT_FOUND,
129
128
  )
130
129
  }
131
130
 
132
- *list(search: KeySearch): Generator<K> {
133
- // Optimization: Empty string or empty array will not match any key
134
- if (search.kid?.length === 0) return
135
- if (search.alg?.length === 0) return
136
-
137
- for (const key of this) {
138
- if (search.use && key.use !== search.use) continue
131
+ find(options: FindKeyOptions): K | undefined {
132
+ for (const key of this.list(options)) {
133
+ return key
134
+ }
139
135
 
140
- if (Array.isArray(search.kid)) {
141
- if (!key.kid || !search.kid.includes(key.kid)) continue
142
- } else if (search.kid) {
143
- if (key.kid !== search.kid) continue
144
- }
136
+ return undefined
137
+ }
145
138
 
146
- if (Array.isArray(search.alg)) {
147
- if (!search.alg.some((a) => key.algorithms.includes(a))) continue
148
- } else if (typeof search.alg === 'string') {
149
- if (!key.algorithms.includes(search.alg)) continue
139
+ *list<O extends FindKeyOptions>(options: O) {
140
+ for (const key of this) {
141
+ if (key.isActive(options) && key.matches(options)) {
142
+ yield key
150
143
  }
151
-
152
- yield key
153
144
  }
154
145
  }
155
146
 
156
- findPrivateKey({ kid, alg, use }: KeySearch): { key: Key; alg: string } {
147
+ findPrivateKey({
148
+ kid,
149
+ alg,
150
+ usage,
151
+ ...options
152
+ }: FindKeyOptions & { usage: PrivateKeyUsage }): {
153
+ key: Key
154
+ alg: string
155
+ } {
157
156
  const matchingKeys: Key[] = []
158
157
 
159
- for (const key of this.list({ kid, alg, use })) {
160
- // Not a private key
161
- if (!key.isPrivate) continue
158
+ // Allow the loop bellow to return early when a single "alg" is provided
159
+ if (Array.isArray(alg) && alg.length === 1) alg = alg[0]
162
160
 
163
- // Skip negotiation if a specific "alg" was provided
161
+ for (const key of this.list({ ...options, kid, alg, usage })) {
162
+ // Skip negotiation if a single "alg" was provided
164
163
  if (typeof alg === 'string') return { key, alg }
165
164
 
166
165
  matchingKeys.push(key)
@@ -188,7 +187,7 @@ export class Keyset<K extends Key = Key> implements Iterable<K> {
188
187
  }
189
188
 
190
189
  throw new JwkError(
191
- `No private key found for ${kid || alg || use || '<unknown>'}`,
190
+ `No private key found for ${kid || alg || usage}`,
192
191
  ERR_JWK_NOT_FOUND,
193
192
  )
194
193
  }
@@ -205,7 +204,8 @@ export class Keyset<K extends Key = Key> implements Iterable<K> {
205
204
  const { key, alg } = this.findPrivateKey({
206
205
  alg: sAlg,
207
206
  kid: sKid,
208
- use: 'sig',
207
+ usage: 'sign',
208
+ allowRevoked: false, // For explicitness (default value is false)
209
209
  })
210
210
  const protectedHeader = { ...header, alg, kid: key.kid }
211
211
 
@@ -221,14 +221,14 @@ export class Keyset<K extends Key = Key> implements Iterable<K> {
221
221
 
222
222
  async verifyJwt<C extends string = never>(
223
223
  token: SignedJwt,
224
- options?: VerifyOptions<C>,
224
+ options?: ActivityCheckOptions & VerifyOptions<C>,
225
225
  ): Promise<VerifyResult<C> & { key: K }> {
226
226
  const { header } = unsafeDecodeJwt(token)
227
227
  const { kid, alg } = header
228
228
 
229
229
  const errors: unknown[] = []
230
230
 
231
- for (const key of this.list({ kid, alg })) {
231
+ for (const key of this.list({ ...options, kid, alg, usage: 'verify' })) {
232
232
  try {
233
233
  const result = await key.verifyJwt<C>(token, options)
234
234
  return { ...result, key }
@@ -247,8 +247,8 @@ export class Keyset<K extends Key = Key> implements Iterable<K> {
247
247
  }
248
248
  }
249
249
 
250
- toJSON(): JwksPub {
251
- // Make a copy to prevent mutation of the original keyset
252
- return structuredClone(this.publicJwks) as UnReadonly<JwksPub>
250
+ toJSON() {
251
+ // Make a copy to allow mutation of the result
252
+ return structuredClone(this.publicJwks) as JwksPub
253
253
  }
254
254
  }
package/src/util.ts CHANGED
@@ -13,24 +13,6 @@ export type RequiredKey<T, K extends keyof T = never> = Simplify<
13
13
  }
14
14
  >
15
15
 
16
- // eslint-disable-next-line @typescript-eslint/ban-types
17
- export type DeepReadonly<T> = T extends Function
18
- ? T
19
- : T extends object
20
- ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
21
- : T extends readonly (infer U)[]
22
- ? readonly DeepReadonly<U>[]
23
- : T
24
-
25
- // eslint-disable-next-line @typescript-eslint/ban-types
26
- export type UnReadonly<T> = T extends Function
27
- ? T
28
- : T extends object
29
- ? { -readonly [K in keyof T]: UnReadonly<T[K]> }
30
- : T extends readonly (infer U)[]
31
- ? UnReadonly<U>[]
32
- : T
33
-
34
16
  export const isDefined = <T>(i: T | undefined): i is T => i !== undefined
35
17
 
36
18
  export const preferredOrderCmp =
@@ -197,3 +179,9 @@ export const segmentedStringRefinementFactory = <C extends number>(
197
179
  return true
198
180
  }
199
181
  }
182
+
183
+ export function isLastOccurrence<
184
+ T extends number | boolean | string | null | undefined | symbol | bigint,
185
+ >(v: T, i: number, arr: readonly T[]): boolean {
186
+ return arr.indexOf(v, i + 1) === -1
187
+ }