@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
@@ -2,22 +2,27 @@ import { group, Logger } from "@andrewheberle/ts-slog"
2
2
  import {
3
3
  ApiException,
4
4
  ConflictException,
5
+ InputValidationException,
5
6
  InternalServerErrorException,
6
7
  NotFoundException,
7
8
  UnprocessableEntityException,
8
9
  } from "chanfana"
9
10
  import type { AppContext } from "../router.js"
10
11
  import type { CertificateSignerResponse } from "../types.js"
11
- import { BadIssuerError } from "../certificate.js"
12
+ import { BadIssuerError, CertificateError } from "../certificate.js"
12
13
  import type { SshCertificate } from "../ssh/certificate.js"
13
14
  import { KeyParseError, type PublicKey } from "../ssh/keys.js"
14
15
  import { getPrivateKey, getPublic, UnsupportedKeyError } from "../utils.js"
15
16
  import { CertificateType } from "../db/types.js"
16
17
  import { KRLBuilder } from "../krl.js"
18
+ import { z } from "zod"
19
+ import { certificateTypeName } from "./schema.js"
17
20
  import {
21
+ getKrlRevision,
18
22
  getKrlVersion,
19
23
  getRevocationList,
20
24
  recordCertificate,
25
+ recordRenewal,
21
26
  RevocationStatus,
22
27
  revocationStatus,
23
28
  revokeCertificate,
@@ -32,7 +37,8 @@ const caKeyError = (l: Logger, err: unknown): ApiException => {
32
37
  l.error(err.message)
33
38
  return new InternalServerErrorException(err.message)
34
39
  case (err instanceof KeyParseError):
35
- l.error("error parsing private key")
40
+ // the reason is only logged, as it may describe the key
41
+ l.error(`error parsing private key: ${err.message}`, "error", err)
36
42
  return new InternalServerErrorException("error parsing private key")
37
43
  case (err instanceof ApiException):
38
44
  // re-throw any exisiting chanfana error
@@ -71,20 +77,38 @@ export const caPublicKey = async (c: AppContext, l: Logger): Promise<Response> =
71
77
  */
72
78
  export const revocationList = async (c: AppContext, l: Logger, certificateType: CertificateType): Promise<Response> => {
73
79
  try {
74
- // get revoked serials
75
- const serials = (await getRevocationList(c.env, certificateType)).map(v => BigInt(v))
76
-
77
80
  // grab private key from secret store
78
81
  const key = await getPrivateKey(c.env)
79
82
 
83
+ // a cached KRL is keyed by the revision it was generated for, which
84
+ // changes whenever a certificate is revoked, and by the CA key that
85
+ // signed it, so a cached KRL is never used once it is out of date. The
86
+ // revision is read before the revoked serials, so the KRL cached for a
87
+ // revision includes at least every certificate revoked by then
88
+ const cache = c.env.CACHE
89
+ const cacheKey = cache === undefined
90
+ ? undefined
91
+ : `krl:${certificateTypeName(certificateType)}:${key.publicKey.fingerprint()}:${await getKrlRevision(c.env, certificateType)}`
92
+
93
+ if (cache !== undefined && cacheKey !== undefined) {
94
+ const cached = await readCachedKrl(cache, cacheKey, l)
95
+ if (cached !== undefined) {
96
+ return c.json(cached)
97
+ }
98
+ }
99
+
100
+ // get revoked serials
101
+ const serials = (await getRevocationList(c.env, certificateType)).map(v => BigInt(v))
102
+
80
103
  // generate KRL
81
104
  const krl = new KRLBuilder(key)
82
105
  .addSerials(serials)
83
106
 
84
- // the version only changes when the content of the KRL does
85
- const version = await getKrlVersion(c.env, certificateType, await krl.digest())
107
+ // the version only changes when the content of the KRL does, and every
108
+ // KRL with the same version has the same generation date
109
+ const { version, generatedAt } = await getKrlVersion(c.env, certificateType, await krl.digest())
86
110
 
87
- const krlBytes = krl.generate(version)
111
+ const krlBytes = krl.generate(version, "", generatedAt)
88
112
 
89
113
  // convert to base64
90
114
  const bytes = new Uint8Array(krlBytes)
@@ -96,15 +120,73 @@ export const revocationList = async (c: AppContext, l: Logger, certificateType:
96
120
 
97
121
  const signature = await krl.signature()
98
122
 
99
- return c.json({
123
+ const response: KrlResponse = {
100
124
  krl: base64Krl,
101
125
  signature: signature,
102
- })
126
+ }
127
+
128
+ if (cache !== undefined && cacheKey !== undefined) {
129
+ try {
130
+ await cache.put(cacheKey, JSON.stringify(response), { expirationTtl: KrlCacheTtl })
131
+ } catch (err) {
132
+ // the KRL is still returned, it is generated again next time
133
+ l.warn("could not cache KRL", "error", err)
134
+ }
135
+ }
136
+
137
+ return c.json(response)
103
138
  } catch (err) {
104
139
  throw caKeyError(l, err)
105
140
  }
106
141
  }
107
142
 
143
+ const KrlResponseSchema = z.object({
144
+ krl: z.string(),
145
+ signature: z.string(),
146
+ })
147
+
148
+ type KrlResponse = z.infer<typeof KrlResponseSchema>
149
+
150
+ // how long a cached KRL is kept, in seconds. A cached KRL is only used for
151
+ // the revision it was generated for, so this only removes entries for earlier
152
+ // revisions from the cache
153
+ const KrlCacheTtl = 7 * 24 * 60 * 60
154
+
155
+ /**
156
+ * Returns a cached KRL response, or undefined if there is none or it cannot be
157
+ * used, in which case the KRL is generated again.
158
+ */
159
+ const readCachedKrl = async (cache: NonNullable<AppContext["env"]["CACHE"]>, key: string, l: Logger): Promise<KrlResponse | undefined> => {
160
+ let cached: string | null
161
+ try {
162
+ cached = await cache.get(key)
163
+ } catch (err) {
164
+ l.warn("could not read cached KRL", "error", err)
165
+ return undefined
166
+ }
167
+
168
+ if (cached === null) {
169
+ return undefined
170
+ }
171
+
172
+ let json: unknown
173
+ try {
174
+ json = JSON.parse(cached)
175
+ } catch {
176
+ l.warn("cached KRL was not valid JSON")
177
+ return undefined
178
+ }
179
+
180
+ const parsed = KrlResponseSchema.safeParse(json)
181
+ if (!parsed.success) {
182
+ l.warn("cached KRL was not a valid KRL response")
183
+ return undefined
184
+ }
185
+
186
+ l.debug("using cached KRL", "key", key)
187
+ return parsed.data
188
+ }
189
+
108
190
  /**
109
191
  * Signs a certificate, records it so it can later be revoked and returns it
110
192
  * as the response to a certificate request or renewal.
@@ -116,41 +198,60 @@ export const revocationList = async (c: AppContext, l: Logger, certificateType:
116
198
  * @param l The logger for the request
117
199
  * @param sign Signs and returns the certificate
118
200
  * @param certificateType The type of certificate being issued
201
+ * @param renews The host certificate being renewed, which is revoked when the new certificate is recorded
119
202
  * @returns The response containing the issued certificate
120
203
  */
121
- export const issueCertificate = async (c: AppContext, l: Logger, sign: () => Promise<SshCertificate>, certificateType: CertificateType): Promise<Response> => {
204
+ export const issueCertificate = async (c: AppContext, l: Logger, sign: () => Promise<SshCertificate>, certificateType: CertificateType, renews?: SshCertificate): Promise<Response> => {
122
205
  try {
123
206
  const certificate = await sign()
124
207
  const response: CertificateSignerResponse = {
125
208
  certificate: btoa(certificate.toString())
126
209
  }
127
210
 
211
+ let recorded: boolean
128
212
  try {
129
- await recordCertificate(c.env, certificate, certificate.keyId, certificateType)
213
+ if (renews === undefined) {
214
+ await recordCertificate(c.env, certificate, certificate.keyId, certificateType)
215
+ recorded = true
216
+ } else {
217
+ recorded = await recordRenewal(c.env, renews, certificate)
218
+ }
130
219
  } catch (err) {
131
220
  // a certificate that is not recorded cannot be revoked, so do not issue it
132
221
  l.error("there was a problem adding issued certificate to database", "error", err)
133
222
  throw new InternalServerErrorException("there was a problem recording the issued certificate")
134
223
  }
135
224
 
225
+ if (!recorded) {
226
+ // the certificate was revoked, or renewed by another request, since it was checked
227
+ l.warn("certificate presented for renewal was revoked before the renewal was recorded")
228
+ throw new InputValidationException("the provided certificate is revoked", ["body", "certificate"])
229
+ }
230
+
136
231
  l.info("completed issuing certificate",
137
232
  ...group(
138
233
  "certificate", "principals",
139
234
  certificate.principals.join(","), "serial", certificate.serial,
140
235
  ),
236
+ ...(renews === undefined ? [] : group("renewed", "serial", renews.serial)),
141
237
  )
142
238
 
143
239
  return c.json(response)
144
240
  } catch (err) {
145
241
  switch (true) {
146
- case (err instanceof UnsupportedKeyError):
147
- l.error(err.message)
148
- throw new InternalServerErrorException(err.message)
242
+ case (err instanceof UnsupportedKeyError || err instanceof KeyParseError):
243
+ throw caKeyError(l, err)
149
244
  case (err instanceof ApiException):
150
245
  // re-throw any exisiting chanfana error
151
246
  throw err
152
247
  case (err instanceof BadIssuerError):
153
248
  throw new UnprocessableEntityException(err.message)
249
+ case (err instanceof CertificateError):
250
+ // the request was valid but asked for a certificate the CA's
251
+ // configuration does not allow, such as an extension that is
252
+ // not permitted or one without any principals
253
+ l.warn("certificate could not be issued", "error", err.message)
254
+ throw new UnprocessableEntityException(err.message)
154
255
  default:
155
256
  // otherwise throw as InternalServerErrorException
156
257
  l.error("unhandled error", "error", err)
@@ -186,7 +287,9 @@ export const revoke = async (c: AppContext, l: Logger, serial: bigint, publicKey
186
287
  const res = await revokeCertificate(c.env, serial)
187
288
 
188
289
  if (res.length === 0) {
189
- throw new InternalServerErrorException("no results were returned for revocation")
290
+ // another request revoked the certificate after its status was checked
291
+ l.warn("certificate was revoked by another request")
292
+ throw new ConflictException("certificate is already revoked")
190
293
  }
191
294
 
192
295
  if (res.length > 1) {
package/src/api/schema.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import z from "zod"
2
2
  import type { SshCaBindings } from "../types.js"
3
3
  import {
4
+ refineSubjectKey,
4
5
  transformAuthorizationHeader,
5
6
  transformPublicKey,
6
7
  transformIdentityToken,
@@ -71,6 +72,14 @@ export const publicKey = openapiStringByte
71
72
  example: "ZWNkc2Etc2hhMi1uaXN0cDI1NiBBQUFBRTJWalpITmhMWE5vWVRJdGJtbHpkSEF5TlRZQUFBQUlibWx6ZEhBeU5UWUFBQUJCQkdNcFNKdHRmMEl0dE5DVmMyOFR6WEZSMUQweHZPM25wNjdWemVBUXNiZFpza3JEY1lqU2x3SjZIUERtVHBYYVUwbEVhWDlMNFloYy9jQ2YxTWU5RlRrPQo="
72
73
  })
73
74
 
75
+ export const subjectPublicKey = openapiStringByte
76
+ .transform(transformPublicKey)
77
+ .superRefine(refineSubjectKey)
78
+ .meta({
79
+ description: "SSH public key to sign",
80
+ example: "ZWNkc2Etc2hhMi1uaXN0cDI1NiBBQUFBRTJWalpITmhMWE5vWVRJdGJtbHpkSEF5TlRZQUFBQUlibWx6ZEhBeU5UWUFBQUJCQkdNcFNKdHRmMEl0dE5DVmMyOFR6WEZSMUQweHZPM25wNjdWemVBUXNiZFpza3JEY1lqU2x3SjZIUERtVHBYYVUwbEVhWDlMNFloYy9jQ2YxTWU5RlRrPQo="
81
+ })
82
+
74
83
  export const createIdentityTokenSchema = (env: SshCaBindings) => (
75
84
  z.string()
76
85
  .meta({
@@ -174,6 +174,7 @@ const HostCertificateRenewEndpoint = (env: SshCaBindings) => {
174
174
  return await issueCertificate(c, l,
175
175
  () => createSignedHostCertificate(c.env, data.body.public_key, opts),
176
176
  CertificateType.Host,
177
+ data.body.certificate,
177
178
  )
178
179
  }
179
180
  }
@@ -1,6 +1,7 @@
1
1
  import z from "zod"
2
2
  import type { SshCaBindings } from "../../types.js"
3
3
  import {
4
+ refineHostPrincipal,
4
5
  split,
5
6
  refineCertificateRequest,
6
7
  transformProofOfPossession,
@@ -16,6 +17,7 @@ import {
16
17
  ForbiddenException,
17
18
  InputValidationException,
18
19
  InternalServerErrorException,
20
+ TooManyRequestsException,
19
21
  UnauthorizedException,
20
22
  UnprocessableEntityException,
21
23
  } from "chanfana"
@@ -31,6 +33,7 @@ import {
31
33
  openapiStringByte,
32
34
  proofOfPossession,
33
35
  publicKey,
36
+ subjectPublicKey,
34
37
  } from "../schema.js"
35
38
 
36
39
  export {
@@ -44,7 +47,7 @@ export {
44
47
 
45
48
  export const userCertificateRequestEndpointBodySchema = (env: SshCaBindings) => (
46
49
  z.object({
47
- public_key: publicKey,
50
+ public_key: subjectPublicKey,
48
51
  proof: proofOfPossession
49
52
  .transform((val, ctx) => transformProofOfPossession(env, val, ctx)),
50
53
  identity: createIdentityTokenSchema(env),
@@ -84,16 +87,17 @@ export const createUserCertificateRequestEndpointSchema = (env: SshCaBindings) =
84
87
  ...InputValidationException.schema(),
85
88
  ...UnprocessableEntityException.schema(),
86
89
  ...InternalServerErrorException.schema(),
90
+ ...TooManyRequestsException.schema(),
87
91
  }
88
92
  })
89
93
 
90
94
  export const hostCertificateRequestEndpointBodySchema = (env: SshCaBindings) => (
91
95
  z.object({
92
- public_key: publicKey,
96
+ public_key: subjectPublicKey,
93
97
  proof: proofOfPossession
94
98
  .transform((val, ctx) => transformProofOfPossession(env, val, ctx)),
95
99
  identity: createIdentityTokenSchema(env),
96
- principals: z.array(z.string()).min(1)
100
+ principals: z.array(z.string().superRefine(refineHostPrincipal)).min(1)
97
101
  .meta({ description: "List of principals to include on the issued certificate" }),
98
102
  lifetime: z.int()
99
103
  .min(seconds("24 hours"))
@@ -128,6 +132,7 @@ export const createHostCertificateRequestEndpointSchema = (env: SshCaBindings) =
128
132
  ...InputValidationException.schema(),
129
133
  ...UnprocessableEntityException.schema(),
130
134
  ...InternalServerErrorException.schema(),
135
+ ...TooManyRequestsException.schema(),
131
136
  }
132
137
  })
133
138
 
@@ -152,7 +157,7 @@ export const hostCertificateRenewEndpointBodySchema = (env: SshCaBindings) => (
152
157
  export const createHostCertificateRenewEndpointSchema = (env: SshCaBindings) => ({
153
158
  tags: [certificateTypeTag(CertificateType.Host)],
154
159
  summary: "Renew a host certificate",
155
- description: "Issues a new SSH host certificate with the same principals as an existing, unexpired and unrevoked host certificate issued by this CA. No OIDC authentication is required; instead the public key must match the existing certificate and the proof of possession must be made with its private key. The lifetime of the renewed certificate is no longer than that of the existing certificate.",
160
+ description: "Issues a new SSH host certificate with the same principals as an existing, unexpired and unrevoked host certificate issued by this CA. No OIDC authentication is required; instead the public key must match the existing certificate and the proof of possession must be made with its private key. The lifetime of the renewed certificate is no longer than that of the existing certificate. The existing certificate is revoked, so it cannot be used or renewed again once the renewed certificate is installed.",
156
161
  request: {
157
162
  body: contentJson(hostCertificateRenewEndpointBodySchema(env))
158
163
  },
@@ -170,6 +175,7 @@ export const createHostCertificateRenewEndpointSchema = (env: SshCaBindings) =>
170
175
  ...InputValidationException.schema(),
171
176
  ...UnprocessableEntityException.schema(),
172
177
  ...InternalServerErrorException.schema(),
178
+ ...TooManyRequestsException.schema(),
173
179
  }
174
180
  })
175
181
 
@@ -211,6 +217,7 @@ export const createRevokeCertificateEndpointSchema = (env: SshCaBindings, certif
211
217
  ...InputValidationException.schema(),
212
218
  ...UnprocessableEntityException.schema(),
213
219
  ...InternalServerErrorException.schema(),
220
+ ...TooManyRequestsException.schema(),
214
221
  ...ConflictException.schema()
215
222
  }
216
223
  })
@@ -222,6 +222,7 @@ const RenewHostCertificateEndpoint = (env: SshCaBindings) => {
222
222
  return await issueCertificate(c, l,
223
223
  () => createSignedHostCertificate(c.env, data.body.certificate.publicKey, opts),
224
224
  CertificateType.Host,
225
+ data.body.certificate,
225
226
  )
226
227
  }
227
228
  }
@@ -1,6 +1,7 @@
1
1
  import z from "zod"
2
2
  import type { SshCaBindings } from "../../types.js"
3
3
  import {
4
+ refineHostPrincipal,
4
5
  refineRenewableCertificate,
5
6
  split,
6
7
  transformCertificate,
@@ -11,6 +12,7 @@ import {
11
12
  contentJson,
12
13
  InputValidationException,
13
14
  InternalServerErrorException,
15
+ TooManyRequestsException,
14
16
  NotFoundException,
15
17
  UnauthorizedException,
16
18
  ForbiddenException,
@@ -28,7 +30,7 @@ import {
28
30
  createIdentityTokenSchema,
29
31
  createRevocationListEndpointSchema,
30
32
  openapiStringByte,
31
- publicKey,
33
+ subjectPublicKey,
32
34
  } from "../schema.js"
33
35
 
34
36
  /**
@@ -96,7 +98,7 @@ export const createGetRevocationListEndpointSchema = (certificateType: Certifica
96
98
 
97
99
  export const issueUserCertificateEndpointBodySchema = (env: SshCaBindings) => (
98
100
  z.object({
99
- public_key: publicKey,
101
+ public_key: subjectPublicKey,
100
102
  identity: createIdentityTokenSchema(env),
101
103
  extensions: z.array(z.string())
102
104
  .default(split(env.SSH_CERTIFICATE_EXTENSIONS))
@@ -130,14 +132,15 @@ export const createIssueUserCertificateEndpointSchema = (env: SshCaBindings) =>
130
132
  ...InputValidationException.schema(),
131
133
  ...UnprocessableEntityException.schema(),
132
134
  ...InternalServerErrorException.schema(),
135
+ ...TooManyRequestsException.schema(),
133
136
  }
134
137
  })
135
138
 
136
139
  export const issueHostCertificateEndpointBodySchema = (env: SshCaBindings) => (
137
140
  z.object({
138
- public_key: publicKey,
141
+ public_key: subjectPublicKey,
139
142
  identity: createIdentityTokenSchema(env),
140
- principals: z.array(z.string()).min(1)
143
+ principals: z.array(z.string().superRefine(refineHostPrincipal)).min(1)
141
144
  .meta({ description: "List of principals to include on the issued certificate" }),
142
145
  lifetime: z.int()
143
146
  .min(seconds("24 hours"))
@@ -168,6 +171,7 @@ export const createIssueHostCertificateEndpointSchema = (env: SshCaBindings) =>
168
171
  ...InputValidationException.schema(),
169
172
  ...UnprocessableEntityException.schema(),
170
173
  ...InternalServerErrorException.schema(),
174
+ ...TooManyRequestsException.schema(),
171
175
  }
172
176
  })
173
177
 
@@ -190,7 +194,7 @@ export const createRenewHostCertificateEndpointSchema = (env: SshCaBindings) =>
190
194
  tags: [certificateTypeTag(CertificateType.Host)],
191
195
  operationId: "renew_host_certificate",
192
196
  summary: "Renew a host certificate",
193
- description: `Issues a new SSH host certificate for the same public key and principals as an existing, unexpired and unrevoked host certificate issued by this CA. No OIDC authentication is required; instead the \`${ProofHeader}\` header must contain a proof of possession of the private key for the existing certificate made for this request. The lifetime of the renewed certificate is no longer than that of the existing certificate.`,
197
+ description: `Issues a new SSH host certificate for the same public key and principals as an existing, unexpired and unrevoked host certificate issued by this CA. No OIDC authentication is required; instead the \`${ProofHeader}\` header must contain a proof of possession of the private key for the existing certificate made for this request. The lifetime of the renewed certificate is no longer than that of the existing certificate. The existing certificate is revoked, so it cannot be used or renewed again once the renewed certificate is installed.`,
194
198
  request: {
195
199
  params: serialParamSchema,
196
200
  headers: createProofHeaderSchema(env),
@@ -206,6 +210,7 @@ export const createRenewHostCertificateEndpointSchema = (env: SshCaBindings) =>
206
210
  ...InputValidationException.schema(),
207
211
  ...UnprocessableEntityException.schema(),
208
212
  ...InternalServerErrorException.schema(),
213
+ ...TooManyRequestsException.schema(),
209
214
  }
210
215
  })
211
216
 
@@ -231,5 +236,6 @@ export const createRevokeCertificateEndpointSchema = (env: SshCaBindings, certif
231
236
  ...NotFoundException.schema(),
232
237
  ...ConflictException.schema(),
233
238
  ...InternalServerErrorException.schema(),
239
+ ...TooManyRequestsException.schema(),
234
240
  }
235
241
  })