@andrewheberle/serverless-ssh-ca 0.23.0 → 0.24.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.
Files changed (98) hide show
  1. package/README.md +123 -0
  2. package/dist/api/operations.d.ts +2 -1
  3. package/dist/api/operations.d.ts.map +1 -1
  4. package/dist/api/operations.js +107 -18
  5. package/dist/api/operations.js.map +1 -1
  6. package/dist/api/schema.d.ts +1 -0
  7. package/dist/api/schema.d.ts.map +1 -1
  8. package/dist/api/schema.js +8 -1
  9. package/dist/api/schema.js.map +1 -1
  10. package/dist/api/v3/index.d.ts.map +1 -1
  11. package/dist/api/v3/index.js +1 -1
  12. package/dist/api/v3/index.js.map +1 -1
  13. package/dist/api/v3/schema.d.ts.map +1 -1
  14. package/dist/api/v3/schema.js +11 -7
  15. package/dist/api/v3/schema.js.map +1 -1
  16. package/dist/api/v4/index.d.ts.map +1 -1
  17. package/dist/api/v4/index.js +1 -1
  18. package/dist/api/v4/index.js.map +1 -1
  19. package/dist/api/v4/schema.d.ts.map +1 -1
  20. package/dist/api/v4/schema.js +11 -7
  21. package/dist/api/v4/schema.js.map +1 -1
  22. package/dist/db/index.d.ts +51 -4
  23. package/dist/db/index.d.ts.map +1 -1
  24. package/dist/db/index.js +133 -33
  25. package/dist/db/index.js.map +1 -1
  26. package/dist/db/migrations/0003_krl_revision.d.ts +3 -0
  27. package/dist/db/migrations/0003_krl_revision.d.ts.map +1 -0
  28. package/dist/db/migrations/0003_krl_revision.js +14 -0
  29. package/dist/db/migrations/0003_krl_revision.js.map +1 -0
  30. package/dist/db/migrations/index.d.ts.map +1 -1
  31. package/dist/db/migrations/index.js +2 -0
  32. package/dist/db/migrations/index.js.map +1 -1
  33. package/dist/db/types.d.ts +2 -0
  34. package/dist/db/types.d.ts.map +1 -1
  35. package/dist/index.d.ts +5 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +4 -2
  38. package/dist/index.js.map +1 -1
  39. package/dist/krl.d.ts +2 -1
  40. package/dist/krl.d.ts.map +1 -1
  41. package/dist/krl.js +3 -2
  42. package/dist/krl.js.map +1 -1
  43. package/dist/logger.d.ts.map +1 -1
  44. package/dist/logger.js +22 -1
  45. package/dist/logger.js.map +1 -1
  46. package/dist/node/cache.d.ts +21 -0
  47. package/dist/node/cache.d.ts.map +1 -0
  48. package/dist/node/cache.js +49 -0
  49. package/dist/node/cache.js.map +1 -0
  50. package/dist/node/env.d.ts +6 -3
  51. package/dist/node/env.d.ts.map +1 -1
  52. package/dist/node/env.js +4 -3
  53. package/dist/node/env.js.map +1 -1
  54. package/dist/node/index.d.ts +1 -0
  55. package/dist/node/index.d.ts.map +1 -1
  56. package/dist/node/index.js +1 -0
  57. package/dist/node/index.js.map +1 -1
  58. package/dist/router.d.ts +25 -1
  59. package/dist/router.d.ts.map +1 -1
  60. package/dist/router.js +38 -1
  61. package/dist/router.js.map +1 -1
  62. package/dist/ssh/keys.d.ts +18 -5
  63. package/dist/ssh/keys.d.ts.map +1 -1
  64. package/dist/ssh/keys.js +126 -11
  65. package/dist/ssh/keys.js.map +1 -1
  66. package/dist/types.d.ts +53 -3
  67. package/dist/types.d.ts.map +1 -1
  68. package/dist/utils.d.ts +68 -1
  69. package/dist/utils.d.ts.map +1 -1
  70. package/dist/utils.js +166 -13
  71. package/dist/utils.js.map +1 -1
  72. package/dist/verify.d.ts +17 -5
  73. package/dist/verify.d.ts.map +1 -1
  74. package/dist/verify.js +47 -9
  75. package/dist/verify.js.map +1 -1
  76. package/openapi.json +402 -2
  77. package/package.json +1 -1
  78. package/src/api/operations.ts +119 -16
  79. package/src/api/schema.ts +9 -0
  80. package/src/api/v3/index.ts +1 -0
  81. package/src/api/v3/schema.ts +11 -4
  82. package/src/api/v4/index.ts +1 -0
  83. package/src/api/v4/schema.ts +11 -5
  84. package/src/db/index.ts +162 -37
  85. package/src/db/migrations/0003_krl_revision.ts +15 -0
  86. package/src/db/migrations/index.ts +2 -0
  87. package/src/db/types.ts +2 -0
  88. package/src/index.ts +6 -4
  89. package/src/krl.ts +3 -2
  90. package/src/logger.ts +25 -1
  91. package/src/node/cache.ts +62 -0
  92. package/src/node/env.ts +6 -5
  93. package/src/node/index.ts +1 -0
  94. package/src/router.ts +64 -2
  95. package/src/ssh/keys.ts +145 -12
  96. package/src/types.ts +49 -3
  97. package/src/utils.ts +188 -14
  98. package/src/verify.ts +54 -14
package/src/ssh/keys.ts CHANGED
@@ -118,6 +118,39 @@ export class PublicKey {
118
118
  }
119
119
  }
120
120
 
121
+ /**
122
+ * The smallest RSA key, in bits, that the CA will issue a certificate for.
123
+ * This matches the minimum accepted by ssh-ca-client.
124
+ */
125
+ export const MinimumRsaKeyBits = 2048
126
+
127
+ /**
128
+ * Returns the size in bits of the modulus of an RSA public key, or undefined
129
+ * if the key is not an RSA key.
130
+ *
131
+ * @param key The public key
132
+ */
133
+ export const rsaKeyBits = (key: PublicKey): number | undefined => {
134
+ if (key.type !== "ssh-rsa") {
135
+ return undefined
136
+ }
137
+
138
+ // the fields of an RSA key are the exponent and the modulus, an mpint
139
+ // that may have a leading zero byte to keep it positive
140
+ const modulus = decodePublicKey(key.blob).fields[1]
141
+ if (modulus === undefined) {
142
+ return undefined
143
+ }
144
+
145
+ const start = modulus.findIndex((byte) => byte !== 0)
146
+ const first = modulus[start]
147
+ if (start === -1 || first === undefined) {
148
+ return 0
149
+ }
150
+
151
+ return (modulus.length - start - 1) * 8 + first.toString(2).length
152
+ }
153
+
121
154
  /** The ECDSA curves that can be used to sign */
122
155
  export type EcdsaCurve = "nistp256" | "nistp384" | "nistp521"
123
156
 
@@ -190,24 +223,97 @@ const keyTypeName = (type: string): string => {
190
223
  return type.replace(/^ssh-/, "").replace(/^ecdsa-sha2-.*$/, "ecdsa")
191
224
  }
192
225
 
226
+ // decodes the base64 body of a PEM block
227
+ const decodePem = (body: string): Uint8Array<ArrayBuffer> => {
228
+ try {
229
+ return new Uint8Array(base64.decode(body.replace(/\s+/g, "")))
230
+ } catch (err) {
231
+ throw new KeyParseError("private key could not be decoded", err)
232
+ }
233
+ }
234
+
235
+ /** The algorithms that an unencrypted PKCS#8 private key is imported as, in the order they are tried */
236
+ const Pkcs8Algorithms: readonly ({ type: "ed25519", algorithm: Algorithm } | { type: "ecdsa", curve: EcdsaCurve, algorithm: EcKeyImportParams })[] = [
237
+ { type: "ed25519", algorithm: { name: "Ed25519" } },
238
+ ...(["nistp256", "nistp384", "nistp521"] as const).map((curve) => ({ type: "ecdsa" as const, curve, algorithm: { name: "ECDSA", namedCurve: EcdsaCurves[curve].namedCurve } })),
239
+ ]
240
+
241
+ // imports a PKCS#8 private key, returning undefined if it is not a key for
242
+ // the algorithm
243
+ const importPkcs8 = async (der: Uint8Array<ArrayBuffer>, algorithm: Algorithm | EcKeyImportParams | RsaHashedImportParams): Promise<CryptoKey | undefined> => {
244
+ try {
245
+ return await crypto.subtle.importKey("pkcs8", der, algorithm, true, ["sign"])
246
+ } catch {
247
+ return undefined
248
+ }
249
+ }
250
+
251
+ // returns a member of a JWK exported by WebCrypto, which is always present
252
+ // for the key types that are exported
253
+ const jwkMember = (jwk: JsonWebKey, name: "d" | "x" | "y"): string => {
254
+ const value = jwk[name]
255
+ if (value === undefined) {
256
+ throw new KeyParseError(`private key was missing "${name}" when exported`)
257
+ }
258
+
259
+ return value
260
+ }
261
+
193
262
  /**
194
- * Parses an unencrypted Ed25519 or ECDSA private key in OpenSSH format, as
195
- * created by `ssh-keygen`
196
- *
197
- * @param text The private key, starting with `-----BEGIN OPENSSH PRIVATE KEY-----`
198
- * @returns The private key
199
- * @throws {@link KeyParseError} when the key cannot be parsed or is encrypted
200
- * @throws {@link UnsupportedKeyError} when the key is not an Ed25519 or ECDSA key
263
+ * Parses an unencrypted Ed25519 or ECDSA private key in PKCS#8 format, as
264
+ * created by `ssh-keygen -m PKCS8` or `openssl genpkey`
201
265
  */
202
- export const parsePrivateKey = (text: string): PrivateKey => {
203
- const match = /-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]*?)-----END OPENSSH PRIVATE KEY-----/.exec(text)
204
- if (match === null) {
205
- throw new KeyParseError("private key was not in OpenSSH format, as created by ssh-keygen (starting with -----BEGIN OPENSSH PRIVATE KEY-----)")
266
+ const parsePkcs8PrivateKey = async (body: string): Promise<PrivateKey> => {
267
+ const der = decodePem(body)
268
+
269
+ for (const candidate of Pkcs8Algorithms) {
270
+ const cryptoKey = await importPkcs8(der, candidate.algorithm)
271
+ if (cryptoKey === undefined) {
272
+ continue
273
+ }
274
+
275
+ const jwk = await crypto.subtle.exportKey("jwk", cryptoKey)
276
+ const d = jwkMember(jwk, "d")
277
+ const x = jwkMember(jwk, "x")
278
+
279
+ if (candidate.type === "ed25519") {
280
+ return {
281
+ type: "ed25519",
282
+ publicKey: new PublicKey(P.struct({ type: SSHText, key: SSHBytes }).encode({ type: "ssh-ed25519", key: base64urlnopad.decode(x) })),
283
+ jwk: { kty: "OKP", crv: "Ed25519", d, x },
284
+ }
285
+ }
286
+
287
+ const y = jwkMember(jwk, "y")
288
+ const point = new Uint8Array([0x04, ...base64urlnopad.decode(x), ...base64urlnopad.decode(y)])
289
+
290
+ return {
291
+ type: "ecdsa",
292
+ curve: candidate.curve,
293
+ publicKey: new PublicKey(P.struct({ type: SSHText, curve: SSHText, key: SSHBytes }).encode({ type: `ecdsa-sha2-${candidate.curve}`, curve: candidate.curve, key: point })),
294
+ jwk: { kty: "EC", crv: EcdsaCurves[candidate.curve].namedCurve, d, x, y },
295
+ }
296
+ }
297
+
298
+ // RSA keys are recognised only to give a clearer error
299
+ if (await importPkcs8(der, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }) !== undefined) {
300
+ throw new UnsupportedKeyError("CA key type rsa is not supported, the CA key must be Ed25519 or ECDSA")
206
301
  }
207
302
 
303
+ // WebCrypto does not say why a key could not be imported, so a key of
304
+ // another type cannot be told apart from one that is corrupt
305
+ throw new KeyParseError("PKCS#8 private key could not be imported as an Ed25519 or ECDSA (nistp256, nistp384 or nistp521) key")
306
+ }
307
+
308
+ /**
309
+ * Parses an unencrypted Ed25519 or ECDSA private key in OpenSSH format, as
310
+ * created by `ssh-keygen`
311
+ */
312
+ const parseOpenSshPrivateKey = (body: string): PrivateKey => {
313
+ const bytes = decodePem(body)
208
314
  let container: P.UnwrapCoder<typeof OpenSshPrivateKey>
209
315
  try {
210
- container = OpenSshPrivateKey.decode(base64.decode(match[1]!.replace(/\s+/g, "")))
316
+ container = OpenSshPrivateKey.decode(bytes)
211
317
  } catch (err) {
212
318
  throw new KeyParseError("private key could not be decoded", err)
213
319
  }
@@ -292,3 +398,30 @@ export const parsePrivateKey = (text: string): PrivateKey => {
292
398
  throw new UnsupportedKeyError(`CA key type ${keyTypeName(start.type)} is not supported, the CA key must be Ed25519 or ECDSA`)
293
399
  }
294
400
 
401
+ /**
402
+ * Parses an unencrypted Ed25519 or ECDSA private key, either in OpenSSH
403
+ * format, as created by `ssh-keygen`, or in PKCS#8 format, as created by
404
+ * `ssh-keygen -m PKCS8` or `openssl genpkey`
405
+ *
406
+ * @param text The private key, starting with `-----BEGIN OPENSSH PRIVATE KEY-----` or `-----BEGIN PRIVATE KEY-----`
407
+ * @returns The private key. A PKCS#8 key has no comment.
408
+ * @throws {@link KeyParseError} when the key cannot be parsed or is encrypted
409
+ * @throws {@link UnsupportedKeyError} when the key is not an Ed25519 or ECDSA key
410
+ */
411
+ export const parsePrivateKey = async (text: string): Promise<PrivateKey> => {
412
+ const openssh = /-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]*?)-----END OPENSSH PRIVATE KEY-----/.exec(text)
413
+ if (openssh?.[1] !== undefined) {
414
+ return parseOpenSshPrivateKey(openssh[1])
415
+ }
416
+
417
+ const pkcs8 = /-----BEGIN PRIVATE KEY-----([\s\S]*?)-----END PRIVATE KEY-----/.exec(text)
418
+ if (pkcs8?.[1] !== undefined) {
419
+ return parsePkcs8PrivateKey(pkcs8[1])
420
+ }
421
+
422
+ if (text.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
423
+ throw new KeyParseError("encrypted private keys are not supported")
424
+ }
425
+
426
+ throw new KeyParseError("private key was not in OpenSSH format, as created by ssh-keygen (starting with -----BEGIN OPENSSH PRIVATE KEY-----), or in PKCS#8 format (starting with -----BEGIN PRIVATE KEY-----)")
427
+ }
package/src/types.ts CHANGED
@@ -89,13 +89,49 @@ export interface CaSecret {
89
89
  }
90
90
 
91
91
  /**
92
- * The bindings (environment) the CA expects, see {@link CaDatabase} and
93
- * {@link CaSecret} for the non-string bindings.
92
+ * A rate limiter, with the same shape as a Cloudflare Workers rate limit
93
+ * binding.
94
+ *
95
+ * A Workers `RateLimit` binding satisfies this structurally. On other
96
+ * runtimes any limiter with the same shape may be used.
97
+ */
98
+ export interface CaRateLimiter {
99
+ /**
100
+ * Counts a request against the limit for a key
101
+ *
102
+ * @param options The key the request is counted against, for example the client IP address
103
+ * @returns Whether the request is within the limit
104
+ */
105
+ limit(options: { key: string }): Promise<{ success: boolean }>
106
+ }
107
+
108
+ /**
109
+ * A key-value cache, with the subset of a Cloudflare Workers KV namespace
110
+ * binding used by the CA.
111
+ *
112
+ * A Workers `KVNamespace` binding satisfies this structurally. On other
113
+ * runtimes any cache with the same shape may be used, such as the in-memory
114
+ * cache returned by `cacheInMemory` from `@andrewheberle/serverless-ssh-ca/node`.
115
+ */
116
+ export interface CaCache {
117
+ /** Returns the value for a key, or null if there is none */
118
+ get(key: string): Promise<string | null>
119
+ /** Sets the value for a key, which may expire after `expirationTtl` seconds */
120
+ put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>
121
+ }
122
+
123
+ /**
124
+ * The bindings (environment) the CA expects, see {@link CaDatabase},
125
+ * {@link CaSecret}, {@link CaRateLimiter} and {@link CaCache} for the non-string
126
+ * bindings.
94
127
  */
95
128
  export interface SshCaBindings {
96
129
  CERTIFICATE_REQUEST_TIME_SKEW_MAX: string
97
130
  JWT_ALGORITHMS: string | string[]
98
- JWT_AUD?: string | string[]
131
+ /** The audience the identity token, and unless {@link SshCaBindings.JWT_ACCESS_TOKEN_AUD} is set the access token, must be issued for */
132
+ JWT_AUD: string | string[]
133
+ /** The audience the access token must be issued for, when it differs from {@link SshCaBindings.JWT_AUD} */
134
+ JWT_ACCESS_TOKEN_AUD?: string | string[]
99
135
  JWT_ISSUER: string
100
136
  JWT_JWKS_URL: string
101
137
  JWT_SSH_CERTIFICATE_PRINCIPALS_CLAIM: string
@@ -104,6 +140,16 @@ export interface SshCaBindings {
104
140
  DB: CaDatabase
105
141
  ISSUER_DN: string
106
142
  PRIVATE_KEY: CaSecret
143
+ /**
144
+ * Limits requests to issue, renew and revoke certificates, see
145
+ * {@link CaRateLimiter}. Requests are not limited when this is not set.
146
+ */
147
+ RATE_LIMITER?: CaRateLimiter
148
+ /**
149
+ * Caches KRLs until a certificate is revoked, see {@link CaCache}. KRLs
150
+ * are generated for every request when this is not set.
151
+ */
152
+ CACHE?: CaCache
107
153
  SSH_CERTIFICATE_EXTENSIONS: string
108
154
  SSH_CERTIFICATE_INCLUDE_SELF_EMAIL?: string | boolean
109
155
  SSH_CERTIFICATE_INCLUDE_SELF?: string | boolean
package/src/utils.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import type { SshCaBindings } from "./types.js"
2
2
  import { JWKInvalid, JWKSInvalid, JWSInvalid, JWSSignatureVerificationFailed, JWTClaimValidationFailed, JWTExpired, JWTInvalid } from "jose/errors"
3
3
  import { CertificateParseError, SshCertificate } from "./ssh/certificate.js"
4
- import { KeyParseError, parsePrivateKey, PublicKey, UnsupportedKeyError, type PrivateKey } from "./ssh/keys.js"
4
+ import { KeyParseError, MinimumRsaKeyBits, parsePrivateKey, PublicKey, rsaKeyBits, UnsupportedKeyError, type PrivateKey } from "./ssh/keys.js"
5
5
  import z from "zod"
6
- import { verifyJWT } from "./verify.js"
6
+ import { MissingAudienceError, verifyJWT } from "./verify.js"
7
7
  import { CertificateRequestJWTPayload } from "./types.js"
8
8
  import { RenewalProofOfPossession, ProofOfPossession, PossessionParseError } from "./proof.js"
9
9
  import type { isRevoked as IsRevokedFn } from "./db/index.js"
@@ -56,6 +56,74 @@ type AccessToken = {
56
56
  sub: string
57
57
  }
58
58
 
59
+ /**
60
+ * Thrown when a claim of a verified token is missing or is not of the
61
+ * expected type.
62
+ */
63
+ export class TokenClaimError extends Error {
64
+ constructor(message: string) {
65
+ super(message)
66
+ this.name = "TokenClaimError"
67
+
68
+ Object.setPrototypeOf(this, TokenClaimError.prototype)
69
+ }
70
+ }
71
+
72
+ const SubjectClaimSchema = z.string().min(1)
73
+
74
+ /**
75
+ * Returns the subject of a verified token, which is compared between the
76
+ * access and identity tokens so must always be present.
77
+ *
78
+ * @param payload The payload of a verified token
79
+ * @returns The `sub` claim of the token
80
+ * @throws {@link TokenClaimError} when the `sub` claim is missing, empty or not a string
81
+ */
82
+ export const tokenSubject = (payload: { sub?: unknown }): string => {
83
+ const parsed = SubjectClaimSchema.safeParse(payload.sub)
84
+ if (!parsed.success) {
85
+ throw new TokenClaimError("JWT was verified but was missing required sub claim")
86
+ }
87
+
88
+ return parsed.data
89
+ }
90
+
91
+ /**
92
+ * The claims of an access token that identify the user's email address.
93
+ *
94
+ * `email_verified` is optional as not every IdP includes it, but when it is
95
+ * included it must be true, as an IdP may otherwise issue a token for an
96
+ * address the user has not shown they control.
97
+ */
98
+ const EmailClaimsSchema = z.object({
99
+ email: z.email(),
100
+ email_verified: z.boolean().optional(),
101
+ })
102
+
103
+ /**
104
+ * Returns the email address of a verified access token, or why it cannot be
105
+ * used.
106
+ *
107
+ * @param payload The payload of a verified access token
108
+ * @returns The email address, or an error message if the claims are not acceptable
109
+ */
110
+ export const tokenEmail = (payload: Record<string, unknown>): { ok: true, email: string } | { ok: false, error: string } => {
111
+ if (payload.email === undefined) {
112
+ return { ok: false, error: "JWT was verified but was missing required email claim" }
113
+ }
114
+
115
+ const parsed = EmailClaimsSchema.safeParse(payload)
116
+ if (!parsed.success) {
117
+ return { ok: false, error: "JWT was verified but the email or email_verified claim was not valid" }
118
+ }
119
+
120
+ if (parsed.data.email_verified === false) {
121
+ return { ok: false, error: "JWT was verified but the email address has not been verified by the IdP" }
122
+ }
123
+
124
+ return { ok: true, email: parsed.data.email }
125
+ }
126
+
59
127
  export const transformAuthorizationHeader = async (env: SshCaBindings, val: string, ctx: z.RefinementCtx): Promise<AccessToken | never> => {
60
128
  if (!val.startsWith("Bearer ") || val.length <= 7) {
61
129
  return fatalIssue(ctx, "authorization header must be a Bearer token", val)
@@ -69,19 +137,28 @@ export const transformAuthorizationHeader = async (env: SshCaBindings, val: stri
69
137
  }
70
138
 
71
139
  try {
72
- // verify jwt
73
- const { payload } = await verifyJWT(env, jwt)
74
-
75
- if (payload.email === undefined) {
76
- return fatalIssue(ctx, "JWT was verified but was missing required email claim", val)
140
+ // verify jwt, which must be issued for this CA
141
+ const aud = split(env.JWT_ACCESS_TOKEN_AUD).length > 0 ? env.JWT_ACCESS_TOKEN_AUD : env.JWT_AUD
142
+ const { payload } = await verifyJWT(env, jwt, aud)
143
+
144
+ const email = tokenEmail(payload)
145
+ if (!email.ok) {
146
+ l.warn(email.error, "in", "transformAuthorizationHeader")
147
+ return fatalIssue(ctx, email.error, val)
77
148
  }
78
149
 
79
150
  return {
80
- email: payload.email,
81
- sub: payload.sub,
151
+ email: email.email,
152
+ sub: tokenSubject(payload),
82
153
  }
83
154
  } catch (err) {
84
155
  switch (true) {
156
+ case (err instanceof MissingAudienceError):
157
+ // a misconfigured CA is a server error, not a validation failure
158
+ l.error(err.message, "in", "transformAuthorizationHeader")
159
+ throw new InternalServerErrorException(err.message)
160
+ case (err instanceof TokenClaimError):
161
+ return fatalIssue(ctx, err.message, val)
85
162
  case (err instanceof JWSInvalid):
86
163
  return fatalIssue(ctx, "the access token was invalid", val)
87
164
  case (err instanceof JWTInvalid):
@@ -145,6 +222,36 @@ export const transformPublicKey = (val: string | Buffer<ArrayBufferLike>, ctx: z
145
222
  }
146
223
  }
147
224
 
225
+ /**
226
+ * Returns why a public key cannot be issued a certificate, or undefined if it
227
+ * can be.
228
+ *
229
+ * RSA keys must be at least {@link MinimumRsaKeyBits} bits, as smaller keys
230
+ * can be factored and are refused by current OpenSSH releases.
231
+ *
232
+ * @param key The public key a certificate would be issued for
233
+ * @returns The reason the key cannot be used, or undefined if it can be
234
+ */
235
+ export const subjectKeyIssue = (key: PublicKey): string | undefined => {
236
+ const bits = rsaKeyBits(key)
237
+ if (bits !== undefined && bits < MinimumRsaKeyBits) {
238
+ return `RSA keys must be at least ${MinimumRsaKeyBits} bits, not ${bits}`
239
+ }
240
+
241
+ return undefined
242
+ }
243
+
244
+ /**
245
+ * Refines a public key so only one allowed by {@link subjectKeyIssue} is
246
+ * accepted for a certificate request.
247
+ */
248
+ export const refineSubjectKey = (val: PublicKey, ctx: z.RefinementCtx): void => {
249
+ const issue = subjectKeyIssue(val)
250
+ if (issue !== undefined) {
251
+ fatalIssue(ctx, issue, val.toString())
252
+ }
253
+ }
254
+
148
255
  /**
149
256
  * Thrown by {@link identityPrincipals} when the principals claim of an
150
257
  * identity token is not a string or an array of strings.
@@ -210,13 +317,13 @@ export const parseIdentity = async (env: SshCaBindings, jwt: string | undefined,
210
317
  throw new Error("missing identity token")
211
318
  }
212
319
 
213
- const aud = split(env.JWT_AUD)
214
- const { payload } = await verifyJWT(env, jwt, { aud: aud.length === 0 ? undefined : aud })
320
+ const { payload } = await verifyJWT(env, jwt, env.JWT_AUD)
215
321
 
322
+ const sub = tokenSubject(payload)
216
323
  const principals = identityPrincipals(env, payload, claim)
217
324
 
218
325
  return {
219
- sub: payload.sub,
326
+ sub: sub,
220
327
  principals: principals,
221
328
  }
222
329
  }
@@ -227,9 +334,14 @@ export const transformIdentityToken = async (env: SshCaBindings, val: string, ct
227
334
 
228
335
  return identity
229
336
  } catch (err) {
337
+ // a misconfigured CA is a server error, not a validation failure
338
+ if (err instanceof MissingAudienceError) {
339
+ logger(env).error(err.message, "in", "transformIdentityToken")
340
+ throw new InternalServerErrorException(err.message)
341
+ }
230
342
  ctx.issues.push({
231
343
  code: "custom",
232
- message: err instanceof PrincipalsClaimError ? err.message : "problem parsing identity token",
344
+ message: err instanceof PrincipalsClaimError || err instanceof TokenClaimError ? err.message : "problem parsing identity token",
233
345
  input: val
234
346
  })
235
347
  return z.NEVER
@@ -356,6 +468,51 @@ export const refineHostCertificateRequest = async (env: SshCaBindings, val: Pars
356
468
  }
357
469
  }
358
470
 
471
+ // characters that OpenSSH treats as pattern syntax when matching host
472
+ // certificate principals, or that separate entries in a pattern list
473
+ const HostPrincipalPatternChars = /[*?!,]/
474
+ // whitespace and control characters, which no host name contains
475
+ const HostPrincipalInvalidChars = /[\s\p{Cc}]/u
476
+
477
+ /**
478
+ * Returns why a principal cannot be included on a host certificate, or
479
+ * undefined if it can be.
480
+ *
481
+ * OpenSSH matches the principals of a host certificate as patterns, so a
482
+ * principal such as `*` would make the certificate valid for every host that
483
+ * trusts the CA. Principals are otherwise not restricted, as hosts may be
484
+ * reached by IP address, bare host name or fully qualified domain name.
485
+ *
486
+ * @param principal The principal to check
487
+ * @returns The reason the principal cannot be used, or undefined if it can be
488
+ */
489
+ export const hostPrincipalIssue = (principal: string): string | undefined => {
490
+ if (principal === "") {
491
+ return "host certificate principals must not be empty"
492
+ }
493
+
494
+ if (HostPrincipalPatternChars.test(principal)) {
495
+ return `host certificate principal ${JSON.stringify(principal)} must not contain wildcard or pattern characters (*, ?, ! or ,)`
496
+ }
497
+
498
+ if (HostPrincipalInvalidChars.test(principal)) {
499
+ return `host certificate principal ${JSON.stringify(principal)} must not contain whitespace or control characters`
500
+ }
501
+
502
+ return undefined
503
+ }
504
+
505
+ /**
506
+ * Refines a host certificate principal so only one allowed by
507
+ * {@link hostPrincipalIssue} is accepted.
508
+ */
509
+ export const refineHostPrincipal = (val: string, ctx: z.RefinementCtx): void => {
510
+ const issue = hostPrincipalIssue(val)
511
+ if (issue !== undefined) {
512
+ fatalIssue(ctx, issue, val)
513
+ }
514
+ }
515
+
359
516
  type ParsedHostCertificateRenewal = {
360
517
  certificate: SshCertificate
361
518
  proof: RenewalProofOfPossession
@@ -363,7 +520,8 @@ type ParsedHostCertificateRenewal = {
363
520
 
364
521
  /**
365
522
  * Checks a certificate presented for renewal was issued by this CA, is a host
366
- * certificate, is within its validity period and has not been revoked.
523
+ * certificate whose principals are allowed (see {@link hostPrincipalIssue}),
524
+ * is within its validity period and has not been revoked.
367
525
  *
368
526
  * @param env The CA bindings
369
527
  * @param isRevoked Returns whether a serial has been revoked
@@ -384,6 +542,22 @@ export const checkRenewableCertificate = async (env: SshCaBindings, isRevoked: t
384
542
  return "the provided certificate is not a host certificate"
385
543
  }
386
544
 
545
+ // a certificate issued before small RSA keys were refused must not be
546
+ // renewed, as the renewal would keep its key
547
+ const keyIssue = subjectKeyIssue(certificate.publicKey)
548
+ if (keyIssue !== undefined) {
549
+ return `the provided certificate cannot be renewed: ${keyIssue}`
550
+ }
551
+
552
+ // a certificate issued before wildcard principals were refused must not
553
+ // be renewed, as the renewal would keep its principals
554
+ for (const principal of certificate.principals) {
555
+ const issue = hostPrincipalIssue(principal)
556
+ if (issue !== undefined) {
557
+ return `the provided certificate cannot be renewed: ${issue}`
558
+ }
559
+ }
560
+
387
561
  // check certificate is within its validity period, allowing for clock
388
562
  // skew at the start as a certificate issued moments ago may have a
389
563
  // validity start up to a second in the future
package/src/verify.ts CHANGED
@@ -3,23 +3,63 @@ import { CertificateRequestJWTPayload } from "./types.js"
3
3
  import type { SshCaBindings } from "./types.js"
4
4
  import { split } from "./utils.js"
5
5
 
6
- type VerifyOptions = {
7
- aud?: string | string[]
6
+ /**
7
+ * Thrown when no audience is configured for a token, as a token issued for
8
+ * any client of the IdP would otherwise be accepted.
9
+ */
10
+ export class MissingAudienceError extends Error {
11
+ constructor(message: string) {
12
+ super(message)
13
+ this.name = "MissingAudienceError"
14
+
15
+ Object.setPrototypeOf(this, MissingAudienceError.prototype)
16
+ }
8
17
  }
9
18
 
10
- export const verifyJWT = async (env: SshCaBindings, jwt: string, opts: VerifyOptions = {}) => {
11
- const JWKS = createRemoteJWKSet(new URL(env.JWT_JWKS_URL))
19
+ type RemoteJWKSet = ReturnType<typeof createRemoteJWKSet>
20
+
21
+ // the remote JWKS for each URL, kept between requests so the keys that jose
22
+ // caches are reused rather than fetched again for every token
23
+ const remoteJWKSets = new Map<string, RemoteJWKSet>()
24
+
25
+ /**
26
+ * Returns the remote JWKS for a URL, creating it on first use.
27
+ *
28
+ * jose caches the keys it fetches for up to 10 minutes and fetches them again
29
+ * sooner when a token is signed with a key it does not have, so keys rotated
30
+ * by the IdP are still picked up.
31
+ *
32
+ * @param url The URL of the JWKS
33
+ * @returns The remote JWKS for the URL
34
+ */
35
+ const remoteJWKSet = (url: string): RemoteJWKSet => {
36
+ let jwks = remoteJWKSets.get(url)
37
+ if (jwks === undefined) {
38
+ jwks = createRemoteJWKSet(new URL(url))
39
+ remoteJWKSets.set(url, jwks)
40
+ }
41
+
42
+ return jwks
43
+ }
12
44
 
13
- if (opts.aud !== undefined) {
14
- return await jwtVerify<CertificateRequestJWTPayload>(jwt, JWKS, {
15
- issuer: env.JWT_ISSUER,
16
- algorithms: split(env.JWT_ALGORITHMS),
17
- audience: opts.aud
18
- })
45
+ /**
46
+ * Verifies a JWT from the IdP, including that it was issued for one of the
47
+ * audiences given.
48
+ *
49
+ * @param env The CA bindings
50
+ * @param jwt The JWT to verify
51
+ * @param aud The audiences the JWT may be issued for, which must not be empty
52
+ * @throws {@link MissingAudienceError} when no audience is given
53
+ */
54
+ export const verifyJWT = async (env: SshCaBindings, jwt: string, aud: string | string[] | undefined) => {
55
+ const audience = split(aud)
56
+ if (audience.length === 0) {
57
+ throw new MissingAudienceError("no audience is configured for verifying tokens, set JWT_AUD")
19
58
  }
20
59
 
21
- return await jwtVerify<CertificateRequestJWTPayload>(jwt, JWKS, {
22
- issuer: env.JWT_ISSUER,
23
- algorithms: split(env.JWT_ALGORITHMS),
24
- })
60
+ return await jwtVerify<CertificateRequestJWTPayload>(jwt, remoteJWKSet(env.JWT_JWKS_URL), {
61
+ issuer: env.JWT_ISSUER,
62
+ algorithms: split(env.JWT_ALGORITHMS),
63
+ audience: audience,
64
+ })
25
65
  }