@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.
- package/README.md +123 -0
- package/dist/api/operations.d.ts +2 -1
- package/dist/api/operations.d.ts.map +1 -1
- package/dist/api/operations.js +107 -18
- package/dist/api/operations.js.map +1 -1
- package/dist/api/schema.d.ts +1 -0
- package/dist/api/schema.d.ts.map +1 -1
- package/dist/api/schema.js +8 -1
- package/dist/api/schema.js.map +1 -1
- package/dist/api/v3/index.d.ts.map +1 -1
- package/dist/api/v3/index.js +1 -1
- package/dist/api/v3/index.js.map +1 -1
- package/dist/api/v3/schema.d.ts.map +1 -1
- package/dist/api/v3/schema.js +11 -7
- package/dist/api/v3/schema.js.map +1 -1
- package/dist/api/v4/index.d.ts.map +1 -1
- package/dist/api/v4/index.js +1 -1
- package/dist/api/v4/index.js.map +1 -1
- package/dist/api/v4/schema.d.ts.map +1 -1
- package/dist/api/v4/schema.js +11 -7
- package/dist/api/v4/schema.js.map +1 -1
- package/dist/db/index.d.ts +51 -4
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +133 -33
- package/dist/db/index.js.map +1 -1
- package/dist/db/migrations/0003_krl_revision.d.ts +3 -0
- package/dist/db/migrations/0003_krl_revision.d.ts.map +1 -0
- package/dist/db/migrations/0003_krl_revision.js +14 -0
- package/dist/db/migrations/0003_krl_revision.js.map +1 -0
- package/dist/db/migrations/index.d.ts.map +1 -1
- package/dist/db/migrations/index.js +2 -0
- package/dist/db/migrations/index.js.map +1 -1
- package/dist/db/types.d.ts +2 -0
- package/dist/db/types.d.ts.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/krl.d.ts +2 -1
- package/dist/krl.d.ts.map +1 -1
- package/dist/krl.js +3 -2
- package/dist/krl.js.map +1 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +22 -1
- package/dist/logger.js.map +1 -1
- package/dist/node/cache.d.ts +21 -0
- package/dist/node/cache.d.ts.map +1 -0
- package/dist/node/cache.js +49 -0
- package/dist/node/cache.js.map +1 -0
- package/dist/node/env.d.ts +6 -3
- package/dist/node/env.d.ts.map +1 -1
- package/dist/node/env.js +4 -3
- package/dist/node/env.js.map +1 -1
- package/dist/node/index.d.ts +1 -0
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -0
- package/dist/node/index.js.map +1 -1
- package/dist/router.d.ts +25 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +38 -1
- package/dist/router.js.map +1 -1
- package/dist/ssh/keys.d.ts +18 -5
- package/dist/ssh/keys.d.ts.map +1 -1
- package/dist/ssh/keys.js +126 -11
- package/dist/ssh/keys.js.map +1 -1
- package/dist/types.d.ts +53 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +68 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +166 -13
- package/dist/utils.js.map +1 -1
- package/dist/verify.d.ts +17 -5
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +47 -9
- package/dist/verify.js.map +1 -1
- package/openapi.json +402 -2
- package/package.json +1 -1
- package/src/api/operations.ts +119 -16
- package/src/api/schema.ts +9 -0
- package/src/api/v3/index.ts +1 -0
- package/src/api/v3/schema.ts +11 -4
- package/src/api/v4/index.ts +1 -0
- package/src/api/v4/schema.ts +11 -5
- package/src/db/index.ts +162 -37
- package/src/db/migrations/0003_krl_revision.ts +15 -0
- package/src/db/migrations/index.ts +2 -0
- package/src/db/types.ts +2 -0
- package/src/index.ts +6 -4
- package/src/krl.ts +3 -2
- package/src/logger.ts +25 -1
- package/src/node/cache.ts +62 -0
- package/src/node/env.ts +6 -5
- package/src/node/index.ts +1 -0
- package/src/router.ts +64 -2
- package/src/ssh/keys.ts +145 -12
- package/src/types.ts +49 -3
- package/src/utils.ts +188 -14
- package/src/verify.ts +54 -14
package/src/api/operations.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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({
|
package/src/api/v3/index.ts
CHANGED
package/src/api/v3/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
|
+
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:
|
|
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:
|
|
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
|
})
|
package/src/api/v4/index.ts
CHANGED
|
@@ -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
|
}
|
package/src/api/v4/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
|
+
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
|
-
|
|
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:
|
|
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:
|
|
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
|
})
|