@andrewheberle/serverless-ssh-ca 0.17.12 → 0.17.15
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 +6 -17
- package/dist/api/v3/index.d.ts +1 -0
- package/dist/api/v3/index.d.ts.map +1 -0
- package/dist/api/v3/index.js +1 -0
- package/dist/api/v3/index.js.map +1 -0
- package/dist/api/v3/schema.d.ts +9 -0
- package/dist/api/v3/schema.d.ts.map +1 -0
- package/dist/api/v3/schema.js +4 -4
- package/dist/api/v3/schema.js.map +1 -0
- package/dist/certificate.d.ts +1 -0
- package/dist/certificate.d.ts.map +1 -0
- package/dist/certificate.js +1 -0
- package/dist/certificate.js.map +1 -0
- package/dist/{db.d.ts → db/index.d.ts} +6 -6
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +151 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/migrations/0001_initial_schema.d.ts +3 -0
- package/dist/db/migrations/0001_initial_schema.d.ts.map +1 -0
- package/dist/db/migrations/0001_initial_schema.js +24 -0
- package/dist/db/migrations/0001_initial_schema.js.map +1 -0
- package/dist/db/migrations/index.d.ts +3 -0
- package/dist/db/migrations/index.d.ts.map +1 -0
- package/dist/db/migrations/index.js +5 -0
- package/dist/db/migrations/index.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -0
- package/dist/krl.d.ts +1 -0
- package/dist/krl.d.ts.map +1 -0
- package/dist/krl.js +1 -0
- package/dist/krl.js.map +1 -0
- package/dist/logger.d.ts +1 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +1 -0
- package/dist/logger.js.map +1 -0
- package/dist/proof.d.ts +2 -0
- package/dist/proof.d.ts.map +1 -0
- package/dist/proof.js +2 -1
- package/dist/proof.js.map +1 -0
- package/dist/router.d.ts +1 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +1 -0
- package/dist/router.js.map +1 -0
- package/dist/sshsig/armor.d.ts +1 -0
- package/dist/sshsig/armor.d.ts.map +1 -0
- package/dist/sshsig/armor.js +1 -0
- package/dist/sshsig/armor.js.map +1 -0
- package/dist/sshsig/formats.d.ts +1 -0
- package/dist/sshsig/formats.d.ts.map +1 -0
- package/dist/sshsig/formats.js +1 -0
- package/dist/sshsig/formats.js.map +1 -0
- package/dist/sshsig/index.d.ts +1 -0
- package/dist/sshsig/index.d.ts.map +1 -0
- package/dist/sshsig/index.js +1 -0
- package/dist/sshsig/index.js.map +1 -0
- package/dist/sshsig/reader.d.ts +1 -0
- package/dist/sshsig/reader.d.ts.map +1 -0
- package/dist/sshsig/reader.js +1 -0
- package/dist/sshsig/reader.js.map +1 -0
- package/dist/sshsig/sig.d.ts +1 -0
- package/dist/sshsig/sig.d.ts.map +1 -0
- package/dist/sshsig/sig.js +1 -0
- package/dist/sshsig/sig.js.map +1 -0
- package/dist/sshsig/sig_parser.d.ts +1 -0
- package/dist/sshsig/sig_parser.d.ts.map +1 -0
- package/dist/sshsig/sig_parser.js +1 -0
- package/dist/sshsig/sig_parser.js.map +1 -0
- package/dist/sshsig/verifier.d.ts +1 -0
- package/dist/sshsig/verifier.d.ts.map +1 -0
- package/dist/sshsig/verifier.js +1 -0
- package/dist/sshsig/verifier.js.map +1 -0
- package/dist/sshsig/writer.d.ts +1 -0
- package/dist/sshsig/writer.d.ts.map +1 -0
- package/dist/sshsig/writer.js +1 -0
- package/dist/sshsig/writer.js.map +1 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +1 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +4 -0
- package/dist/utils.js.map +1 -0
- package/dist/verify.d.ts +1 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +1 -0
- package/dist/verify.js.map +1 -0
- package/package.json +16 -8
- package/src/api/v3/index.ts +419 -0
- package/src/api/v3/schema.ts +265 -0
- package/src/certificate.ts +214 -0
- package/src/db/index.ts +194 -0
- package/src/db/migrations/0001_initial_schema.ts +25 -0
- package/src/db/migrations/index.ts +6 -0
- package/src/index.ts +18 -0
- package/src/krl.ts +414 -0
- package/src/logger.ts +6 -0
- package/src/proof.ts +187 -0
- package/src/router.ts +35 -0
- package/src/sshsig/armor.ts +24 -0
- package/src/sshsig/formats.ts +226 -0
- package/src/sshsig/index.ts +47 -0
- package/src/sshsig/reader.ts +59 -0
- package/src/sshsig/sig.ts +17 -0
- package/src/sshsig/sig_parser.ts +90 -0
- package/src/sshsig/verifier.ts +85 -0
- package/src/sshsig/writer.ts +56 -0
- package/src/types.ts +49 -0
- package/src/utils.ts +414 -0
- package/src/verify.ts +25 -0
- package/LICENSE +0 -201
- package/dist/db.js +0 -111
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { group } from "@andrewheberle/ts-slog"
|
|
2
|
+
import {
|
|
3
|
+
ApiException,
|
|
4
|
+
ConflictException,
|
|
5
|
+
ForbiddenException,
|
|
6
|
+
fromHono,
|
|
7
|
+
InternalServerErrorException,
|
|
8
|
+
NotFoundException,
|
|
9
|
+
OpenAPIRoute,
|
|
10
|
+
UnauthorizedException,
|
|
11
|
+
UnprocessableEntityException,
|
|
12
|
+
} from "chanfana"
|
|
13
|
+
import { Hono } from "hono"
|
|
14
|
+
import { AppContext } from "../../router"
|
|
15
|
+
import { CertificateSignerResponse } from "../../types"
|
|
16
|
+
import {
|
|
17
|
+
BadIssuerError,
|
|
18
|
+
CreateCertificateOptions,
|
|
19
|
+
CreateHostCertificateOptions,
|
|
20
|
+
createSignedCertificate,
|
|
21
|
+
createSignedHostCertificate,
|
|
22
|
+
|
|
23
|
+
} from "../../certificate"
|
|
24
|
+
import {
|
|
25
|
+
getPublic,
|
|
26
|
+
split,
|
|
27
|
+
} from "../../utils"
|
|
28
|
+
import {
|
|
29
|
+
KeyParseError,
|
|
30
|
+
parsePrivateKey,
|
|
31
|
+
} from "sshpk"
|
|
32
|
+
import {
|
|
33
|
+
CertificateType,
|
|
34
|
+
getRevocationList,
|
|
35
|
+
recordCertificate,
|
|
36
|
+
RevocationStatus,
|
|
37
|
+
revocationStatus,
|
|
38
|
+
revokeCertificate,
|
|
39
|
+
} from "../../db"
|
|
40
|
+
import { KRLBuilder } from "../../krl"
|
|
41
|
+
import {
|
|
42
|
+
CaPublicKeyEndpointSchema,
|
|
43
|
+
createUserCertificateRequestEndpointSchema,
|
|
44
|
+
createRevocationListEndpointSchema,
|
|
45
|
+
createHostCertificateRequestEndpointSchema,
|
|
46
|
+
createHostCertificateRenewEndpointSchema,
|
|
47
|
+
createRevokeCertificateEndpointSchema,
|
|
48
|
+
} from "./schema"
|
|
49
|
+
import { logger } from "../../logger"
|
|
50
|
+
import type { SshCaBindings } from "../../types"
|
|
51
|
+
|
|
52
|
+
const CaPublicKeyEndpoint = (env: SshCaBindings) => {
|
|
53
|
+
return class extends OpenAPIRoute {
|
|
54
|
+
override schema = CaPublicKeyEndpointSchema
|
|
55
|
+
|
|
56
|
+
override async handle(c: AppContext) {
|
|
57
|
+
const l = logger(env).with(
|
|
58
|
+
...group("request", "action", "ca"),
|
|
59
|
+
)
|
|
60
|
+
try {
|
|
61
|
+
const pub = await getPublic(c.env)
|
|
62
|
+
|
|
63
|
+
return c.text(`${pub.toString("ssh").trim()}\n`)
|
|
64
|
+
} catch (err) {
|
|
65
|
+
switch (true) {
|
|
66
|
+
case (err instanceof KeyParseError):
|
|
67
|
+
l.error("error parsing private key")
|
|
68
|
+
throw new InternalServerErrorException("error parsing private key")
|
|
69
|
+
case (err instanceof ApiException):
|
|
70
|
+
// re-throw any exisiting chanfana error
|
|
71
|
+
throw err
|
|
72
|
+
default:
|
|
73
|
+
// otherwise throw as InternalServerErrorException
|
|
74
|
+
l.error("unhandled error", "error", err)
|
|
75
|
+
throw new InternalServerErrorException("internal server error")
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const UserCertificateRequestEndpoint = (env: SshCaBindings) => {
|
|
83
|
+
return class extends OpenAPIRoute {
|
|
84
|
+
override schema = createUserCertificateRequestEndpointSchema(env)
|
|
85
|
+
|
|
86
|
+
override async handle(c: AppContext) {
|
|
87
|
+
const data = await this.getValidatedData<typeof this.schema>()
|
|
88
|
+
|
|
89
|
+
const l = logger(env).with(
|
|
90
|
+
...group("request",
|
|
91
|
+
"type", "user",
|
|
92
|
+
"action", "request",
|
|
93
|
+
),
|
|
94
|
+
...group("auth_token",
|
|
95
|
+
"email", data.headers.Authorization.email,
|
|
96
|
+
"sub", data.headers.Authorization.sub,
|
|
97
|
+
),
|
|
98
|
+
...group("id_token",
|
|
99
|
+
"sub", data.body.identity.sub,
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if (data.body.identity.sub !== data.headers["Authorization"].sub) {
|
|
104
|
+
l.error("token subjects did not match")
|
|
105
|
+
throw new ForbiddenException("Possible token substitution as subjects for authentication and identity tokens did not match")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const opts: CreateCertificateOptions = {
|
|
109
|
+
lifetime: data.body.lifetime,
|
|
110
|
+
principals: data.body.identity.principals,
|
|
111
|
+
extensions: data.body.extensions,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const certificate = await createSignedCertificate(c.env, data.headers.Authorization.email, data.body.public_key, opts)
|
|
116
|
+
const response: CertificateSignerResponse = {
|
|
117
|
+
certificate: btoa(certificate.toString("openssh"))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
await recordCertificate(c.env, certificate, data.headers.Authorization.email)
|
|
122
|
+
} catch (err) {
|
|
123
|
+
l.error("there was a problem adding issued certificate to database", "error", err)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
l.info("completed issuing certificate",
|
|
127
|
+
...group(
|
|
128
|
+
"certificate", "principals",
|
|
129
|
+
certificate.subjects.map(v => v.toString()).join(","), "serial", certificate.serial.readBigUInt64BE(0),
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return c.json(response)
|
|
134
|
+
} catch (err) {
|
|
135
|
+
switch (true) {
|
|
136
|
+
case (err instanceof ApiException):
|
|
137
|
+
// re-throw any exisiting chanfana error
|
|
138
|
+
throw err
|
|
139
|
+
default:
|
|
140
|
+
// otherwise throw as InternalServerErrorException
|
|
141
|
+
l.error("unhandled error", "error", err)
|
|
142
|
+
throw new InternalServerErrorException("internal server error")
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const RevocationListEndpoint = (env: SshCaBindings) => {
|
|
150
|
+
return class extends OpenAPIRoute {
|
|
151
|
+
override schema = createRevocationListEndpointSchema(env)
|
|
152
|
+
|
|
153
|
+
override async handle(c: AppContext) {
|
|
154
|
+
const data = await this.getValidatedData<typeof this.schema>()
|
|
155
|
+
|
|
156
|
+
const l = logger(env).with(
|
|
157
|
+
...group("request",
|
|
158
|
+
"type", data.params.certificateType,
|
|
159
|
+
"action", "krl",
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
const certificateType = data.params.certificateType === "user" ? CertificateType.User : CertificateType.Host
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
// get revoked serials
|
|
167
|
+
const serials = (await getRevocationList(c.env, certificateType)).map(v => BigInt(v))
|
|
168
|
+
|
|
169
|
+
// grab private key from secret store
|
|
170
|
+
const secret = await c.env.PRIVATE_KEY.get()
|
|
171
|
+
|
|
172
|
+
// parse key
|
|
173
|
+
const key = parsePrivateKey(secret)
|
|
174
|
+
|
|
175
|
+
// generate KRL
|
|
176
|
+
const krl = new KRLBuilder(key)
|
|
177
|
+
.addSerials(serials)
|
|
178
|
+
|
|
179
|
+
const krlBytes = krl.generate()
|
|
180
|
+
|
|
181
|
+
// convert to base64
|
|
182
|
+
const bytes = new Uint8Array(krlBytes)
|
|
183
|
+
let binary = ""
|
|
184
|
+
for (const byte of bytes) {
|
|
185
|
+
binary += String.fromCharCode(byte)
|
|
186
|
+
}
|
|
187
|
+
const base64Krl = btoa(binary)
|
|
188
|
+
|
|
189
|
+
const signature = await krl.signature()
|
|
190
|
+
|
|
191
|
+
return c.json({
|
|
192
|
+
krl: base64Krl,
|
|
193
|
+
signature: signature,
|
|
194
|
+
})
|
|
195
|
+
} catch (err) {
|
|
196
|
+
switch (true) {
|
|
197
|
+
case (err instanceof KeyParseError):
|
|
198
|
+
l.error("error parsing private key")
|
|
199
|
+
throw new InternalServerErrorException("error parsing private key")
|
|
200
|
+
case (err instanceof ApiException):
|
|
201
|
+
// re-throw any exisiting chanfana error
|
|
202
|
+
throw err
|
|
203
|
+
default:
|
|
204
|
+
// otherwise throw as InternalServerErrorException
|
|
205
|
+
l.error("unhandled error", "error", err)
|
|
206
|
+
throw new InternalServerErrorException("internal server error")
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const HostCertificateRequestEndpoint = (env: SshCaBindings) => {
|
|
214
|
+
return class extends OpenAPIRoute {
|
|
215
|
+
override schema = createHostCertificateRequestEndpointSchema(env)
|
|
216
|
+
|
|
217
|
+
override async handle(c: AppContext) {
|
|
218
|
+
const data = await this.getValidatedData<typeof this.schema>()
|
|
219
|
+
|
|
220
|
+
const l = logger(env).with(
|
|
221
|
+
...group("request",
|
|
222
|
+
"type", "host",
|
|
223
|
+
"action", "request",
|
|
224
|
+
),
|
|
225
|
+
...group("auth_token",
|
|
226
|
+
"email", data.headers.Authorization.email,
|
|
227
|
+
"sub", data.headers.Authorization.sub,
|
|
228
|
+
),
|
|
229
|
+
...group("id_token",
|
|
230
|
+
"sub", data.body.identity.sub,
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
// check id and access token are for the same user
|
|
235
|
+
if (data.body.identity.sub !== data.headers.Authorization.sub) {
|
|
236
|
+
l.error("token subjects did not match")
|
|
237
|
+
throw new ForbiddenException("possible token substitution as subjects for authentication and identity tokens did not match")
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// check user can issue host certificates
|
|
241
|
+
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))) {
|
|
242
|
+
l.error("unauthorized host certificate request")
|
|
243
|
+
throw new UnauthorizedException("User not allowed to issue host certificates")
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const opts: CreateHostCertificateOptions = {
|
|
247
|
+
lifetime: data.body.lifetime,
|
|
248
|
+
principals: data.body.principals,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const certificate = await createSignedHostCertificate(c.env, data.body.public_key, opts)
|
|
253
|
+
const response: CertificateSignerResponse = {
|
|
254
|
+
certificate: btoa(certificate.toString("openssh"))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
await recordCertificate(c.env, certificate, `host_${certificate.subjects[0]!.hostname}`, CertificateType.Host)
|
|
259
|
+
} catch (err) {
|
|
260
|
+
l.error("there was a problem adding issued certificate to database", "error", err)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
l.info("completed issuing certificate",
|
|
264
|
+
...group(
|
|
265
|
+
"certificate", "principals",
|
|
266
|
+
certificate.subjects.map(v => v.toString()).join(","), "serial", certificate.serial.readBigUInt64BE(0),
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
return c.json(response)
|
|
271
|
+
} catch (err) {
|
|
272
|
+
switch (true) {
|
|
273
|
+
case (err instanceof ApiException):
|
|
274
|
+
// re-throw any exisiting chanfana error
|
|
275
|
+
throw err
|
|
276
|
+
default:
|
|
277
|
+
// otherwise throw as InternalServerErrorException
|
|
278
|
+
l.error("unhandled error", "error", err)
|
|
279
|
+
throw new InternalServerErrorException("internal server error")
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const HostCertificateRenewEndpoint = (env: SshCaBindings) => {
|
|
287
|
+
return class extends OpenAPIRoute {
|
|
288
|
+
override schema = createHostCertificateRenewEndpointSchema(env)
|
|
289
|
+
|
|
290
|
+
override async handle(c: AppContext) {
|
|
291
|
+
const data = await this.getValidatedData<typeof this.schema>()
|
|
292
|
+
const serial = data.body.certificate.serial.readBigUInt64BE(0)
|
|
293
|
+
|
|
294
|
+
const l = logger(env).with(
|
|
295
|
+
...group("request",
|
|
296
|
+
"type", "host",
|
|
297
|
+
"action", "renewal",
|
|
298
|
+
"serial", serial,
|
|
299
|
+
),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
// use smaller of the current certificate lifetime and the requested lifetime
|
|
303
|
+
const originalLifetime = (data.body.certificate.validUntil.getTime() - data.body.certificate.validFrom.getTime()) / 1000
|
|
304
|
+
const lifetime = originalLifetime > data.body.lifetime
|
|
305
|
+
? data.body.lifetime
|
|
306
|
+
: originalLifetime
|
|
307
|
+
|
|
308
|
+
const opts: CreateHostCertificateOptions = {
|
|
309
|
+
lifetime: lifetime,
|
|
310
|
+
subjects: data.body.certificate.subjects
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
const certificate = await createSignedHostCertificate(c.env, data.body.public_key, opts)
|
|
315
|
+
const response: CertificateSignerResponse = {
|
|
316
|
+
certificate: btoa(certificate.toString("openssh"))
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
await recordCertificate(c.env, certificate, `host_${certificate.subjects[0]!.hostname}`, CertificateType.Host)
|
|
321
|
+
} catch (err) {
|
|
322
|
+
l.error("there was a problem adding issued certificate to database", "error", err)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
l.info("completed issuing certificate",
|
|
326
|
+
...group(
|
|
327
|
+
"certificate", "principals",
|
|
328
|
+
certificate.subjects.map(v => v.toString()).join(","), "serial", certificate.serial.readBigUInt64BE(0),
|
|
329
|
+
),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
return c.json(response)
|
|
333
|
+
} catch (err) {
|
|
334
|
+
switch (true) {
|
|
335
|
+
case (err instanceof ApiException):
|
|
336
|
+
// re-throw any exisiting chanfana error
|
|
337
|
+
throw err
|
|
338
|
+
case (err instanceof BadIssuerError):
|
|
339
|
+
throw new UnprocessableEntityException(err.message)
|
|
340
|
+
default:
|
|
341
|
+
// otherwise throw as InternalServerErrorException
|
|
342
|
+
l.error("unhandled error", "error", err)
|
|
343
|
+
throw new InternalServerErrorException("internal server error")
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const RevokeCertificateEndpoint = (env: SshCaBindings) => {
|
|
351
|
+
return class extends OpenAPIRoute {
|
|
352
|
+
override schema = createRevokeCertificateEndpointSchema(env)
|
|
353
|
+
|
|
354
|
+
override async handle(c: AppContext) {
|
|
355
|
+
const data = await this.getValidatedData<typeof this.schema>()
|
|
356
|
+
|
|
357
|
+
const l = logger(env).with(
|
|
358
|
+
...group("request", "type", data.params.certificateType, "action", "revoke", "serial", data.body.serial),
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
const certificatetype = data.params.certificateType === "user" ? CertificateType.User : CertificateType.Host
|
|
362
|
+
|
|
363
|
+
// ensure certificate can be revoked
|
|
364
|
+
const status = await revocationStatus(c.env, data.body.serial, data.body.public_key.toString("ssh"), certificatetype)
|
|
365
|
+
switch (status) {
|
|
366
|
+
case RevocationStatus.Revoked:
|
|
367
|
+
l.warn("certificate is already revoked")
|
|
368
|
+
throw new ConflictException("certificate is already revoked")
|
|
369
|
+
case RevocationStatus.Unrevokable:
|
|
370
|
+
l.warn("cannot revoke this certificate")
|
|
371
|
+
throw new ForbiddenException("cannot revoke this certificate")
|
|
372
|
+
case RevocationStatus.NotFound:
|
|
373
|
+
l.warn("certificate not found")
|
|
374
|
+
throw new NotFoundException("certificate not found")
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
const res = await revokeCertificate(c.env, data.body.serial)
|
|
379
|
+
|
|
380
|
+
if (res.length === 0) {
|
|
381
|
+
throw new InternalServerErrorException("no results were returned for revocation")
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (res.length > 1) {
|
|
385
|
+
l.warn("more than one revocation result was returned", "count", res.length)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
l.info("completed revocation")
|
|
389
|
+
return c.json({
|
|
390
|
+
revoked_at: res[0]!.revoked_at
|
|
391
|
+
})
|
|
392
|
+
} catch (err) {
|
|
393
|
+
switch (true) {
|
|
394
|
+
case (err instanceof ApiException):
|
|
395
|
+
// re-throw any exisiting chanfana error
|
|
396
|
+
throw err
|
|
397
|
+
default:
|
|
398
|
+
// otherwise throw as InternalServerErrorException
|
|
399
|
+
l.error("unhandled error", "error", err)
|
|
400
|
+
throw new InternalServerErrorException("internal server error")
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
export const createApi = (env: SshCaBindings) => {
|
|
409
|
+
const api = fromHono(new Hono())
|
|
410
|
+
|
|
411
|
+
api.get("/ca", CaPublicKeyEndpoint(env))
|
|
412
|
+
api.post("/user/certificate", UserCertificateRequestEndpoint(env))
|
|
413
|
+
api.get("/:certificateType/krl", RevocationListEndpoint(env))
|
|
414
|
+
api.post("/host/certificate", HostCertificateRequestEndpoint(env))
|
|
415
|
+
api.post("/host/renew", HostCertificateRenewEndpoint(env))
|
|
416
|
+
api.post("/:certificateType/revoke", RevokeCertificateEndpoint(env))
|
|
417
|
+
|
|
418
|
+
return api
|
|
419
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import z from "zod"
|
|
2
|
+
import type { SshCaBindings } from "../../types"
|
|
3
|
+
import {
|
|
4
|
+
split,
|
|
5
|
+
refineCertificateRequest,
|
|
6
|
+
transformAuthorizationHeader,
|
|
7
|
+
transformProofOfPossession,
|
|
8
|
+
transformPublicKey,
|
|
9
|
+
transformRenewalProofOfPossession,
|
|
10
|
+
refineHostCertificateRequest,
|
|
11
|
+
transformCertificate,
|
|
12
|
+
refineHostCertificateRenewal,
|
|
13
|
+
refineRevokeCertificate,
|
|
14
|
+
transformIdentityToken,
|
|
15
|
+
} from "../../utils"
|
|
16
|
+
import {
|
|
17
|
+
ConflictException,
|
|
18
|
+
contentJson,
|
|
19
|
+
ForbiddenException,
|
|
20
|
+
InputValidationException,
|
|
21
|
+
InternalServerErrorException,
|
|
22
|
+
UnauthorizedException,
|
|
23
|
+
UnprocessableEntityException,
|
|
24
|
+
} from "chanfana"
|
|
25
|
+
import { seconds } from "itty-time"
|
|
26
|
+
import { isRevoked } from "../../db"
|
|
27
|
+
|
|
28
|
+
const openapiStringByte = z.base64()
|
|
29
|
+
.transform((v) => {
|
|
30
|
+
const a = z.util.base64ToUint8Array(v)
|
|
31
|
+
return Buffer.from(
|
|
32
|
+
a.buffer,
|
|
33
|
+
a.byteOffset,
|
|
34
|
+
a.length
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
.openapi({ format: "byte" })
|
|
38
|
+
|
|
39
|
+
const proofOfPossession = z.string()
|
|
40
|
+
.meta({
|
|
41
|
+
description: "Proof of possession comprising of ${timestamp}.${fingerprint}.${signature}",
|
|
42
|
+
example: "1765696780805.SHA256:79mq0wtpQMTvS4+Of8VzLN0qmYWNUyTXYmqKwhEgSLs.LS0tLS1CRUdJTiBTU0ggU0lHTkFUVVJFLS0tLS0KVTFOSVUwbEhBQUFBQVFBQUFHZ0FBQUFUWldOa2MyRXRjMmhoTWkxdWFYTjBjREkxTmdBQUFBaHVhWE4wY0RJMQpOZ0FBQUVFRUR6Ymk4OE10alBYSDFCb1JURU9HaS96aEc4QnhQNVZUUW5OQkFNMGZtMExtekErUUZkUlNVYVRFCkdNN2l0QjhINmZNZFAydlAxcElQcGUyOWVLQkJZd0FBQUFSbWFXeGxBQUFBQUFBQUFBWnphR0UxTVRJQUFBQmsKQUFBQUUyVmpaSE5oTFhOb1lUSXRibWx6ZEhBeU5UWUFBQUJKQUFBQUlHdmZVZGNDQkRXa3ZwL0ZHS3NkakgrRQpKZzZXN0EvWUJVZng2Z0srQ01raEFBQUFJUUQxOFd5cDNZejZ0eXNvdllXbEJHTXprRnlYbFRrSHM5blVVRXJqCkczNWVPQT09Ci0tLS0tRU5EIFNTSCBTSUdOQVRVUkUtLS0tLQo="
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const publicKey = openapiStringByte
|
|
46
|
+
.transform(transformPublicKey)
|
|
47
|
+
.meta({
|
|
48
|
+
description: "SSH public key to sign",
|
|
49
|
+
example: "ZWNkc2Etc2hhMi1uaXN0cDI1NiBBQUFBRTJWalpITmhMWE5vWVRJdGJtbHpkSEF5TlRZQUFBQUlibWx6ZEhBeU5UWUFBQUJCQkdNcFNKdHRmMEl0dE5DVmMyOFR6WEZSMUQweHZPM25wNjdWemVBUXNiZFpza3JEY1lqU2x3SjZIUERtVHBYYVUwbEVhWDlMNFloYy9jQ2YxTWU5RlRrPQo="
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
export const createIdentityTokenSchema = (env: SshCaBindings) => (
|
|
53
|
+
z.string()
|
|
54
|
+
.meta({
|
|
55
|
+
description: "Identity Token JWT from OIDC IdP",
|
|
56
|
+
example: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwiaWF0IjoxNzc1NzAzMDk1fQ.ri_neAWnhMNK3LzlsrBcQYymSM4yRjmNSZZSeZiXhrEqtEz6c3cXk0Esq765umGjpUsWcosL-OFrDJlyAjTDnhrd9oV08uc_CW0rQRsJIGEuRo3ryxkLdVu9mGoZWEUb9KwjGJrwxvr-0cPWx5jaDyKwJcqMvtV_bEITUD51sDB1Vm89QfYRO_pGJo2vrRzSvMjpUenRpwPay4lYIBxl41_4YpR9Rc6VrIZuYsjV2iqEZ4eBrygMA7zPR_hN7l7s95FddLOzj5NsK57VT4uLHwYohx2oqMzw3M-B9HsZIQin_9q61pZFQXepzJth0woXiZheU27llnfHX967PhNQyg"
|
|
57
|
+
})
|
|
58
|
+
.transform((val, ctx) => transformIdentityToken(env, val, ctx))
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
export const createAccessTokenSchema = (env: SshCaBindings) => (
|
|
62
|
+
z.string()
|
|
63
|
+
.meta({
|
|
64
|
+
description: "Access Token JWT from OIDC IdP",
|
|
65
|
+
example: "Bearer eyJ..."
|
|
66
|
+
})
|
|
67
|
+
.transform((val, ctx) => transformAuthorizationHeader(env, val, ctx))
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const krl = openapiStringByte
|
|
71
|
+
.meta({ description: "Key Revocation List" })
|
|
72
|
+
|
|
73
|
+
const createHeaderSchema = (env: SshCaBindings) => (
|
|
74
|
+
z.object({
|
|
75
|
+
"Authorization": createAccessTokenSchema(env)
|
|
76
|
+
})
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const certificate = openapiStringByte
|
|
80
|
+
.meta({ description: "Issued Certificate" })
|
|
81
|
+
|
|
82
|
+
export const CaPublicKeyEndpointSchema = {
|
|
83
|
+
responses: {
|
|
84
|
+
"200": {
|
|
85
|
+
content: {
|
|
86
|
+
"text/plain": {
|
|
87
|
+
schema: z.string()
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
description: "Returns SSH Certificate Authority Public Key",
|
|
91
|
+
},
|
|
92
|
+
...InternalServerErrorException.schema(),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const createUserCertificateRequestEndpointSchema = (env: SshCaBindings) => ({
|
|
97
|
+
security: [{ oidcAuth: [] }],
|
|
98
|
+
request: {
|
|
99
|
+
headers: createHeaderSchema(env),
|
|
100
|
+
body: contentJson(
|
|
101
|
+
z.object({
|
|
102
|
+
public_key: publicKey,
|
|
103
|
+
proof: proofOfPossession
|
|
104
|
+
.transform((val, ctx) => transformProofOfPossession(env, val, ctx)),
|
|
105
|
+
identity: createIdentityTokenSchema(env),
|
|
106
|
+
extensions: z.array(z.string())
|
|
107
|
+
.default(split(env.SSH_CERTIFICATE_EXTENSIONS))
|
|
108
|
+
.meta({ description: "Extensions to include in the issued SSH certificate" }),
|
|
109
|
+
lifetime: z.int()
|
|
110
|
+
.min(seconds("5 minutes"))
|
|
111
|
+
.max(seconds(env.SSH_CERTIFICATE_LIFETIME))
|
|
112
|
+
.default(seconds(env.SSH_CERTIFICATE_LIFETIME))
|
|
113
|
+
.meta({ description: "Lifetime of issued SSH certificate" }),
|
|
114
|
+
})
|
|
115
|
+
.superRefine((val, ctx) => refineCertificateRequest(env, val, ctx))
|
|
116
|
+
.openapi("User Certificate Request")
|
|
117
|
+
)
|
|
118
|
+
},
|
|
119
|
+
responses: {
|
|
120
|
+
"200": {
|
|
121
|
+
description: "SSH User Certificate issued successfully",
|
|
122
|
+
...contentJson(z.object({
|
|
123
|
+
certificate: certificate,
|
|
124
|
+
})
|
|
125
|
+
.openapi("Certificate Response")
|
|
126
|
+
)
|
|
127
|
+
},
|
|
128
|
+
...UnauthorizedException.schema(),
|
|
129
|
+
...ForbiddenException.schema(),
|
|
130
|
+
...InputValidationException.schema(),
|
|
131
|
+
...UnprocessableEntityException.schema(),
|
|
132
|
+
...InternalServerErrorException.schema(),
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
export const createRevocationListEndpointSchema = (env: SshCaBindings) => ({
|
|
137
|
+
request: {
|
|
138
|
+
params: z.object({
|
|
139
|
+
certificateType: z.enum(["user", "host"])
|
|
140
|
+
}),
|
|
141
|
+
},
|
|
142
|
+
responses: {
|
|
143
|
+
"200": {
|
|
144
|
+
description: "Returns an Open SSH Key Revocation List as BASE64 and an SSHSIG signature for verification",
|
|
145
|
+
...contentJson(z.object({
|
|
146
|
+
krl: krl,
|
|
147
|
+
signature: z.string()
|
|
148
|
+
})
|
|
149
|
+
.openapi("Key Revocation List Response")
|
|
150
|
+
)
|
|
151
|
+
},
|
|
152
|
+
...InternalServerErrorException.schema(),
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
export const createHostCertificateRequestEndpointSchema = (env: SshCaBindings) => ({
|
|
157
|
+
security: [{ oidcAuth: [] }],
|
|
158
|
+
request: {
|
|
159
|
+
headers: createHeaderSchema(env),
|
|
160
|
+
body: contentJson(
|
|
161
|
+
z.object({
|
|
162
|
+
public_key: publicKey,
|
|
163
|
+
proof: proofOfPossession
|
|
164
|
+
.transform((val, ctx) => transformProofOfPossession(env, val, ctx)),
|
|
165
|
+
identity: createIdentityTokenSchema(env),
|
|
166
|
+
principals: z.array(z.string()).min(1)
|
|
167
|
+
.meta({ description: "List of principals to include on the issued certificate" }),
|
|
168
|
+
lifetime: z.int()
|
|
169
|
+
.min(seconds("24 hours"))
|
|
170
|
+
.max(seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
|
|
171
|
+
.default(seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
|
|
172
|
+
.meta({ description: "Lifetime of issued Host SSH certificate" }),
|
|
173
|
+
})
|
|
174
|
+
.superRefine((val, ctx) => refineHostCertificateRequest(env, val, ctx))
|
|
175
|
+
.openapi("Host Certificate Request")
|
|
176
|
+
)
|
|
177
|
+
},
|
|
178
|
+
responses: {
|
|
179
|
+
"200": {
|
|
180
|
+
description: "SSH Host Certificate issued successfully",
|
|
181
|
+
...contentJson(z.object({
|
|
182
|
+
certificate: certificate,
|
|
183
|
+
})
|
|
184
|
+
.openapi("Certificate Response")
|
|
185
|
+
)
|
|
186
|
+
},
|
|
187
|
+
...UnauthorizedException.schema(),
|
|
188
|
+
...ForbiddenException.schema(),
|
|
189
|
+
...InputValidationException.schema(),
|
|
190
|
+
...UnprocessableEntityException.schema(),
|
|
191
|
+
...InternalServerErrorException.schema(),
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
export const createHostCertificateRenewEndpointSchema = (env: SshCaBindings) => ({
|
|
196
|
+
request: {
|
|
197
|
+
body: contentJson(
|
|
198
|
+
z.object({
|
|
199
|
+
certificate: openapiStringByte
|
|
200
|
+
.transform(transformCertificate)
|
|
201
|
+
.meta({ description: "SSH certificate to renew" }),
|
|
202
|
+
public_key: publicKey,
|
|
203
|
+
proof: proofOfPossession
|
|
204
|
+
.transform((val, ctx) => transformRenewalProofOfPossession(env, val, ctx)),
|
|
205
|
+
lifetime: z.int()
|
|
206
|
+
.min(seconds("24 hours"))
|
|
207
|
+
.max(seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
|
|
208
|
+
.default(seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
|
|
209
|
+
.meta({ description: "Lifetime of renewed Host SSH certificate" }),
|
|
210
|
+
})
|
|
211
|
+
.superRefine((val, ctx) => refineHostCertificateRenewal(env, isRevoked, val, ctx))
|
|
212
|
+
.openapi("Host Certificate Renew")
|
|
213
|
+
)
|
|
214
|
+
},
|
|
215
|
+
responses: {
|
|
216
|
+
"200": {
|
|
217
|
+
description: "SSH Host Certificate renewed successfully",
|
|
218
|
+
...contentJson(z.object({
|
|
219
|
+
certificate: certificate
|
|
220
|
+
})
|
|
221
|
+
.openapi("Certificate Response")
|
|
222
|
+
),
|
|
223
|
+
},
|
|
224
|
+
...UnauthorizedException.schema(),
|
|
225
|
+
...ForbiddenException.schema(),
|
|
226
|
+
...InputValidationException.schema(),
|
|
227
|
+
...UnprocessableEntityException.schema(),
|
|
228
|
+
...InternalServerErrorException.schema(),
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
export const createRevokeCertificateEndpointSchema = (env: SshCaBindings) => ({
|
|
233
|
+
request: {
|
|
234
|
+
params: z.object({
|
|
235
|
+
certificateType: z.enum(["user", "host"])
|
|
236
|
+
}),
|
|
237
|
+
body: contentJson(
|
|
238
|
+
z.object({
|
|
239
|
+
serial: z.bigint()
|
|
240
|
+
.meta({ description: "Serial number of certificate to revoke" }),
|
|
241
|
+
public_key: publicKey,
|
|
242
|
+
proof: proofOfPossession
|
|
243
|
+
.transform((val, ctx) => transformProofOfPossession(env, val, ctx)),
|
|
244
|
+
})
|
|
245
|
+
.openapi("Certificate Revocation")
|
|
246
|
+
.superRefine((val, ctx) => refineRevokeCertificate(env, val, ctx))
|
|
247
|
+
)
|
|
248
|
+
},
|
|
249
|
+
responses: {
|
|
250
|
+
"200": {
|
|
251
|
+
description: "SSH certificate revoked successfully",
|
|
252
|
+
...contentJson(z.object({
|
|
253
|
+
revoked_at: z.iso.datetime(),
|
|
254
|
+
})
|
|
255
|
+
.openapi("Revocation Response")
|
|
256
|
+
),
|
|
257
|
+
},
|
|
258
|
+
...UnauthorizedException.schema(),
|
|
259
|
+
...ForbiddenException.schema(),
|
|
260
|
+
...InputValidationException.schema(),
|
|
261
|
+
...UnprocessableEntityException.schema(),
|
|
262
|
+
...InternalServerErrorException.schema(),
|
|
263
|
+
...ConflictException.schema()
|
|
264
|
+
}
|
|
265
|
+
})
|