Skip to content

Commit f560cf2

Browse files
Allow "use" claims only in public jwk (#4103)
Disallow use of `use` claim in private JWK (replaced with `key_ops`)
1 parent fefe701 commit f560cf2

File tree

21 files changed

+491
-239
lines changed

21 files changed

+491
-239
lines changed

.changeset/heavy-kangaroos-care.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@atproto/jwk": patch
3+
---
4+
5+
Silently ignore invalid JWKs from JSON Web Key Set (as per spec)

.changeset/selfish-wasps-return.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@atproto/jwk": minor
3+
---
4+
5+
Update key matching algorithm to support `key_ops`

.changeset/shiny-seals-wave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@atproto/jwk": patch
3+
---
4+
5+
Avoid using `revoked` and inactive keys from JWK sets

.changeset/tough-emus-explain.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@atproto/jwk-webcrypto": minor
3+
"@atproto/jwk": minor
4+
---
5+
6+
Only allow `"use"` claims in public jwk

.github/workflows/repo.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ jobs:
4747
name: Test
4848
needs: build
4949
strategy:
50+
fail-fast: false
5051
matrix:
5152
shard: [1/8, 2/8, 3/8, 4/8, 5/8, 6/8, 7/8, 8/8]
5253
runs-on: ubuntu-22.04

packages/oauth/jwk-jose/src/jose-key.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
SignedJwt,
2424
VerifyOptions,
2525
VerifyResult,
26+
isPrivateJwk,
2627
jwkSchema,
2728
jwtHeaderSchema,
2829
jwtPayloadSchema,
@@ -163,12 +164,12 @@ export class JoseKey<J extends Jwk = Jwk> extends Key<J> {
163164
allowedAlgos: string[] = ['ES256'],
164165
kid?: string,
165166
options?: Omit<GenerateKeyPairOptions, 'extractable'>,
166-
) {
167+
): Promise<JoseKey> {
167168
const kp = await this.generateKeyPair(allowedAlgos, {
168169
...options,
169170
extractable: true,
170171
})
171-
return this.fromImportable(kp.privateKey, kid)
172+
return this.fromKeyLike(kp.privateKey, kid)
172173
}
173174

174175
static async fromImportable(
@@ -239,8 +240,16 @@ export class JoseKey<J extends Jwk = Jwk> extends Key<J> {
239240
if (!jwk || typeof jwk !== 'object') throw new JwkError('Invalid JWK')
240241

241242
const kid = either(jwk.kid, inputKid)
242-
const use = jwk.use || 'sig'
243243

244-
return new JoseKey(jwkSchema.parse({ ...jwk, kid, use }))
244+
// Backwards compatibility with old behavior
245+
if (jwk.use != null && isPrivateJwk(jwk)) {
246+
console.warn(
247+
'Deprecation warning: Private JWK with a "use" property will be rejected in the future. Please remove replace "use" with (valid) "key_ops".',
248+
)
249+
jwk.key_ops ??= jwk.use === 'sig' ? ['sign'] : ['encrypt']
250+
delete jwk.use
251+
}
252+
253+
return new JoseKey<Jwk>(jwkSchema.parse({ ...jwk, kid }))
245254
}
246255
}

packages/oauth/jwk-webcrypto/src/webcrypto-key.ts

Lines changed: 12 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,15 @@
1-
import { z } from 'zod'
2-
import { JwkError, jwkSchema } from '@atproto/jwk'
1+
import { Jwk, JwkError, jwkSchema } from '@atproto/jwk'
32
import { GenerateKeyPairOptions, JoseKey } from '@atproto/jwk-jose'
43
import { fromSubtleAlgorithm, isCryptoKeyPair } from './util.js'
54

6-
// Webcrypto keys are bound to a single algorithm
7-
export const jwkWithAlgSchema = z.intersection(
8-
jwkSchema,
9-
z.object({ alg: z.string() }),
10-
)
11-
12-
export type JwkWithAlg = z.infer<typeof jwkWithAlgSchema>
13-
14-
export class WebcryptoKey<
15-
J extends JwkWithAlg = JwkWithAlg,
16-
> extends JoseKey<J> {
5+
export class WebcryptoKey<J extends Jwk = Jwk> extends JoseKey<J> {
176
// We need to override the static method generate from JoseKey because
187
// the browser needs both the private and public keys
198
static override async generate(
209
allowedAlgos: string[] = ['ES256'],
2110
kid: string = crypto.randomUUID(),
2211
options?: GenerateKeyPairOptions,
23-
) {
12+
): Promise<WebcryptoKey> {
2413
const keyPair = await this.generateKeyPair(allowedAlgos, options)
2514

2615
// Type safety only: in the browser, 'jose' always generates a CryptoKeyPair
@@ -31,14 +20,11 @@ export class WebcryptoKey<
3120
return this.fromKeypair(keyPair, kid)
3221
}
3322

34-
static async fromKeypair(cryptoKeyPair: CryptoKeyPair, kid?: string) {
35-
// https://datatracker.ietf.org/doc/html/rfc7517
36-
// > The "use" and "key_ops" JWK members SHOULD NOT be used together; [...]
37-
// > Applications should specify which of these members they use.
38-
23+
static async fromKeypair(
24+
cryptoKeyPair: CryptoKeyPair,
25+
kid?: string,
26+
): Promise<WebcryptoKey> {
3927
const {
40-
key_ops,
41-
use,
4228
alg = fromSubtleAlgorithm(cryptoKeyPair.privateKey.algorithm),
4329
...jwk
4430
} = await crypto.subtle.exportKey(
@@ -48,17 +34,8 @@ export class WebcryptoKey<
4834
: cryptoKeyPair.publicKey,
4935
)
5036

51-
if (use && use !== 'sig') {
52-
throw new TypeError(`Unsupported JWK use "${use}"`)
53-
}
54-
55-
if (key_ops && !key_ops.some((o) => o === 'sign' || o === 'verify')) {
56-
// Make sure that "key_ops", if present, is compatible with "use"
57-
throw new TypeError(`Invalid key_ops "${key_ops}" for "sig" use`)
58-
}
59-
60-
return new WebcryptoKey(
61-
jwkWithAlgSchema.parse({ ...jwk, kid, alg, use: 'sig' }),
37+
return new WebcryptoKey<Jwk>(
38+
jwkSchema.parse({ ...jwk, kid, alg }),
6239
cryptoKeyPair,
6340
)
6441
}
@@ -67,18 +44,16 @@ export class WebcryptoKey<
6744
jwk: Readonly<J>,
6845
readonly cryptoKeyPair: CryptoKeyPair,
6946
) {
47+
// Webcrypto keys are bound to a single algorithm
48+
if (!jwk.alg) throw new JwkError('JWK "alg" is required for Webcrypto keys')
49+
7050
super(jwk)
7151
}
7252

7353
get isPrivate() {
7454
return true
7555
}
7656

77-
get privateJwk(): Readonly<J> | undefined {
78-
if (super.isPrivate) return this.jwk
79-
throw new Error('Private Webcrypto Key not exportable')
80-
}
81-
8257
protected override async getKeyObj(alg: string) {
8358
if (this.jwk.alg !== alg) {
8459
throw new JwkError(`Key cannot be used with algorithm "${alg}"`)

packages/oauth/jwk/src/alg.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,29 @@
11
import { JwkError } from './errors.js'
2-
import { Jwk } from './jwk.js'
2+
import { JwkBase, isEncKeyUsage, isSigKeyUsage } from './jwk.js'
33

44
// Copy variable to prevent bundlers from automatically polyfilling "process" (e.g. parcel)
55
const { process } = globalThis
66
const IS_NODE_RUNTIME =
77
typeof process !== 'undefined' && typeof process?.versions?.node === 'string'
88

9-
export function* jwkAlgorithms(jwk: Jwk): Generator<string> {
9+
export function* jwkAlgorithms(jwk: JwkBase): Generator<string, void, unknown> {
1010
// Ed25519, Ed448, and secp256k1 always have "alg"
11-
// OKP always has "use"
12-
if (jwk.alg) {
11+
12+
if (typeof jwk.alg === 'string') {
1313
yield jwk.alg
1414
return
1515
}
1616

1717
switch (jwk.kty) {
1818
case 'EC': {
19-
if (jwk.use === 'enc' || jwk.use === undefined) {
19+
if (jwkSupportsEnc(jwk)) {
2020
yield 'ECDH-ES'
2121
yield 'ECDH-ES+A128KW'
2222
yield 'ECDH-ES+A192KW'
2323
yield 'ECDH-ES+A256KW'
2424
}
2525

26-
if (jwk.use === 'sig' || jwk.use === undefined) {
26+
if (jwkSupportsSig(jwk)) {
2727
const crv = 'crv' in jwk ? jwk.crv : undefined
2828
switch (crv) {
2929
case 'P-256':
@@ -54,15 +54,15 @@ export function* jwkAlgorithms(jwk: Jwk): Generator<string> {
5454
}
5555

5656
case 'RSA': {
57-
if (jwk.use === 'enc' || jwk.use === undefined) {
57+
if (jwkSupportsEnc(jwk)) {
5858
yield 'RSA-OAEP'
5959
yield 'RSA-OAEP-256'
6060
yield 'RSA-OAEP-384'
6161
yield 'RSA-OAEP-512'
6262
if (IS_NODE_RUNTIME) yield 'RSA1_5'
6363
}
6464

65-
if (jwk.use === 'sig' || jwk.use === undefined) {
65+
if (jwkSupportsSig(jwk)) {
6666
yield 'PS256'
6767
yield 'PS384'
6868
yield 'PS512'
@@ -75,7 +75,7 @@ export function* jwkAlgorithms(jwk: Jwk): Generator<string> {
7575
}
7676

7777
case 'oct': {
78-
if (jwk.use === 'enc' || jwk.use === undefined) {
78+
if (jwkSupportsEnc(jwk)) {
7979
yield 'A128GCMKW'
8080
yield 'A192GCMKW'
8181
yield 'A256GCMKW'
@@ -84,7 +84,7 @@ export function* jwkAlgorithms(jwk: Jwk): Generator<string> {
8484
yield 'A256KW'
8585
}
8686

87-
if (jwk.use === 'sig' || jwk.use === undefined) {
87+
if (jwkSupportsSig(jwk)) {
8888
yield 'HS256'
8989
yield 'HS384'
9090
yield 'HS512'
@@ -97,3 +97,15 @@ export function* jwkAlgorithms(jwk: Jwk): Generator<string> {
9797
throw new JwkError(`Unsupported kty "${jwk.kty}"`)
9898
}
9999
}
100+
101+
function jwkSupportsEnc(jwk: JwkBase): boolean {
102+
return (
103+
jwk.key_ops?.some(isEncKeyUsage) ?? (jwk.use == null || jwk.use === 'enc')
104+
)
105+
}
106+
107+
function jwkSupportsSig(jwk: JwkBase): boolean {
108+
return (
109+
jwk.key_ops?.some(isSigKeyUsage) ?? (jwk.use == null || jwk.use === 'sig')
110+
)
111+
}

0 commit comments

Comments
 (0)