@andrewheberle/serverless-ssh-ca 0.21.0 → 0.23.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.
- package/README.md +55 -1
- package/dist/api/operations.d.ts +49 -0
- package/dist/api/operations.d.ts.map +1 -0
- package/dist/api/operations.js +175 -0
- package/dist/api/operations.js.map +1 -0
- package/dist/api/schema.d.ts +71 -0
- package/dist/api/schema.d.ts.map +1 -0
- package/dist/api/schema.js +107 -0
- package/dist/api/schema.js.map +1 -0
- package/dist/api/v3/index.d.ts.map +1 -1
- package/dist/api/v3/index.js +25 -212
- package/dist/api/v3/index.js.map +1 -1
- package/dist/api/v3/schema.d.ts +30 -74
- package/dist/api/v3/schema.d.ts.map +1 -1
- package/dist/api/v3/schema.js +18 -73
- package/dist/api/v3/schema.js.map +1 -1
- package/dist/api/v4/index.d.ts +8 -0
- package/dist/api/v4/index.d.ts.map +1 -0
- package/dist/api/v4/index.js +155 -0
- package/dist/api/v4/index.js.map +1 -0
- package/dist/api/v4/schema.d.ts +268 -0
- package/dist/api/v4/schema.d.ts.map +1 -0
- package/dist/api/v4/schema.js +182 -0
- package/dist/api/v4/schema.js.map +1 -0
- package/dist/certificate.d.ts +9 -6
- package/dist/certificate.d.ts.map +1 -1
- package/dist/certificate.js +55 -99
- package/dist/certificate.js.map +1 -1
- package/dist/db/index.d.ts +32 -24
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +101 -28
- package/dist/db/index.js.map +1 -1
- package/dist/db/migrations/0002_krl_version.d.ts +3 -0
- package/dist/db/migrations/0002_krl_version.d.ts.map +1 -0
- package/dist/db/migrations/0002_krl_version.js +15 -0
- package/dist/db/migrations/0002_krl_version.js.map +1 -0
- package/dist/db/migrations/index.d.ts.map +1 -1
- package/dist/db/migrations/index.js +3 -1
- package/dist/db/migrations/index.js.map +1 -1
- package/dist/db/types.d.ts +37 -0
- package/dist/db/types.d.ts.map +1 -0
- package/dist/db/types.js +13 -0
- package/dist/db/types.js.map +1 -0
- package/dist/krl.d.ts +9 -1
- package/dist/krl.d.ts.map +1 -1
- package/dist/krl.js +22 -162
- package/dist/krl.js.map +1 -1
- package/dist/proof.d.ts +36 -7
- package/dist/proof.d.ts.map +1 -1
- package/dist/proof.js +53 -21
- package/dist/proof.js.map +1 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +3 -1
- package/dist/router.js.map +1 -1
- package/dist/ssh/certificate.d.ts +93 -0
- package/dist/ssh/certificate.d.ts.map +1 -0
- package/dist/ssh/certificate.js +178 -0
- package/dist/ssh/certificate.js.map +1 -0
- package/dist/ssh/encoding.d.ts +41 -0
- package/dist/ssh/encoding.d.ts.map +1 -0
- package/dist/ssh/encoding.js +70 -0
- package/dist/ssh/encoding.js.map +1 -0
- package/dist/ssh/keys.d.ts +80 -0
- package/dist/ssh/keys.d.ts.map +1 -0
- package/dist/ssh/keys.js +249 -0
- package/dist/ssh/keys.js.map +1 -0
- package/dist/ssh/signer.d.ts +35 -0
- package/dist/ssh/signer.d.ts.map +1 -0
- package/dist/ssh/signer.js +99 -0
- package/dist/ssh/signer.js.map +1 -0
- package/dist/types.d.ts +12 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +40 -21
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +87 -54
- package/dist/utils.js.map +1 -1
- package/openapi.json +1960 -82
- package/package.json +5 -17
- package/src/api/operations.ts +211 -0
- package/src/api/schema.ts +137 -0
- package/src/api/v3/index.ts +32 -259
- package/src/api/v3/schema.ts +34 -96
- package/src/api/v4/index.ts +265 -0
- package/src/api/v4/schema.ts +235 -0
- package/src/certificate.ts +62 -123
- package/src/db/index.ts +113 -49
- package/src/db/migrations/0002_krl_version.ts +16 -0
- package/src/db/migrations/index.ts +3 -1
- package/src/db/types.ts +40 -0
- package/src/krl.ts +32 -194
- package/src/proof.ts +65 -31
- package/src/router.ts +3 -1
- package/src/ssh/certificate.ts +236 -0
- package/src/ssh/encoding.ts +81 -0
- package/src/ssh/keys.ts +294 -0
- package/src/ssh/signer.ts +129 -0
- package/src/types.ts +12 -18
- package/src/utils.ts +99 -69
|
@@ -0,0 +1,265 @@
|
|
|
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
|
+
)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const RevokeCertificateEndpoint = (env: SshCaBindings, certificateType: CertificateType) => {
|
|
231
|
+
return class extends OpenAPIRoute {
|
|
232
|
+
override schema = createRevokeCertificateEndpointSchema(env, certificateType)
|
|
233
|
+
|
|
234
|
+
override async handle(c: AppContext) {
|
|
235
|
+
const data = await this.getValidatedData<typeof this.schema>()
|
|
236
|
+
const serial = data.params.serial
|
|
237
|
+
const proof = data.headers[ProofHeader]
|
|
238
|
+
|
|
239
|
+
const l = logger(env).with(
|
|
240
|
+
...group("request", "type", certificateTypeName(certificateType), "action", "revoke", "serial", serial),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
// the key the proof was made with is the key the certificate must
|
|
244
|
+
// have been issued for
|
|
245
|
+
await verifyProof(l, proof, proof.signaturePubkey, "DELETE", `/${certificateTypeName(certificateType)}/certificates/${serial}`)
|
|
246
|
+
|
|
247
|
+
return await revoke(c, l, serial, proof.signaturePubkey, certificateType)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export const createApi = (env: SshCaBindings) => {
|
|
253
|
+
const api = fromHono(new Hono())
|
|
254
|
+
|
|
255
|
+
api.get("/ca", CaPublicKeyEndpoint(env))
|
|
256
|
+
api.post("/user/certificates", IssueUserCertificateEndpoint(env))
|
|
257
|
+
api.delete("/user/certificates/:serial", RevokeCertificateEndpoint(env, CertificateType.User))
|
|
258
|
+
api.get("/user/krl", RevocationListEndpoint(env, CertificateType.User))
|
|
259
|
+
api.post("/host/certificates", IssueHostCertificateEndpoint(env))
|
|
260
|
+
api.post("/host/certificates/:serial/renew", RenewHostCertificateEndpoint(env))
|
|
261
|
+
api.delete("/host/certificates/:serial", RevokeCertificateEndpoint(env, CertificateType.Host))
|
|
262
|
+
api.get("/host/krl", RevocationListEndpoint(env, CertificateType.Host))
|
|
263
|
+
|
|
264
|
+
return api
|
|
265
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import z from "zod"
|
|
2
|
+
import type { SshCaBindings } from "../../types.js"
|
|
3
|
+
import {
|
|
4
|
+
refineRenewableCertificate,
|
|
5
|
+
split,
|
|
6
|
+
transformCertificate,
|
|
7
|
+
transformProofOfPossession,
|
|
8
|
+
} from "../../utils.js"
|
|
9
|
+
import {
|
|
10
|
+
ConflictException,
|
|
11
|
+
contentJson,
|
|
12
|
+
InputValidationException,
|
|
13
|
+
InternalServerErrorException,
|
|
14
|
+
NotFoundException,
|
|
15
|
+
UnauthorizedException,
|
|
16
|
+
ForbiddenException,
|
|
17
|
+
UnprocessableEntityException,
|
|
18
|
+
} from "chanfana"
|
|
19
|
+
import { seconds } from "itty-time"
|
|
20
|
+
import { isRevoked } from "../../db/index.js"
|
|
21
|
+
import { CertificateType } from "../../db/types.js"
|
|
22
|
+
import {
|
|
23
|
+
CaPublicKeyEndpointSchema,
|
|
24
|
+
certificate,
|
|
25
|
+
certificateTypeName,
|
|
26
|
+
certificateTypeTag,
|
|
27
|
+
createAccessTokenSchema,
|
|
28
|
+
createIdentityTokenSchema,
|
|
29
|
+
createRevocationListEndpointSchema,
|
|
30
|
+
openapiStringByte,
|
|
31
|
+
publicKey,
|
|
32
|
+
} from "../schema.js"
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The name of the header carrying the proof of possession of the private key
|
|
36
|
+
*/
|
|
37
|
+
export const ProofHeader = "Proof-Of-Possession"
|
|
38
|
+
|
|
39
|
+
export const createProofSchema = (env: SshCaBindings) => (
|
|
40
|
+
z.string()
|
|
41
|
+
.meta({
|
|
42
|
+
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.",
|
|
43
|
+
example: "1765696780805.SHA256:79mq0wtpQMTvS4+Of8VzLN0qmYWNUyTXYmqKwhEgSLs.LS0tLS1CRUdJTiBTU0ggU0lHTkFUVVJFLS0tLS0K...",
|
|
44
|
+
})
|
|
45
|
+
.transform((val, ctx) => transformProofOfPossession(env, val, ctx))
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Headers for a request authorised by a proof of possession alone
|
|
50
|
+
*/
|
|
51
|
+
export const createProofHeaderSchema = (env: SshCaBindings) => (
|
|
52
|
+
z.object({
|
|
53
|
+
[ProofHeader]: createProofSchema(env),
|
|
54
|
+
})
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Headers for a request authenticated with an access token and authorised by
|
|
59
|
+
* a proof of possession
|
|
60
|
+
*/
|
|
61
|
+
export const createAuthenticatedHeaderSchema = (env: SshCaBindings) => (
|
|
62
|
+
z.object({
|
|
63
|
+
"Authorization": createAccessTokenSchema(env),
|
|
64
|
+
[ProofHeader]: createProofSchema(env),
|
|
65
|
+
})
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
export const serialParamSchema = z.object({
|
|
69
|
+
// a string as JSON cannot carry a bigint and numbers lose precision
|
|
70
|
+
// above 2^53, and without leading zeros so the path a proof is made for
|
|
71
|
+
// has only one form
|
|
72
|
+
serial: z.string()
|
|
73
|
+
.regex(/^(0|[1-9]\d*)$/)
|
|
74
|
+
.transform(BigInt)
|
|
75
|
+
.refine((v) => v <= 0xFFFFFFFFFFFFFFFFn, { message: "serial must fit in an unsigned 64-bit integer" })
|
|
76
|
+
.meta({ description: "Serial number of the certificate", example: "12345678901234567890" }),
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const certificateResponse = {
|
|
80
|
+
...contentJson(z.object({
|
|
81
|
+
certificate: certificate,
|
|
82
|
+
})
|
|
83
|
+
.openapi("Certificate Response")
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const createCaPublicKeyEndpointSchema = () => ({
|
|
88
|
+
...CaPublicKeyEndpointSchema,
|
|
89
|
+
operationId: "get_ca_public_key",
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
export const createGetRevocationListEndpointSchema = (certificateType: CertificateType) => ({
|
|
93
|
+
...createRevocationListEndpointSchema(certificateType),
|
|
94
|
+
operationId: `get_${certificateTypeName(certificateType)}_revocation_list`,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
export const issueUserCertificateEndpointBodySchema = (env: SshCaBindings) => (
|
|
98
|
+
z.object({
|
|
99
|
+
public_key: publicKey,
|
|
100
|
+
identity: createIdentityTokenSchema(env),
|
|
101
|
+
extensions: z.array(z.string())
|
|
102
|
+
.default(split(env.SSH_CERTIFICATE_EXTENSIONS))
|
|
103
|
+
.meta({ description: "Extensions to include in the issued SSH certificate" }),
|
|
104
|
+
lifetime: z.int()
|
|
105
|
+
.min(seconds("5 minutes"))
|
|
106
|
+
.max(seconds(env.SSH_CERTIFICATE_LIFETIME))
|
|
107
|
+
.default(seconds(env.SSH_CERTIFICATE_LIFETIME))
|
|
108
|
+
.meta({ description: "Lifetime of issued SSH certificate" }),
|
|
109
|
+
})
|
|
110
|
+
.openapi("User Certificate Issue Request")
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
export const createIssueUserCertificateEndpointSchema = (env: SshCaBindings) => ({
|
|
114
|
+
tags: [certificateTypeTag(CertificateType.User)],
|
|
115
|
+
operationId: "issue_user_certificate",
|
|
116
|
+
summary: "Request a user certificate",
|
|
117
|
+
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.`,
|
|
118
|
+
security: [{ oidcAuth: [] }],
|
|
119
|
+
request: {
|
|
120
|
+
headers: createAuthenticatedHeaderSchema(env),
|
|
121
|
+
body: contentJson(issueUserCertificateEndpointBodySchema(env)),
|
|
122
|
+
},
|
|
123
|
+
responses: {
|
|
124
|
+
"200": {
|
|
125
|
+
description: "SSH User Certificate issued successfully",
|
|
126
|
+
...certificateResponse,
|
|
127
|
+
},
|
|
128
|
+
...UnauthorizedException.schema(),
|
|
129
|
+
...ForbiddenException.schema(),
|
|
130
|
+
...InputValidationException.schema(),
|
|
131
|
+
...UnprocessableEntityException.schema(),
|
|
132
|
+
...InternalServerErrorException.schema(),
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
export const issueHostCertificateEndpointBodySchema = (env: SshCaBindings) => (
|
|
137
|
+
z.object({
|
|
138
|
+
public_key: publicKey,
|
|
139
|
+
identity: createIdentityTokenSchema(env),
|
|
140
|
+
principals: z.array(z.string()).min(1)
|
|
141
|
+
.meta({ description: "List of principals to include on the issued certificate" }),
|
|
142
|
+
lifetime: z.int()
|
|
143
|
+
.min(seconds("24 hours"))
|
|
144
|
+
.max(seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
|
|
145
|
+
.default(seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
|
|
146
|
+
.meta({ description: "Lifetime of issued Host SSH certificate" }),
|
|
147
|
+
})
|
|
148
|
+
.openapi("Host Certificate Issue Request")
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
export const createIssueHostCertificateEndpointSchema = (env: SshCaBindings) => ({
|
|
152
|
+
tags: [certificateTypeTag(CertificateType.Host)],
|
|
153
|
+
operationId: "issue_host_certificate",
|
|
154
|
+
summary: "Request a host certificate",
|
|
155
|
+
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.`,
|
|
156
|
+
security: [{ oidcAuth: [] }],
|
|
157
|
+
request: {
|
|
158
|
+
headers: createAuthenticatedHeaderSchema(env),
|
|
159
|
+
body: contentJson(issueHostCertificateEndpointBodySchema(env)),
|
|
160
|
+
},
|
|
161
|
+
responses: {
|
|
162
|
+
"200": {
|
|
163
|
+
description: "SSH Host Certificate issued successfully",
|
|
164
|
+
...certificateResponse,
|
|
165
|
+
},
|
|
166
|
+
...UnauthorizedException.schema(),
|
|
167
|
+
...ForbiddenException.schema(),
|
|
168
|
+
...InputValidationException.schema(),
|
|
169
|
+
...UnprocessableEntityException.schema(),
|
|
170
|
+
...InternalServerErrorException.schema(),
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
export const renewHostCertificateEndpointBodySchema = (env: SshCaBindings) => (
|
|
175
|
+
z.object({
|
|
176
|
+
certificate: openapiStringByte
|
|
177
|
+
.transform(transformCertificate)
|
|
178
|
+
.meta({ description: "SSH certificate to renew, which must have the serial number in the path" }),
|
|
179
|
+
lifetime: z.int()
|
|
180
|
+
.min(seconds("24 hours"))
|
|
181
|
+
.max(seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
|
|
182
|
+
.default(seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
|
|
183
|
+
.meta({ description: "Lifetime of renewed Host SSH certificate" }),
|
|
184
|
+
})
|
|
185
|
+
.superRefine((val, ctx) => refineRenewableCertificate(env, isRevoked, val, ctx))
|
|
186
|
+
.openapi("Host Certificate Renewal Request")
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
export const createRenewHostCertificateEndpointSchema = (env: SshCaBindings) => ({
|
|
190
|
+
tags: [certificateTypeTag(CertificateType.Host)],
|
|
191
|
+
operationId: "renew_host_certificate",
|
|
192
|
+
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.`,
|
|
194
|
+
request: {
|
|
195
|
+
params: serialParamSchema,
|
|
196
|
+
headers: createProofHeaderSchema(env),
|
|
197
|
+
body: contentJson(renewHostCertificateEndpointBodySchema(env)),
|
|
198
|
+
},
|
|
199
|
+
responses: {
|
|
200
|
+
"200": {
|
|
201
|
+
description: "SSH Host Certificate renewed successfully",
|
|
202
|
+
...certificateResponse,
|
|
203
|
+
},
|
|
204
|
+
...UnauthorizedException.schema(),
|
|
205
|
+
...ForbiddenException.schema(),
|
|
206
|
+
...InputValidationException.schema(),
|
|
207
|
+
...UnprocessableEntityException.schema(),
|
|
208
|
+
...InternalServerErrorException.schema(),
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
export const createRevokeCertificateEndpointSchema = (env: SshCaBindings, certificateType: CertificateType) => ({
|
|
213
|
+
tags: [certificateTypeTag(certificateType)],
|
|
214
|
+
operationId: `revoke_${certificateTypeName(certificateType)}_certificate`,
|
|
215
|
+
summary: `Revoke a ${certificateTypeName(certificateType)} certificate`,
|
|
216
|
+
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.`,
|
|
217
|
+
request: {
|
|
218
|
+
params: serialParamSchema,
|
|
219
|
+
headers: createProofHeaderSchema(env),
|
|
220
|
+
},
|
|
221
|
+
responses: {
|
|
222
|
+
"200": {
|
|
223
|
+
description: `SSH ${certificateTypeName(certificateType)} certificate revoked successfully`,
|
|
224
|
+
...contentJson(z.object({
|
|
225
|
+
revoked_at: z.iso.datetime(),
|
|
226
|
+
})
|
|
227
|
+
.openapi("Revocation Response")
|
|
228
|
+
),
|
|
229
|
+
},
|
|
230
|
+
...InputValidationException.schema(),
|
|
231
|
+
...NotFoundException.schema(),
|
|
232
|
+
...ConflictException.schema(),
|
|
233
|
+
...InternalServerErrorException.schema(),
|
|
234
|
+
}
|
|
235
|
+
})
|