@andrewheberle/serverless-ssh-ca 0.22.0 → 0.24.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.
Files changed (122) hide show
  1. package/README.md +174 -1
  2. package/dist/api/operations.d.ts +50 -0
  3. package/dist/api/operations.d.ts.map +1 -0
  4. package/dist/api/operations.js +264 -0
  5. package/dist/api/operations.js.map +1 -0
  6. package/dist/api/schema.d.ts +72 -0
  7. package/dist/api/schema.d.ts.map +1 -0
  8. package/dist/api/schema.js +114 -0
  9. package/dist/api/schema.js.map +1 -0
  10. package/dist/api/v3/index.d.ts.map +1 -1
  11. package/dist/api/v3/index.js +13 -202
  12. package/dist/api/v3/index.js.map +1 -1
  13. package/dist/api/v3/schema.d.ts +23 -68
  14. package/dist/api/v3/schema.d.ts.map +1 -1
  15. package/dist/api/v3/schema.js +18 -104
  16. package/dist/api/v3/schema.js.map +1 -1
  17. package/dist/api/v4/index.d.ts +8 -0
  18. package/dist/api/v4/index.d.ts.map +1 -0
  19. package/dist/api/v4/index.js +155 -0
  20. package/dist/api/v4/index.js.map +1 -0
  21. package/dist/api/v4/schema.d.ts +268 -0
  22. package/dist/api/v4/schema.d.ts.map +1 -0
  23. package/dist/api/v4/schema.js +186 -0
  24. package/dist/api/v4/schema.js.map +1 -0
  25. package/dist/certificate.d.ts +9 -6
  26. package/dist/certificate.d.ts.map +1 -1
  27. package/dist/certificate.js +55 -99
  28. package/dist/certificate.js.map +1 -1
  29. package/dist/db/index.d.ts +68 -9
  30. package/dist/db/index.d.ts.map +1 -1
  31. package/dist/db/index.js +165 -49
  32. package/dist/db/index.js.map +1 -1
  33. package/dist/db/migrations/0003_krl_revision.d.ts +3 -0
  34. package/dist/db/migrations/0003_krl_revision.d.ts.map +1 -0
  35. package/dist/db/migrations/0003_krl_revision.js +14 -0
  36. package/dist/db/migrations/0003_krl_revision.js.map +1 -0
  37. package/dist/db/migrations/index.d.ts.map +1 -1
  38. package/dist/db/migrations/index.js +2 -0
  39. package/dist/db/migrations/index.js.map +1 -1
  40. package/dist/db/types.d.ts +2 -0
  41. package/dist/db/types.d.ts.map +1 -1
  42. package/dist/index.d.ts +5 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +4 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/krl.d.ts +3 -2
  47. package/dist/krl.d.ts.map +1 -1
  48. package/dist/krl.js +12 -165
  49. package/dist/krl.js.map +1 -1
  50. package/dist/node/cache.d.ts +21 -0
  51. package/dist/node/cache.d.ts.map +1 -0
  52. package/dist/node/cache.js +49 -0
  53. package/dist/node/cache.js.map +1 -0
  54. package/dist/node/env.d.ts +6 -3
  55. package/dist/node/env.d.ts.map +1 -1
  56. package/dist/node/env.js +4 -3
  57. package/dist/node/env.js.map +1 -1
  58. package/dist/node/index.d.ts +1 -0
  59. package/dist/node/index.d.ts.map +1 -1
  60. package/dist/node/index.js +1 -0
  61. package/dist/node/index.js.map +1 -1
  62. package/dist/proof.d.ts +36 -7
  63. package/dist/proof.d.ts.map +1 -1
  64. package/dist/proof.js +53 -21
  65. package/dist/proof.js.map +1 -1
  66. package/dist/router.d.ts +25 -1
  67. package/dist/router.d.ts.map +1 -1
  68. package/dist/router.js +41 -2
  69. package/dist/router.js.map +1 -1
  70. package/dist/ssh/certificate.d.ts +93 -0
  71. package/dist/ssh/certificate.d.ts.map +1 -0
  72. package/dist/ssh/certificate.js +178 -0
  73. package/dist/ssh/certificate.js.map +1 -0
  74. package/dist/ssh/encoding.d.ts +41 -0
  75. package/dist/ssh/encoding.d.ts.map +1 -0
  76. package/dist/ssh/encoding.js +70 -0
  77. package/dist/ssh/encoding.js.map +1 -0
  78. package/dist/ssh/keys.d.ts +92 -0
  79. package/dist/ssh/keys.d.ts.map +1 -0
  80. package/dist/ssh/keys.js +277 -0
  81. package/dist/ssh/keys.js.map +1 -0
  82. package/dist/ssh/signer.d.ts +35 -0
  83. package/dist/ssh/signer.d.ts.map +1 -0
  84. package/dist/ssh/signer.js +99 -0
  85. package/dist/ssh/signer.js.map +1 -0
  86. package/dist/types.d.ts +64 -19
  87. package/dist/types.d.ts.map +1 -1
  88. package/dist/utils.d.ts +107 -21
  89. package/dist/utils.d.ts.map +1 -1
  90. package/dist/utils.js +251 -65
  91. package/dist/utils.js.map +1 -1
  92. package/dist/verify.d.ts +17 -5
  93. package/dist/verify.d.ts.map +1 -1
  94. package/dist/verify.js +47 -9
  95. package/dist/verify.js.map +1 -1
  96. package/openapi.json +1942 -1
  97. package/package.json +4 -3
  98. package/src/api/operations.ts +314 -0
  99. package/src/api/schema.ts +146 -0
  100. package/src/api/v3/index.ts +21 -248
  101. package/src/api/v3/schema.ts +36 -128
  102. package/src/api/v4/index.ts +266 -0
  103. package/src/api/v4/schema.ts +241 -0
  104. package/src/certificate.ts +62 -123
  105. package/src/db/index.ts +196 -56
  106. package/src/db/migrations/0003_krl_revision.ts +15 -0
  107. package/src/db/migrations/index.ts +2 -0
  108. package/src/db/types.ts +2 -0
  109. package/src/index.ts +6 -4
  110. package/src/krl.ts +13 -197
  111. package/src/node/cache.ts +62 -0
  112. package/src/node/env.ts +6 -5
  113. package/src/node/index.ts +1 -0
  114. package/src/proof.ts +65 -31
  115. package/src/router.ts +67 -3
  116. package/src/ssh/certificate.ts +236 -0
  117. package/src/ssh/encoding.ts +81 -0
  118. package/src/ssh/keys.ts +327 -0
  119. package/src/ssh/signer.ts +129 -0
  120. package/src/types.ts +60 -20
  121. package/src/utils.ts +285 -81
  122. package/src/verify.ts +54 -14
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
- import sshpk from "sshpk"
4
- import type { Certificate, Key, PrivateKey } from "sshpk"
3
+ import { CertificateParseError, SshCertificate } from "./ssh/certificate.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"
@@ -11,8 +11,7 @@ import { logger } from "./logger.js"
11
11
  import { ms } from "itty-time"
12
12
  import { InternalServerErrorException } from "chanfana"
13
13
 
14
- // sshpk is CommonJS and Node's ESM loader cannot detect most of its named exports, so values are taken from the default import
15
- const { KeyParseError, CertificateParseError, parseCertificate, parseKey, parsePrivateKey } = sshpk
14
+ export { UnsupportedKeyError }
16
15
 
17
16
  export const fatalIssue = (ctx: z.RefinementCtx, message: string, val: unknown) => {
18
17
  ctx.issues.push({
@@ -57,6 +56,74 @@ type AccessToken = {
57
56
  sub: string
58
57
  }
59
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
+
60
127
  export const transformAuthorizationHeader = async (env: SshCaBindings, val: string, ctx: z.RefinementCtx): Promise<AccessToken | never> => {
61
128
  if (!val.startsWith("Bearer ") || val.length <= 7) {
62
129
  return fatalIssue(ctx, "authorization header must be a Bearer token", val)
@@ -70,19 +137,28 @@ export const transformAuthorizationHeader = async (env: SshCaBindings, val: stri
70
137
  }
71
138
 
72
139
  try {
73
- // verify jwt
74
- const { payload } = await verifyJWT(env, jwt)
75
-
76
- if (payload.email === undefined) {
77
- 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)
78
148
  }
79
149
 
80
150
  return {
81
- email: payload.email,
82
- sub: payload.sub,
151
+ email: email.email,
152
+ sub: tokenSubject(payload),
83
153
  }
84
154
  } catch (err) {
85
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)
86
162
  case (err instanceof JWSInvalid):
87
163
  return fatalIssue(ctx, "the access token was invalid", val)
88
164
  case (err instanceof JWTInvalid):
@@ -104,11 +180,20 @@ export const transformAuthorizationHeader = async (env: SshCaBindings, val: stri
104
180
  }
105
181
  }
106
182
 
107
- export const transformPublicKey = (val: string | Buffer<ArrayBufferLike>, ctx: z.RefinementCtx): Key | never => {
108
- try {
109
- const key = typeof val === "string" ? parseKey(Buffer.from(val, "base64")) : parseKey(val)
183
+ // decodes a value sent as base64, which may already have been decoded by the schema
184
+ const fromBase64 = (val: string | Buffer<ArrayBufferLike>): string => {
185
+ return (typeof val === "string" ? Buffer.from(val, "base64") : Buffer.from(val)).toString("utf8")
186
+ }
110
187
 
111
- return key
188
+ /**
189
+ * Parses a public key in OpenSSH format, `${type} ${base64}[ ${comment}]`
190
+ *
191
+ * @param val The public key, either base64 encoded or as bytes
192
+ * @param ctx The Zod context for any errors
193
+ */
194
+ export const transformPublicKey = (val: string | Buffer<ArrayBufferLike>, ctx: z.RefinementCtx): PublicKey | never => {
195
+ try {
196
+ return PublicKey.parse(fromBase64(val))
112
197
  } catch (err) {
113
198
  switch (true) {
114
199
  case (err instanceof TypeError):
@@ -137,6 +222,36 @@ export const transformPublicKey = (val: string | Buffer<ArrayBufferLike>, ctx: z
137
222
  }
138
223
  }
139
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
+
140
255
  /**
141
256
  * Thrown by {@link identityPrincipals} when the principals claim of an
142
257
  * identity token is not a string or an array of strings.
@@ -202,13 +317,13 @@ export const parseIdentity = async (env: SshCaBindings, jwt: string | undefined,
202
317
  throw new Error("missing identity token")
203
318
  }
204
319
 
205
- const aud = split(env.JWT_AUD)
206
- const { payload } = await verifyJWT(env, jwt, { aud: aud.length === 0 ? undefined : aud })
320
+ const { payload } = await verifyJWT(env, jwt, env.JWT_AUD)
207
321
 
322
+ const sub = tokenSubject(payload)
208
323
  const principals = identityPrincipals(env, payload, claim)
209
324
 
210
325
  return {
211
- sub: payload.sub,
326
+ sub: sub,
212
327
  principals: principals,
213
328
  }
214
329
  }
@@ -219,9 +334,14 @@ export const transformIdentityToken = async (env: SshCaBindings, val: string, ct
219
334
 
220
335
  return identity
221
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
+ }
222
342
  ctx.issues.push({
223
343
  code: "custom",
224
- message: err instanceof PrincipalsClaimError ? err.message : "problem parsing identity token",
344
+ message: err instanceof PrincipalsClaimError || err instanceof TokenClaimError ? err.message : "problem parsing identity token",
225
345
  input: val
226
346
  })
227
347
  return z.NEVER
@@ -234,11 +354,9 @@ export const transformIdentityToken = async (env: SshCaBindings, val: string, ct
234
354
  * @param ctx The Zod context for any errors
235
355
  * @returns
236
356
  */
237
- export const transformCertificate = (val: string | Buffer<ArrayBufferLike>, ctx: z.RefinementCtx): Certificate | never => {
357
+ export const transformCertificate = (val: string | Buffer<ArrayBufferLike>, ctx: z.RefinementCtx): SshCertificate | never => {
238
358
  try {
239
- const cert = typeof val === "string" ? parseCertificate(Buffer.from(val, "base64"), "openssh") : parseCertificate(val, "openssh")
240
-
241
- return cert
359
+ return SshCertificate.parse(fromBase64(val))
242
360
  } catch (err) {
243
361
  switch (true) {
244
362
  case (err instanceof TypeError):
@@ -269,7 +387,7 @@ export const transformCertificate = (val: string | Buffer<ArrayBufferLike>, ctx:
269
387
 
270
388
  type ParsedCertificateRequest = {
271
389
  proof: ProofOfPossession
272
- public_key: Key
390
+ public_key: PublicKey
273
391
  lifetime: number
274
392
  identity: IdentityToken
275
393
  extensions: string[]
@@ -311,7 +429,7 @@ export const transformRenewalProofOfPossession = (env: SshCaBindings, val: strin
311
429
  }
312
430
 
313
431
  type HostCertificateRequest = {
314
- public_key: Key
432
+ public_key: PublicKey
315
433
  lifetime: number
316
434
  }
317
435
 
@@ -327,7 +445,7 @@ export const refineHostCertificateRequest = async (env: SshCaBindings, val: Pars
327
445
 
328
446
  try {
329
447
  // check proof of possession fingerprint matches public key
330
- l.debug("checking val.proof.matches()", "public_key", val.public_key.toString("ssh"))
448
+ l.debug("checking val.proof.matches()", "public_key", val.public_key.toString())
331
449
  if (!val.proof.matches(val.public_key)) {
332
450
  l.warn("proof of possession fingerprint did not match public_key")
333
451
  return fatalIssue(ctx, "proof of possession fingerprint did not match public_key", val)
@@ -350,39 +468,149 @@ export const refineHostCertificateRequest = async (env: SshCaBindings, val: Pars
350
468
  }
351
469
  }
352
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
+
353
516
  type ParsedHostCertificateRenewal = {
354
- certificate: Certificate
517
+ certificate: SshCertificate
355
518
  proof: RenewalProofOfPossession
356
519
  } & HostCertificateRequest
357
520
 
358
- export const refineHostCertificateRenewal = async (env: SshCaBindings, isRevoked: typeof IsRevokedFn, val: ParsedHostCertificateRenewal, ctx: z.RefinementCtx): Promise<never> => {
521
+ /**
522
+ * Checks a certificate presented for renewal was issued by this CA, is a host
523
+ * certificate whose principals are allowed (see {@link hostPrincipalIssue}),
524
+ * is within its validity period and has not been revoked.
525
+ *
526
+ * @param env The CA bindings
527
+ * @param isRevoked Returns whether a serial has been revoked
528
+ * @param certificate The certificate presented for renewal
529
+ * @returns The reason the certificate cannot be renewed, or undefined if it can be
530
+ * @throws {@link UnsupportedKeyError} when the CA key is not supported
531
+ */
532
+ export const checkRenewableCertificate = async (env: SshCaBindings, isRevoked: typeof IsRevokedFn, certificate: SshCertificate): Promise<string | undefined> => {
533
+ // ensure certificate is signed by CA
534
+ const issuer = await getPublic(env)
535
+ if (!await certificate.isSignedBy(issuer)) {
536
+ return "the provided certificate was not signed by this CA"
537
+ }
538
+
539
+ // only host certificates can be renewed, as renewal does not need the
540
+ // OIDC authentication a user certificate is issued with
541
+ if (certificate.kind !== "host") {
542
+ return "the provided certificate is not a host certificate"
543
+ }
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
+
561
+ // check certificate is within its validity period, allowing for clock
562
+ // skew at the start as a certificate issued moments ago may have a
563
+ // validity start up to a second in the future
564
+ const now = Date.now()
565
+ if (now >= certificate.validBefore.getTime()) {
566
+ return "the provided certificate is expired"
567
+ }
568
+ if (certificate.validAfter.getTime() > now + ms(env.CERTIFICATE_REQUEST_TIME_SKEW_MAX)) {
569
+ return "the provided certificate is not yet valid"
570
+ }
571
+
572
+ // ensure certificate presented for renewal is not revoked
573
+ if (await isRevoked(env, certificate.serial)) {
574
+ return "the provided certificate is revoked"
575
+ }
576
+
577
+ return undefined
578
+ }
579
+
580
+ /**
581
+ * Refines a host certificate renewal so only a certificate that can be
582
+ * renewed (see {@link checkRenewableCertificate}) is accepted.
583
+ */
584
+ export const refineRenewableCertificate = async (env: SshCaBindings, isRevoked: typeof IsRevokedFn, val: { certificate: SshCertificate }, ctx: z.RefinementCtx): Promise<never> => {
359
585
  const l = logger(env)
360
586
  try {
361
- // ensure certificate is signed by CA
362
- const issuer = await getPublic(env)
363
- if (!val.certificate.isSignedByKey(issuer)) {
364
- return fatalIssue(ctx, "the provided certificate was not signed by this CA", val)
587
+ const reason = await checkRenewableCertificate(env, isRevoked, val.certificate)
588
+ if (reason !== undefined) {
589
+ return fatalIssue(ctx, reason, val)
365
590
  }
366
591
 
367
- // check certificate is within its validity period, allowing for clock
368
- // skew at the start as a certificate issued moments ago may have a
369
- // validity start up to a second in the future
370
- const now = Date.now()
371
- if (now >= val.certificate.validUntil.getTime()) {
372
- return fatalIssue(ctx, "the provided certificate is expired", val)
373
- }
374
- if (val.certificate.validFrom.getTime() > now + ms(env.CERTIFICATE_REQUEST_TIME_SKEW_MAX)) {
375
- return fatalIssue(ctx, "the provided certificate is not yet valid", val)
592
+ return z.NEVER
593
+ } catch (err) {
594
+ // a misconfigured CA key is a server error, not a validation failure
595
+ if (err instanceof UnsupportedKeyError) {
596
+ l.error(err.message, "in", "refineRenewableCertificate")
597
+ throw new InternalServerErrorException(err.message)
376
598
  }
599
+ l.error("certificate renewal check unhandled error", "in", "refineRenewableCertificate", "error", err)
600
+ return fatalIssue(ctx, "certificate renewal check unhandled error", val)
601
+ }
602
+ }
377
603
 
378
- // ensure certificate presented for renewal is not revoked
379
- const serial = val.certificate.serial.readBigUInt64BE(0)
380
- if (await isRevoked(env, serial)) {
381
- return fatalIssue(ctx, "the provided certificate is revoked", val)
604
+ export const refineHostCertificateRenewal = async (env: SshCaBindings, isRevoked: typeof IsRevokedFn, val: ParsedHostCertificateRenewal, ctx: z.RefinementCtx): Promise<never> => {
605
+ const l = logger(env)
606
+ try {
607
+ const reason = await checkRenewableCertificate(env, isRevoked, val.certificate)
608
+ if (reason !== undefined) {
609
+ return fatalIssue(ctx, reason, val)
382
610
  }
383
611
 
384
612
  // check proof of possession fingerprint matches public key and certificate subject key
385
- if (!val.proof.matches(val.public_key, val.certificate.subjectKey)) {
613
+ if (!val.proof.matches(val.public_key, val.certificate.publicKey)) {
386
614
  return fatalIssue(ctx, "proof of possession fingerprints did not match public_key and/or certificate", val)
387
615
  }
388
616
 
@@ -406,7 +634,7 @@ export const refineHostCertificateRenewal = async (env: SshCaBindings, isRevoked
406
634
 
407
635
  type ParsedRevokeCertificate = {
408
636
  serial: bigint
409
- public_key: Key
637
+ public_key: PublicKey
410
638
  proof: ProofOfPossession
411
639
  }
412
640
 
@@ -441,53 +669,29 @@ export const split = (v?: string | string[]): string[] => {
441
669
  : v
442
670
  }
443
671
 
444
- /**
445
- * Thrown by {@link getPrivateKey} when the CA private key is of a type that
446
- * cannot be used.
447
- */
448
- export class UnsupportedKeyError extends Error {
449
- constructor(message: string) {
450
- super(message)
451
- this.name = "UnsupportedKeyError"
452
-
453
- Object.setPrototypeOf(this, UnsupportedKeyError.prototype)
454
- }
455
- }
456
-
457
672
  /**
458
673
  * Load and parse the CA private key from the `PRIVATE_KEY` secret.
459
674
  *
460
- * Only Ed25519 and ECDSA keys are supported. RSA keys are rejected because
461
- * sshpk signs RSA certificates with SHA-1 (`ssh-rsa`), which OpenSSH 8.2 and
462
- * later refuse by default, and because RSA keys in OpenSSH format are
463
- * converted with invalid CRT parameters that the Workers runtime rejects.
675
+ * Only Ed25519 and ECDSA keys are supported, as these are the key types that
676
+ * certificates and KRLs are signed with (see `createSigner` in `ssh/signer.ts`).
464
677
  *
465
- * @throws KeyParseError when the secret cannot be parsed.
678
+ * @throws {@link KeyParseError} when the secret cannot be parsed.
466
679
  * @throws {@link UnsupportedKeyError} when the key is not Ed25519 or ECDSA.
467
680
  */
468
681
  export const getPrivateKey = async (env: SshCaBindings): Promise<PrivateKey> => {
469
682
  // grab private key from secret store
470
683
  const secret = await env.PRIVATE_KEY.get()
471
684
 
472
- // parse it
473
- const key = parsePrivateKey(secret)
474
-
475
- if (key.type !== "ed25519" && key.type !== "ecdsa") {
476
- throw new UnsupportedKeyError(`CA key type ${key.type} is not supported, the CA key must be Ed25519 or ECDSA`)
477
- }
478
-
479
- return key
685
+ return parsePrivateKey(secret)
480
686
  }
481
687
 
482
- export const getPublic = async (env: SshCaBindings, key?: PrivateKey): Promise<Key> => {
688
+ /**
689
+ * Returns the public key of the CA, with `ISSUER_DN` as its comment
690
+ */
691
+ export const getPublic = async (env: SshCaBindings, key?: PrivateKey): Promise<PublicKey> => {
483
692
  if (key === undefined) {
484
693
  key = await getPrivateKey(env)
485
694
  }
486
695
 
487
- // convert to a public key and add comment
488
- const pub = key.toPublic()
489
- pub.comment = env.ISSUER_DN
490
-
491
- // return in ssh format trimmed of any whitespace
492
- return pub
696
+ return key.publicKey.withComment(env.ISSUER_DN)
493
697
  }
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
  }