@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
@@ -0,0 +1,266 @@
1
+ import { group, Logger } from "@andrewheberle/ts-slog"
2
+ import {
3
+ ForbiddenException,
4
+ fromHono,
5
+ InputValidationException,
6
+ OpenAPIRoute,
7
+ UnauthorizedException,
8
+ } from "chanfana"
9
+ import { Hono } from "hono"
10
+ import type { PublicKey } from "../../ssh/keys.js"
11
+ import { AppContext } from "../../router.js"
12
+ import {
13
+ CreateCertificateOptions,
14
+ CreateHostCertificateOptions,
15
+ createSignedCertificate,
16
+ createSignedHostCertificate,
17
+ } from "../../certificate.js"
18
+ import { split } from "../../utils.js"
19
+ import { CertificateType } from "../../db/types.js"
20
+ import type { ProofOfPossession } from "../../proof.js"
21
+ import { caPublicKey, issueCertificate, revocationList, revoke } from "../operations.js"
22
+ import { certificateTypeName } from "../schema.js"
23
+ import {
24
+ createCaPublicKeyEndpointSchema,
25
+ createGetRevocationListEndpointSchema,
26
+ createIssueHostCertificateEndpointSchema,
27
+ createIssueUserCertificateEndpointSchema,
28
+ createRenewHostCertificateEndpointSchema,
29
+ createRevokeCertificateEndpointSchema,
30
+ ProofHeader,
31
+ } from "./schema.js"
32
+ import { logger } from "../../logger.js"
33
+ import type { SshCaBindings } from "../../types.js"
34
+
35
+ /**
36
+ * The path the v4 API is served under, which is part of the request a proof
37
+ * of possession is made for
38
+ */
39
+ export const BasePath = "/api/v4"
40
+
41
+ /**
42
+ * Ensures a proof of possession was made with the private key for a public key
43
+ * and for this request, so it cannot be replayed against another endpoint or
44
+ * for another certificate.
45
+ *
46
+ * @param l The logger for the request
47
+ * @param proof The proof of possession from the request
48
+ * @param key The public key the proof must be made with
49
+ * @param method The HTTP method of the request
50
+ * @param path The path of the request below {@link BasePath}, with any parameters filled in
51
+ * @throws {@link InputValidationException} when the proof is not valid for the key and request
52
+ */
53
+ const verifyProof = async (l: Logger, proof: ProofOfPossession, key: PublicKey, method: string, path: string): Promise<void> => {
54
+ if (!proof.matches(key)) {
55
+ l.warn("proof of possession fingerprint did not match public key")
56
+ throw new InputValidationException("proof of possession fingerprint did not match public key", ["headers", ProofHeader])
57
+ }
58
+
59
+ if (!await proof.verifyRequest(method, `${BasePath}${path}`)) {
60
+ l.warn("proof of possession signature validation failed")
61
+ throw new InputValidationException("proof of possession signature validation failed", ["headers", ProofHeader])
62
+ }
63
+ }
64
+
65
+ const CaPublicKeyEndpoint = (env: SshCaBindings) => {
66
+ return class extends OpenAPIRoute {
67
+ override schema = createCaPublicKeyEndpointSchema()
68
+
69
+ override async handle(c: AppContext) {
70
+ const l = logger(env).with(
71
+ ...group("request", "action", "ca"),
72
+ )
73
+
74
+ return await caPublicKey(c, l)
75
+ }
76
+ }
77
+ }
78
+
79
+ const IssueUserCertificateEndpoint = (env: SshCaBindings) => {
80
+ return class extends OpenAPIRoute {
81
+ override schema = createIssueUserCertificateEndpointSchema(env)
82
+
83
+ override async handle(c: AppContext) {
84
+ const data = await this.getValidatedData<typeof this.schema>()
85
+
86
+ const l = logger(env).with(
87
+ ...group("request",
88
+ "type", "user",
89
+ "action", "request",
90
+ ),
91
+ ...group("auth_token",
92
+ "email", data.headers.Authorization.email,
93
+ "sub", data.headers.Authorization.sub,
94
+ ),
95
+ ...group("id_token",
96
+ "sub", data.body.identity.sub,
97
+ ),
98
+ )
99
+
100
+ await verifyProof(l, data.headers[ProofHeader], data.body.public_key, "POST", "/user/certificates")
101
+
102
+ if (data.body.identity.sub !== data.headers.Authorization.sub) {
103
+ l.error("token subjects did not match")
104
+ throw new ForbiddenException("Possible token substitution as subjects for authentication and identity tokens did not match")
105
+ }
106
+
107
+ const opts: CreateCertificateOptions = {
108
+ lifetime: data.body.lifetime,
109
+ principals: data.body.identity.principals,
110
+ extensions: data.body.extensions,
111
+ }
112
+
113
+ return await issueCertificate(c, l,
114
+ () => createSignedCertificate(c.env, data.headers.Authorization.email, data.body.public_key, opts),
115
+ CertificateType.User,
116
+ )
117
+ }
118
+ }
119
+ }
120
+
121
+ const RevocationListEndpoint = (env: SshCaBindings, certificateType: CertificateType) => {
122
+ return class extends OpenAPIRoute {
123
+ override schema = createGetRevocationListEndpointSchema(certificateType)
124
+
125
+ override async handle(c: AppContext) {
126
+ const l = logger(env).with(
127
+ ...group("request",
128
+ "type", certificateTypeName(certificateType),
129
+ "action", "krl",
130
+ ),
131
+ )
132
+
133
+ return await revocationList(c, l, certificateType)
134
+ }
135
+ }
136
+ }
137
+
138
+ const IssueHostCertificateEndpoint = (env: SshCaBindings) => {
139
+ return class extends OpenAPIRoute {
140
+ override schema = createIssueHostCertificateEndpointSchema(env)
141
+
142
+ override async handle(c: AppContext) {
143
+ const data = await this.getValidatedData<typeof this.schema>()
144
+
145
+ const l = logger(env).with(
146
+ ...group("request",
147
+ "type", "host",
148
+ "action", "request",
149
+ ),
150
+ ...group("auth_token",
151
+ "email", data.headers.Authorization.email,
152
+ "sub", data.headers.Authorization.sub,
153
+ ),
154
+ ...group("id_token",
155
+ "sub", data.body.identity.sub,
156
+ ),
157
+ )
158
+
159
+ await verifyProof(l, data.headers[ProofHeader], data.body.public_key, "POST", "/host/certificates")
160
+
161
+ // check id and access token are for the same user
162
+ if (data.body.identity.sub !== data.headers.Authorization.sub) {
163
+ l.error("token subjects did not match")
164
+ throw new ForbiddenException("possible token substitution as subjects for authentication and identity tokens did not match")
165
+ }
166
+
167
+ // check user can issue host certificates
168
+ if (!split(c.env.SSH_HOST_CERTIFICATE_ALLOWED_EMAILS).includes(data.headers.Authorization.email) && !data.body.identity.principals.some((p: string) => split(c.env.SSH_HOST_CERTIFICATE_ALLOWED_ROLES).includes(p))) {
169
+ l.error("unauthorized host certificate request")
170
+ throw new UnauthorizedException("User not allowed to issue host certificates")
171
+ }
172
+
173
+ const opts: CreateHostCertificateOptions = {
174
+ lifetime: data.body.lifetime,
175
+ principals: data.body.principals,
176
+ }
177
+
178
+ return await issueCertificate(c, l,
179
+ () => createSignedHostCertificate(c.env, data.body.public_key, opts),
180
+ CertificateType.Host,
181
+ )
182
+ }
183
+ }
184
+ }
185
+
186
+ const RenewHostCertificateEndpoint = (env: SshCaBindings) => {
187
+ return class extends OpenAPIRoute {
188
+ override schema = createRenewHostCertificateEndpointSchema(env)
189
+
190
+ override async handle(c: AppContext) {
191
+ const data = await this.getValidatedData<typeof this.schema>()
192
+ const serial = data.params.serial
193
+
194
+ const l = logger(env).with(
195
+ ...group("request",
196
+ "type", "host",
197
+ "action", "renewal",
198
+ "serial", serial,
199
+ ),
200
+ )
201
+
202
+ // the serial in the path is what the proof was made for, so it
203
+ // must be the certificate being renewed
204
+ if (data.body.certificate.serial !== serial) {
205
+ l.warn("certificate serial did not match the serial in the path")
206
+ throw new InputValidationException("certificate serial did not match the serial in the path", ["body", "certificate"])
207
+ }
208
+
209
+ await verifyProof(l, data.headers[ProofHeader], data.body.certificate.publicKey, "POST", `/host/certificates/${serial}/renew`)
210
+
211
+ // use smaller of the current certificate lifetime and the requested lifetime
212
+ const originalLifetime = (data.body.certificate.validBefore.getTime() - data.body.certificate.validAfter.getTime()) / 1000
213
+ const lifetime = originalLifetime > data.body.lifetime
214
+ ? data.body.lifetime
215
+ : originalLifetime
216
+
217
+ const opts: CreateHostCertificateOptions = {
218
+ lifetime: lifetime,
219
+ principals: data.body.certificate.principals,
220
+ }
221
+
222
+ return await issueCertificate(c, l,
223
+ () => createSignedHostCertificate(c.env, data.body.certificate.publicKey, opts),
224
+ CertificateType.Host,
225
+ data.body.certificate,
226
+ )
227
+ }
228
+ }
229
+ }
230
+
231
+ const RevokeCertificateEndpoint = (env: SshCaBindings, certificateType: CertificateType) => {
232
+ return class extends OpenAPIRoute {
233
+ override schema = createRevokeCertificateEndpointSchema(env, certificateType)
234
+
235
+ override async handle(c: AppContext) {
236
+ const data = await this.getValidatedData<typeof this.schema>()
237
+ const serial = data.params.serial
238
+ const proof = data.headers[ProofHeader]
239
+
240
+ const l = logger(env).with(
241
+ ...group("request", "type", certificateTypeName(certificateType), "action", "revoke", "serial", serial),
242
+ )
243
+
244
+ // the key the proof was made with is the key the certificate must
245
+ // have been issued for
246
+ await verifyProof(l, proof, proof.signaturePubkey, "DELETE", `/${certificateTypeName(certificateType)}/certificates/${serial}`)
247
+
248
+ return await revoke(c, l, serial, proof.signaturePubkey, certificateType)
249
+ }
250
+ }
251
+ }
252
+
253
+ export const createApi = (env: SshCaBindings) => {
254
+ const api = fromHono(new Hono())
255
+
256
+ api.get("/ca", CaPublicKeyEndpoint(env))
257
+ api.post("/user/certificates", IssueUserCertificateEndpoint(env))
258
+ api.delete("/user/certificates/:serial", RevokeCertificateEndpoint(env, CertificateType.User))
259
+ api.get("/user/krl", RevocationListEndpoint(env, CertificateType.User))
260
+ api.post("/host/certificates", IssueHostCertificateEndpoint(env))
261
+ api.post("/host/certificates/:serial/renew", RenewHostCertificateEndpoint(env))
262
+ api.delete("/host/certificates/:serial", RevokeCertificateEndpoint(env, CertificateType.Host))
263
+ api.get("/host/krl", RevocationListEndpoint(env, CertificateType.Host))
264
+
265
+ return api
266
+ }
@@ -0,0 +1,241 @@
1
+ import z from "zod"
2
+ import type { SshCaBindings } from "../../types.js"
3
+ import {
4
+ refineHostPrincipal,
5
+ refineRenewableCertificate,
6
+ split,
7
+ transformCertificate,
8
+ transformProofOfPossession,
9
+ } from "../../utils.js"
10
+ import {
11
+ ConflictException,
12
+ contentJson,
13
+ InputValidationException,
14
+ InternalServerErrorException,
15
+ TooManyRequestsException,
16
+ NotFoundException,
17
+ UnauthorizedException,
18
+ ForbiddenException,
19
+ UnprocessableEntityException,
20
+ } from "chanfana"
21
+ import { seconds } from "itty-time"
22
+ import { isRevoked } from "../../db/index.js"
23
+ import { CertificateType } from "../../db/types.js"
24
+ import {
25
+ CaPublicKeyEndpointSchema,
26
+ certificate,
27
+ certificateTypeName,
28
+ certificateTypeTag,
29
+ createAccessTokenSchema,
30
+ createIdentityTokenSchema,
31
+ createRevocationListEndpointSchema,
32
+ openapiStringByte,
33
+ subjectPublicKey,
34
+ } from "../schema.js"
35
+
36
+ /**
37
+ * The name of the header carrying the proof of possession of the private key
38
+ */
39
+ export const ProofHeader = "Proof-Of-Possession"
40
+
41
+ export const createProofSchema = (env: SshCaBindings) => (
42
+ z.string()
43
+ .meta({
44
+ description: "Proof of possession of the private key comprising of `${timestamp}.${fingerprint}.${signature}`. The signature is a BASE64 encoded armored SSHSIG signature, made with the `request-proof-of-possession@com.github.serverless-ssh-ca.andrewheberle` namespace, of `${timestamp}.${fingerprint}.${METHOD} ${path}` where `${METHOD} ${path}` is the request the proof is for, for example `DELETE /api/v4/user/certificates/12345678901234567890`. The timestamp is in milliseconds since the Unix epoch.",
45
+ example: "1765696780805.SHA256:79mq0wtpQMTvS4+Of8VzLN0qmYWNUyTXYmqKwhEgSLs.LS0tLS1CRUdJTiBTU0ggU0lHTkFUVVJFLS0tLS0K...",
46
+ })
47
+ .transform((val, ctx) => transformProofOfPossession(env, val, ctx))
48
+ )
49
+
50
+ /**
51
+ * Headers for a request authorised by a proof of possession alone
52
+ */
53
+ export const createProofHeaderSchema = (env: SshCaBindings) => (
54
+ z.object({
55
+ [ProofHeader]: createProofSchema(env),
56
+ })
57
+ )
58
+
59
+ /**
60
+ * Headers for a request authenticated with an access token and authorised by
61
+ * a proof of possession
62
+ */
63
+ export const createAuthenticatedHeaderSchema = (env: SshCaBindings) => (
64
+ z.object({
65
+ "Authorization": createAccessTokenSchema(env),
66
+ [ProofHeader]: createProofSchema(env),
67
+ })
68
+ )
69
+
70
+ export const serialParamSchema = z.object({
71
+ // a string as JSON cannot carry a bigint and numbers lose precision
72
+ // above 2^53, and without leading zeros so the path a proof is made for
73
+ // has only one form
74
+ serial: z.string()
75
+ .regex(/^(0|[1-9]\d*)$/)
76
+ .transform(BigInt)
77
+ .refine((v) => v <= 0xFFFFFFFFFFFFFFFFn, { message: "serial must fit in an unsigned 64-bit integer" })
78
+ .meta({ description: "Serial number of the certificate", example: "12345678901234567890" }),
79
+ })
80
+
81
+ const certificateResponse = {
82
+ ...contentJson(z.object({
83
+ certificate: certificate,
84
+ })
85
+ .openapi("Certificate Response")
86
+ )
87
+ }
88
+
89
+ export const createCaPublicKeyEndpointSchema = () => ({
90
+ ...CaPublicKeyEndpointSchema,
91
+ operationId: "get_ca_public_key",
92
+ })
93
+
94
+ export const createGetRevocationListEndpointSchema = (certificateType: CertificateType) => ({
95
+ ...createRevocationListEndpointSchema(certificateType),
96
+ operationId: `get_${certificateTypeName(certificateType)}_revocation_list`,
97
+ })
98
+
99
+ export const issueUserCertificateEndpointBodySchema = (env: SshCaBindings) => (
100
+ z.object({
101
+ public_key: subjectPublicKey,
102
+ identity: createIdentityTokenSchema(env),
103
+ extensions: z.array(z.string())
104
+ .default(split(env.SSH_CERTIFICATE_EXTENSIONS))
105
+ .meta({ description: "Extensions to include in the issued SSH certificate" }),
106
+ lifetime: z.int()
107
+ .min(seconds("5 minutes"))
108
+ .max(seconds(env.SSH_CERTIFICATE_LIFETIME))
109
+ .default(seconds(env.SSH_CERTIFICATE_LIFETIME))
110
+ .meta({ description: "Lifetime of issued SSH certificate" }),
111
+ })
112
+ .openapi("User Certificate Issue Request")
113
+ )
114
+
115
+ export const createIssueUserCertificateEndpointSchema = (env: SshCaBindings) => ({
116
+ tags: [certificateTypeTag(CertificateType.User)],
117
+ operationId: "issue_user_certificate",
118
+ summary: "Request a user certificate",
119
+ description: `Issues a signed SSH user certificate for the provided public key. The request must be authenticated with an access token and include an identity token from the OIDC IdP for the same subject, along with a proof of possession of the private key for \`public_key\` in the \`${ProofHeader}\` header made for this request. Principals on the issued certificate are derived from the identity token.`,
120
+ security: [{ oidcAuth: [] }],
121
+ request: {
122
+ headers: createAuthenticatedHeaderSchema(env),
123
+ body: contentJson(issueUserCertificateEndpointBodySchema(env)),
124
+ },
125
+ responses: {
126
+ "200": {
127
+ description: "SSH User Certificate issued successfully",
128
+ ...certificateResponse,
129
+ },
130
+ ...UnauthorizedException.schema(),
131
+ ...ForbiddenException.schema(),
132
+ ...InputValidationException.schema(),
133
+ ...UnprocessableEntityException.schema(),
134
+ ...InternalServerErrorException.schema(),
135
+ ...TooManyRequestsException.schema(),
136
+ }
137
+ })
138
+
139
+ export const issueHostCertificateEndpointBodySchema = (env: SshCaBindings) => (
140
+ z.object({
141
+ public_key: subjectPublicKey,
142
+ identity: createIdentityTokenSchema(env),
143
+ principals: z.array(z.string().superRefine(refineHostPrincipal)).min(1)
144
+ .meta({ description: "List of principals to include on the issued certificate" }),
145
+ lifetime: z.int()
146
+ .min(seconds("24 hours"))
147
+ .max(seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
148
+ .default(seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
149
+ .meta({ description: "Lifetime of issued Host SSH certificate" }),
150
+ })
151
+ .openapi("Host Certificate Issue Request")
152
+ )
153
+
154
+ export const createIssueHostCertificateEndpointSchema = (env: SshCaBindings) => ({
155
+ tags: [certificateTypeTag(CertificateType.Host)],
156
+ operationId: "issue_host_certificate",
157
+ summary: "Request a host certificate",
158
+ description: `Issues a signed SSH host certificate for the provided public key and principals. The request must be authenticated with an access token and include an identity token from the OIDC IdP for the same subject, along with a proof of possession of the private key for \`public_key\` in the \`${ProofHeader}\` header made for this request. The user must be permitted to request host certificates by email address or role.`,
159
+ security: [{ oidcAuth: [] }],
160
+ request: {
161
+ headers: createAuthenticatedHeaderSchema(env),
162
+ body: contentJson(issueHostCertificateEndpointBodySchema(env)),
163
+ },
164
+ responses: {
165
+ "200": {
166
+ description: "SSH Host Certificate issued successfully",
167
+ ...certificateResponse,
168
+ },
169
+ ...UnauthorizedException.schema(),
170
+ ...ForbiddenException.schema(),
171
+ ...InputValidationException.schema(),
172
+ ...UnprocessableEntityException.schema(),
173
+ ...InternalServerErrorException.schema(),
174
+ ...TooManyRequestsException.schema(),
175
+ }
176
+ })
177
+
178
+ export const renewHostCertificateEndpointBodySchema = (env: SshCaBindings) => (
179
+ z.object({
180
+ certificate: openapiStringByte
181
+ .transform(transformCertificate)
182
+ .meta({ description: "SSH certificate to renew, which must have the serial number in the path" }),
183
+ lifetime: z.int()
184
+ .min(seconds("24 hours"))
185
+ .max(seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
186
+ .default(seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
187
+ .meta({ description: "Lifetime of renewed Host SSH certificate" }),
188
+ })
189
+ .superRefine((val, ctx) => refineRenewableCertificate(env, isRevoked, val, ctx))
190
+ .openapi("Host Certificate Renewal Request")
191
+ )
192
+
193
+ export const createRenewHostCertificateEndpointSchema = (env: SshCaBindings) => ({
194
+ tags: [certificateTypeTag(CertificateType.Host)],
195
+ operationId: "renew_host_certificate",
196
+ summary: "Renew a host 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.`,
198
+ request: {
199
+ params: serialParamSchema,
200
+ headers: createProofHeaderSchema(env),
201
+ body: contentJson(renewHostCertificateEndpointBodySchema(env)),
202
+ },
203
+ responses: {
204
+ "200": {
205
+ description: "SSH Host Certificate renewed successfully",
206
+ ...certificateResponse,
207
+ },
208
+ ...UnauthorizedException.schema(),
209
+ ...ForbiddenException.schema(),
210
+ ...InputValidationException.schema(),
211
+ ...UnprocessableEntityException.schema(),
212
+ ...InternalServerErrorException.schema(),
213
+ ...TooManyRequestsException.schema(),
214
+ }
215
+ })
216
+
217
+ export const createRevokeCertificateEndpointSchema = (env: SshCaBindings, certificateType: CertificateType) => ({
218
+ tags: [certificateTypeTag(certificateType)],
219
+ operationId: `revoke_${certificateTypeName(certificateType)}_certificate`,
220
+ summary: `Revoke a ${certificateTypeName(certificateType)} certificate`,
221
+ description: `Revokes a ${certificateTypeName(certificateType)} certificate issued by this CA and adds it to the ${certificateTypeName(certificateType)} Key Revocation List. The \`${ProofHeader}\` header must contain a proof of possession of the private key for the public key that the certificate was issued for, made for this request. A certificate issued for a different public key is reported as not found.`,
222
+ request: {
223
+ params: serialParamSchema,
224
+ headers: createProofHeaderSchema(env),
225
+ },
226
+ responses: {
227
+ "200": {
228
+ description: `SSH ${certificateTypeName(certificateType)} certificate revoked successfully`,
229
+ ...contentJson(z.object({
230
+ revoked_at: z.iso.datetime(),
231
+ })
232
+ .openapi("Revocation Response")
233
+ ),
234
+ },
235
+ ...InputValidationException.schema(),
236
+ ...NotFoundException.schema(),
237
+ ...ConflictException.schema(),
238
+ ...InternalServerErrorException.schema(),
239
+ ...TooManyRequestsException.schema(),
240
+ }
241
+ })