@andrewheberle/serverless-ssh-ca 0.17.13 → 0.17.16
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 +8 -19
- 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 +2 -1
- package/dist/api/v3/index.js.map +1 -0
- package/dist/api/v3/schema.d.ts +47 -1
- package/dist/api/v3/schema.d.ts.map +1 -0
- package/dist/api/v3/schema.js +65 -61
- 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 +11 -6
- 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 +2 -1
- 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 +13 -1
- 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 +9 -1
- 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 +8 -8
- 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 +2 -1
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +14 -11
- 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 +26 -10
- package/src/api/v3/index.ts +419 -0
- package/src/api/v3/schema.ts +273 -0
- package/src/certificate.ts +220 -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 +16 -0
- package/src/proof.ts +187 -0
- package/src/router.ts +42 -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 +48 -0
- package/src/utils.ts +412 -0
- package/src/verify.ts +25 -0
- package/LICENSE +0 -201
- package/dist/db.js +0 -111
|
@@ -0,0 +1,273 @@
|
|
|
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
|
+
export 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 userCertificateRequestEndpointBodySchema = (env: SshCaBindings) => (
|
|
97
|
+
z.object({
|
|
98
|
+
public_key: publicKey,
|
|
99
|
+
proof: proofOfPossession
|
|
100
|
+
.transform((val, ctx) => transformProofOfPossession(env, val, ctx)),
|
|
101
|
+
identity: createIdentityTokenSchema(env),
|
|
102
|
+
extensions: z.array(z.string())
|
|
103
|
+
.default(split(env.SSH_CERTIFICATE_EXTENSIONS))
|
|
104
|
+
.meta({ description: "Extensions to include in the issued SSH certificate" }),
|
|
105
|
+
lifetime: z.int()
|
|
106
|
+
.min(seconds("5 minutes"))
|
|
107
|
+
.max(seconds(env.SSH_CERTIFICATE_LIFETIME))
|
|
108
|
+
.default(seconds(env.SSH_CERTIFICATE_LIFETIME))
|
|
109
|
+
.meta({ description: "Lifetime of issued SSH certificate" }),
|
|
110
|
+
})
|
|
111
|
+
.superRefine((val, ctx) => refineCertificateRequest(env, val, ctx))
|
|
112
|
+
.openapi("User Certificate Request")
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
export const createUserCertificateRequestEndpointSchema = (env: SshCaBindings) => ({
|
|
116
|
+
security: [{ oidcAuth: [] }],
|
|
117
|
+
request: {
|
|
118
|
+
headers: createHeaderSchema(env),
|
|
119
|
+
body: contentJson(userCertificateRequestEndpointBodySchema(env)),
|
|
120
|
+
},
|
|
121
|
+
responses: {
|
|
122
|
+
"200": {
|
|
123
|
+
description: "SSH User Certificate issued successfully",
|
|
124
|
+
...contentJson(z.object({
|
|
125
|
+
certificate: certificate,
|
|
126
|
+
})
|
|
127
|
+
.openapi("Certificate Response")
|
|
128
|
+
)
|
|
129
|
+
},
|
|
130
|
+
...UnauthorizedException.schema(),
|
|
131
|
+
...ForbiddenException.schema(),
|
|
132
|
+
...InputValidationException.schema(),
|
|
133
|
+
...UnprocessableEntityException.schema(),
|
|
134
|
+
...InternalServerErrorException.schema(),
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
export const createRevocationListEndpointSchema = () => ({
|
|
139
|
+
request: {
|
|
140
|
+
params: z.object({
|
|
141
|
+
certificateType: z.enum(["user", "host"])
|
|
142
|
+
}),
|
|
143
|
+
},
|
|
144
|
+
responses: {
|
|
145
|
+
"200": {
|
|
146
|
+
description: "Returns an Open SSH Key Revocation List as BASE64 and an SSHSIG signature for verification",
|
|
147
|
+
...contentJson(z.object({
|
|
148
|
+
krl: krl,
|
|
149
|
+
signature: z.string()
|
|
150
|
+
})
|
|
151
|
+
.openapi("Key Revocation List Response")
|
|
152
|
+
)
|
|
153
|
+
},
|
|
154
|
+
...InternalServerErrorException.schema(),
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
export const hostCertificateRequestEndpointBodySchema = (env: SshCaBindings) => (
|
|
159
|
+
z.object({
|
|
160
|
+
public_key: publicKey,
|
|
161
|
+
proof: proofOfPossession
|
|
162
|
+
.transform((val, ctx) => transformProofOfPossession(env, val, ctx)),
|
|
163
|
+
identity: createIdentityTokenSchema(env),
|
|
164
|
+
principals: z.array(z.string()).min(1)
|
|
165
|
+
.meta({ description: "List of principals to include on the issued certificate" }),
|
|
166
|
+
lifetime: z.int()
|
|
167
|
+
.min(seconds("24 hours"))
|
|
168
|
+
.max(seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
|
|
169
|
+
.default(seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
|
|
170
|
+
.meta({ description: "Lifetime of issued Host SSH certificate" }),
|
|
171
|
+
})
|
|
172
|
+
.superRefine((val, ctx) => refineHostCertificateRequest(env, val, ctx))
|
|
173
|
+
.openapi("Host Certificate Request")
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
export const createHostCertificateRequestEndpointSchema = (env: SshCaBindings) => ({
|
|
177
|
+
security: [{ oidcAuth: [] }],
|
|
178
|
+
request: {
|
|
179
|
+
headers: createHeaderSchema(env),
|
|
180
|
+
body: contentJson(hostCertificateRequestEndpointBodySchema(env))
|
|
181
|
+
},
|
|
182
|
+
responses: {
|
|
183
|
+
"200": {
|
|
184
|
+
description: "SSH Host Certificate issued successfully",
|
|
185
|
+
...contentJson(z.object({
|
|
186
|
+
certificate: certificate,
|
|
187
|
+
})
|
|
188
|
+
.openapi("Certificate Response")
|
|
189
|
+
)
|
|
190
|
+
},
|
|
191
|
+
...UnauthorizedException.schema(),
|
|
192
|
+
...ForbiddenException.schema(),
|
|
193
|
+
...InputValidationException.schema(),
|
|
194
|
+
...UnprocessableEntityException.schema(),
|
|
195
|
+
...InternalServerErrorException.schema(),
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
export const hostCertificateRenewEndpointBodySchema = (env: SshCaBindings) => (
|
|
200
|
+
z.object({
|
|
201
|
+
certificate: openapiStringByte
|
|
202
|
+
.transform(transformCertificate)
|
|
203
|
+
.meta({ description: "SSH certificate to renew" }),
|
|
204
|
+
public_key: publicKey,
|
|
205
|
+
proof: proofOfPossession
|
|
206
|
+
.transform((val, ctx) => transformRenewalProofOfPossession(env, val, ctx)),
|
|
207
|
+
lifetime: z.int()
|
|
208
|
+
.min(seconds("24 hours"))
|
|
209
|
+
.max(seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
|
|
210
|
+
.default(seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
|
|
211
|
+
.meta({ description: "Lifetime of renewed Host SSH certificate" }),
|
|
212
|
+
})
|
|
213
|
+
.superRefine((val, ctx) => refineHostCertificateRenewal(env, isRevoked, val, ctx))
|
|
214
|
+
.openapi("Host Certificate Renew")
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
export const createHostCertificateRenewEndpointSchema = (env: SshCaBindings) => ({
|
|
218
|
+
request: {
|
|
219
|
+
body: contentJson(hostCertificateRenewEndpointBodySchema(env))
|
|
220
|
+
},
|
|
221
|
+
responses: {
|
|
222
|
+
"200": {
|
|
223
|
+
description: "SSH Host Certificate renewed successfully",
|
|
224
|
+
...contentJson(z.object({
|
|
225
|
+
certificate: certificate
|
|
226
|
+
})
|
|
227
|
+
.openapi("Certificate Response")
|
|
228
|
+
),
|
|
229
|
+
},
|
|
230
|
+
...UnauthorizedException.schema(),
|
|
231
|
+
...ForbiddenException.schema(),
|
|
232
|
+
...InputValidationException.schema(),
|
|
233
|
+
...UnprocessableEntityException.schema(),
|
|
234
|
+
...InternalServerErrorException.schema(),
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
export const revokeCertificateEndpointBodySchema = (env: SshCaBindings) => (
|
|
239
|
+
z.object({
|
|
240
|
+
serial: z.bigint()
|
|
241
|
+
.meta({ description: "Serial number of certificate to revoke" }),
|
|
242
|
+
public_key: publicKey,
|
|
243
|
+
proof: proofOfPossession
|
|
244
|
+
.transform((val, ctx) => transformProofOfPossession(env, val, ctx)),
|
|
245
|
+
})
|
|
246
|
+
.openapi("Certificate Revocation")
|
|
247
|
+
.superRefine((val, ctx) => refineRevokeCertificate(env, val, ctx))
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
export const createRevokeCertificateEndpointSchema = (env: SshCaBindings) => ({
|
|
251
|
+
request: {
|
|
252
|
+
params: z.object({
|
|
253
|
+
certificateType: z.enum(["user", "host"])
|
|
254
|
+
}),
|
|
255
|
+
body: contentJson(revokeCertificateEndpointBodySchema(env))
|
|
256
|
+
},
|
|
257
|
+
responses: {
|
|
258
|
+
"200": {
|
|
259
|
+
description: "SSH certificate revoked successfully",
|
|
260
|
+
...contentJson(z.object({
|
|
261
|
+
revoked_at: z.iso.datetime(),
|
|
262
|
+
})
|
|
263
|
+
.openapi("Revocation Response")
|
|
264
|
+
),
|
|
265
|
+
},
|
|
266
|
+
...UnauthorizedException.schema(),
|
|
267
|
+
...ForbiddenException.schema(),
|
|
268
|
+
...InputValidationException.schema(),
|
|
269
|
+
...UnprocessableEntityException.schema(),
|
|
270
|
+
...InternalServerErrorException.schema(),
|
|
271
|
+
...ConflictException.schema()
|
|
272
|
+
}
|
|
273
|
+
})
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { seconds } from "itty-time"
|
|
2
|
+
import { Certificate, createCertificate, Identity, identityForHost, identityForUser, identityFromDN, Key, parsePrivateKey, PrivateKey } from "sshpk"
|
|
3
|
+
import { SSHExtension } from "./types"
|
|
4
|
+
import { split } from "./utils"
|
|
5
|
+
import type { SshCaBindings } from "./types"
|
|
6
|
+
|
|
7
|
+
// const sshCertificateExtensions = split(env.SSH_CERTIFICATE_EXTENSIONS)
|
|
8
|
+
|
|
9
|
+
export type CreateCertificateOptions = {
|
|
10
|
+
lifetime?: number
|
|
11
|
+
principals?: string[]
|
|
12
|
+
extensions?: string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* const DefaultCreateCertificateOptions: CreateCertificateOptions = {
|
|
16
|
+
lifetime: seconds(env.SSH_CERTIFICATE_LIFETIME),
|
|
17
|
+
principals: [],
|
|
18
|
+
extensions: sshCertificateExtensions
|
|
19
|
+
} */
|
|
20
|
+
|
|
21
|
+
export class CertificateError extends Error {
|
|
22
|
+
constructor(message: string) {
|
|
23
|
+
super(message)
|
|
24
|
+
this.name = "CertificateError"
|
|
25
|
+
|
|
26
|
+
// This is necessary for proper stack trace in TypeScript
|
|
27
|
+
Object.setPrototypeOf(this, CertificateError.prototype)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type GenerateCertificateOptions = {
|
|
32
|
+
/**
|
|
33
|
+
* @internal
|
|
34
|
+
* Override the certificate serial number.
|
|
35
|
+
* Should only be set in tests.
|
|
36
|
+
*/
|
|
37
|
+
serial?: bigint
|
|
38
|
+
} & CreateCertificateOptions
|
|
39
|
+
|
|
40
|
+
export const generateCertificate = (env: SshCaBindings, email: string, key: PrivateKey, public_key: Key, options?: GenerateCertificateOptions): Certificate => {
|
|
41
|
+
let { lifetime } = options ?? {}
|
|
42
|
+
const { principals, extensions, serial } = options ?? {}
|
|
43
|
+
|
|
44
|
+
// add identities
|
|
45
|
+
const includeSelf = env.SSH_CERTIFICATE_INCLUDE_SELF === undefined
|
|
46
|
+
? false
|
|
47
|
+
: typeof env.SSH_CERTIFICATE_INCLUDE_SELF === "string"
|
|
48
|
+
? env.SSH_CERTIFICATE_INCLUDE_SELF === "true"
|
|
49
|
+
: env.SSH_CERTIFICATE_INCLUDE_SELF
|
|
50
|
+
const identity = includeSelf
|
|
51
|
+
? [identityForUser(email.split("@")[0]!)]
|
|
52
|
+
: []
|
|
53
|
+
if (principals !== undefined) {
|
|
54
|
+
for (const p of principals) {
|
|
55
|
+
identity.push(identityForUser(p))
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const p of split(env.SSH_CERTIFICATE_PRINCIPALS)) {
|
|
60
|
+
identity.push(identityForUser(p))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
// lifetime is the smaller of what was provided in the options or the default
|
|
65
|
+
lifetime = lifetime !== undefined
|
|
66
|
+
? Math.min(lifetime, seconds(env.SSH_CERTIFICATE_LIFETIME))
|
|
67
|
+
: seconds(env.SSH_CERTIFICATE_LIFETIME)
|
|
68
|
+
|
|
69
|
+
// generate value for serial of certificate
|
|
70
|
+
const s = generateSerial(serial)
|
|
71
|
+
|
|
72
|
+
// set issuer of certificate based on ISSUER_DN
|
|
73
|
+
const issuer = identityFromDN(env.ISSUER_DN)
|
|
74
|
+
|
|
75
|
+
// create certificate
|
|
76
|
+
const certificate = createCertificate(identity, public_key, issuer, key, { lifetime: lifetime, serial: s })
|
|
77
|
+
|
|
78
|
+
// ensure openssh info is included in certificate (should not occur)
|
|
79
|
+
if (certificate.signatures.openssh === undefined) {
|
|
80
|
+
throw new CertificateError("missing openssh information in certificate")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const sshCertificateExtensions = split(env.SSH_CERTIFICATE_EXTENSIONS)
|
|
84
|
+
|
|
85
|
+
// add usage extensions
|
|
86
|
+
const sshextensions: SSHExtension[] = []
|
|
87
|
+
if (extensions !== undefined) {
|
|
88
|
+
// if extensions are provided in request, ensure they do not include extra extensions beyond the defaults
|
|
89
|
+
for (const ext of extensions) {
|
|
90
|
+
if (!sshCertificateExtensions.includes(ext)) {
|
|
91
|
+
throw new CertificateError(`${ext} is not allowed`)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// add to list of allowed extensions
|
|
95
|
+
sshextensions.push({
|
|
96
|
+
critical: false,
|
|
97
|
+
name: ext,
|
|
98
|
+
data: Buffer.alloc(0)
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
// use defaults if not provided
|
|
103
|
+
sshCertificateExtensions.forEach((ext: string) => {
|
|
104
|
+
sshextensions.push({
|
|
105
|
+
critical: false,
|
|
106
|
+
name: ext,
|
|
107
|
+
data: Buffer.alloc(0)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// add info to certificate
|
|
114
|
+
certificate.signatures = {
|
|
115
|
+
openssh: {
|
|
116
|
+
nonce: certificate.signatures.openssh.nonce,
|
|
117
|
+
keyId: email,
|
|
118
|
+
signature: certificate.signatures.openssh.signature,
|
|
119
|
+
exts: sshextensions
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// re-sign after changes
|
|
124
|
+
certificate.signWith(key)
|
|
125
|
+
|
|
126
|
+
return certificate
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function createSignedCertificate(env: SshCaBindings, email: string, public_key: Key, options?: CreateCertificateOptions): Promise<Certificate> {
|
|
130
|
+
// grab private key from secret store
|
|
131
|
+
const secret = await env.PRIVATE_KEY.get()
|
|
132
|
+
|
|
133
|
+
// parse key
|
|
134
|
+
const key = parsePrivateKey(secret)
|
|
135
|
+
|
|
136
|
+
if (options === undefined) {
|
|
137
|
+
options = {
|
|
138
|
+
lifetime: seconds(env.SSH_CERTIFICATE_LIFETIME),
|
|
139
|
+
principals: [],
|
|
140
|
+
extensions: split(env.SSH_CERTIFICATE_EXTENSIONS)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return generateCertificate(env, email, key, public_key, options)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type CreateHostCertificateOptions = {
|
|
148
|
+
lifetime?: number
|
|
149
|
+
principals?: string[]
|
|
150
|
+
subjects?: Identity[]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* const DefaultCreateHostertificateOptions: CreateHostCertificateOptions = {
|
|
154
|
+
lifetime: seconds(env.SSH_HOST_CERTIFICATE_LIFETIME),
|
|
155
|
+
principals: [],
|
|
156
|
+
} */
|
|
157
|
+
|
|
158
|
+
export class BadIssuerError extends Error {
|
|
159
|
+
constructor(message: string) {
|
|
160
|
+
super(message)
|
|
161
|
+
this.name = "BadIssuerError"
|
|
162
|
+
|
|
163
|
+
Object.setPrototypeOf(this, BadIssuerError.prototype)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function createSignedHostCertificate(env: SshCaBindings, public_key: Key, options?: CreateHostCertificateOptions): Promise<Certificate> {
|
|
168
|
+
if (options === undefined) {
|
|
169
|
+
options = {
|
|
170
|
+
lifetime: seconds(env.SSH_HOST_CERTIFICATE_LIFETIME),
|
|
171
|
+
principals: []
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// generate list of identities for host key
|
|
175
|
+
const identity: Identity[] = []
|
|
176
|
+
if (options.principals !== undefined) {
|
|
177
|
+
for (const p of options.principals) {
|
|
178
|
+
identity.push(identityForHost(p))
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// add any already provided subjects (for renewals only)
|
|
183
|
+
if (options.subjects !== undefined) {
|
|
184
|
+
identity.push(...options.subjects)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// grab private key from secret store
|
|
188
|
+
const secret = await env.PRIVATE_KEY.get()
|
|
189
|
+
|
|
190
|
+
// parse private key
|
|
191
|
+
const key = parsePrivateKey(secret)
|
|
192
|
+
|
|
193
|
+
// lifetime is the smaller of what was provided in the options or the default
|
|
194
|
+
const lifetime = options.lifetime !== undefined
|
|
195
|
+
? Math.min(options.lifetime, seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
|
|
196
|
+
: seconds(env.SSH_HOST_CERTIFICATE_LIFETIME)
|
|
197
|
+
|
|
198
|
+
// generate value for serial of certificate
|
|
199
|
+
const serial = generateSerial()
|
|
200
|
+
|
|
201
|
+
// set issuer of certificate based on ISSUER_DN
|
|
202
|
+
const issuer = identityFromDN(env.ISSUER_DN)
|
|
203
|
+
|
|
204
|
+
// create certificate
|
|
205
|
+
const certificate = createCertificate(identity, public_key, issuer, key, { lifetime: lifetime, serial: serial })
|
|
206
|
+
|
|
207
|
+
return certificate
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export const generateSerial = (serial?: bigint): Buffer<ArrayBuffer> => {
|
|
211
|
+
if (serial !== undefined) {
|
|
212
|
+
const s = Buffer.alloc(8)
|
|
213
|
+
s.writeBigUInt64BE(serial)
|
|
214
|
+
|
|
215
|
+
return s
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const randomBytes = crypto.getRandomValues(new Uint8Array(8))
|
|
219
|
+
return Buffer.from(randomBytes)
|
|
220
|
+
}
|
package/src/db/index.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { Certificate, Format, Identity } from "sshpk"
|
|
2
|
+
import { logger } from "../logger"
|
|
3
|
+
import type { SshCaBindings } from "../types"
|
|
4
|
+
import { D1QB } from "workers-qb"
|
|
5
|
+
import { migrations } from "./migrations"
|
|
6
|
+
|
|
7
|
+
export enum CertificateType {
|
|
8
|
+
User,
|
|
9
|
+
Host
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type CertificateRecord = {
|
|
13
|
+
id: number
|
|
14
|
+
serial: string
|
|
15
|
+
key_id: string
|
|
16
|
+
principals: string
|
|
17
|
+
extensions: string | null
|
|
18
|
+
valid_after: Date
|
|
19
|
+
valid_before: Date
|
|
20
|
+
revoked_at: Date | null
|
|
21
|
+
certificate_type: CertificateType
|
|
22
|
+
public_key: string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type DatabaseSchema = {
|
|
26
|
+
certificates: CertificateRecord
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const connect = async (env: SshCaBindings): Promise<D1QB<DatabaseSchema>> => {
|
|
30
|
+
const l = logger(env)
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
l.info("connecting to database")
|
|
34
|
+
|
|
35
|
+
const qb = new D1QB<DatabaseSchema>(env.DB)
|
|
36
|
+
const migrationBuilder = qb.migrations({ migrations })
|
|
37
|
+
const appliedMigrations = await migrationBuilder.apply()
|
|
38
|
+
|
|
39
|
+
l.info("applied database migrations", "migrations", appliedMigrations.length, "migration_names", appliedMigrations.map(m => m.name))
|
|
40
|
+
|
|
41
|
+
return qb
|
|
42
|
+
} catch (err) {
|
|
43
|
+
l.error("error connecting to database", "error", err)
|
|
44
|
+
throw err
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const dbCleanup = async (env: SshCaBindings) => {
|
|
49
|
+
const l = logger(env).with("retention", env.DB_CERTIFICATE_RETENTION)
|
|
50
|
+
|
|
51
|
+
if (env.DB_CERTIFICATE_RETENTION === "infinite") {
|
|
52
|
+
l.info("skipping database cleanup")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
l.info("starting database cleanup")
|
|
56
|
+
try {
|
|
57
|
+
const qb = await connect(env)
|
|
58
|
+
const res = await qb.delete({
|
|
59
|
+
tableName: "certificates",
|
|
60
|
+
where: {
|
|
61
|
+
conditions: "unixepoch('subsec') > unixepoch(valid_before,'subsec',?)",
|
|
62
|
+
params: env.DB_CERTIFICATE_RETENTION
|
|
63
|
+
}
|
|
64
|
+
}).execute()
|
|
65
|
+
l.info("completed database cleanup", "changes", res.meta?.changes)
|
|
66
|
+
} catch (err) {
|
|
67
|
+
l.error("error during database cleanup", "retention", env.DB_CERTIFICATE_RETENTION, "error", err)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const recordCertificate = async (env: SshCaBindings, certificate: Certificate, keyid: string, certificateType: CertificateType = CertificateType.User) => {
|
|
72
|
+
const serial = certificate.serial.readBigUInt64BE(0)
|
|
73
|
+
const subjects = certificate.subjects.map((v: Identity): string => {
|
|
74
|
+
return v.toString()
|
|
75
|
+
}).join(",")
|
|
76
|
+
const extensions = certificate.getExtensions().map((v: Format.OpenSshSignatureExt | Format.x509SignatureExt): string => {
|
|
77
|
+
// @ts-expect-error: the name property does exist
|
|
78
|
+
return v.name as string
|
|
79
|
+
}).join(",")
|
|
80
|
+
const qb = await connect(env)
|
|
81
|
+
const res = await qb.insert({
|
|
82
|
+
tableName: "certificates",
|
|
83
|
+
data: {
|
|
84
|
+
serial: `${serial}`,
|
|
85
|
+
key_id: keyid,
|
|
86
|
+
principals: subjects,
|
|
87
|
+
extensions: extensions,
|
|
88
|
+
valid_after: certificate.validFrom.toISOString(),
|
|
89
|
+
valid_before: certificate.validUntil.toISOString(),
|
|
90
|
+
certificate_type: certificateType,
|
|
91
|
+
public_key: certificate.subjectKey.toString("ssh")
|
|
92
|
+
},
|
|
93
|
+
}).execute()
|
|
94
|
+
|
|
95
|
+
if (!res.success) {
|
|
96
|
+
throw new Error("error during query")
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const isRevoked = async (env: SshCaBindings, serial: bigint): Promise<boolean> => {
|
|
101
|
+
const qb = await connect(env)
|
|
102
|
+
const res = await qb.fetchOne({
|
|
103
|
+
tableName: "certificates",
|
|
104
|
+
fields: ["serial"],
|
|
105
|
+
where: {
|
|
106
|
+
conditions: "serial = ? AND revoked_at NOTNULL",
|
|
107
|
+
params: `${serial}`
|
|
108
|
+
},
|
|
109
|
+
}).execute()
|
|
110
|
+
|
|
111
|
+
return res.results !== undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export enum RevocationStatus {
|
|
115
|
+
NotFound,
|
|
116
|
+
Revoked,
|
|
117
|
+
Revokable,
|
|
118
|
+
Unrevokable
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const revocationStatus = async (env: SshCaBindings, serial: bigint, publickey: string, certificateType: CertificateType): Promise<RevocationStatus> => {
|
|
122
|
+
const qb = await connect(env)
|
|
123
|
+
const res = await qb.fetchOne({
|
|
124
|
+
tableName: "certificates",
|
|
125
|
+
fields: ["public_key", "revoked_at"],
|
|
126
|
+
where: {
|
|
127
|
+
conditions: "serial = ? AND public_key = ? AND certificate_type = ?",
|
|
128
|
+
params: [`${serial}`, publickey, certificateType]
|
|
129
|
+
}
|
|
130
|
+
}).execute()
|
|
131
|
+
|
|
132
|
+
if (res.results === undefined) {
|
|
133
|
+
return RevocationStatus.NotFound
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (res.results.revoked_at !== null) {
|
|
137
|
+
return RevocationStatus.Revoked
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (publickey === res.results.public_key) {
|
|
141
|
+
return RevocationStatus.Revokable
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return RevocationStatus.Unrevokable
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const getRevocationList = async (env: SshCaBindings, certificateType: CertificateType): Promise<string[]> => {
|
|
148
|
+
const qb = await connect(env)
|
|
149
|
+
const res = await qb.fetchAll({
|
|
150
|
+
tableName: "certificates",
|
|
151
|
+
fields: ["serial"],
|
|
152
|
+
where: {
|
|
153
|
+
conditions: "revoked_at NOTNULL AND certificate_type = ? AND unixepoch('subsec') < unixepoch(valid_before,'subsec','24 hours')",
|
|
154
|
+
params: certificateType
|
|
155
|
+
}
|
|
156
|
+
}).execute()
|
|
157
|
+
|
|
158
|
+
if (!res.success) {
|
|
159
|
+
throw new Error("error during query")
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (res.results === undefined) {
|
|
163
|
+
return []
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return res.results.map(item => item.serial)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
type RevokedCertificate = Pick<CertificateRecord, "revoked_at">
|
|
170
|
+
|
|
171
|
+
export const revokeCertificate = async (env: SshCaBindings, serial: bigint): Promise<RevokedCertificate[]> => {
|
|
172
|
+
const qb = await connect(env)
|
|
173
|
+
const res = await qb.update<RevokedCertificate>({
|
|
174
|
+
tableName: "certificates",
|
|
175
|
+
data: {
|
|
176
|
+
revoked_at: (new Date).toISOString()
|
|
177
|
+
},
|
|
178
|
+
where: {
|
|
179
|
+
conditions: "serial = ?",
|
|
180
|
+
params: `${serial}`
|
|
181
|
+
},
|
|
182
|
+
returning: ["revoked_at"]
|
|
183
|
+
}).execute()
|
|
184
|
+
|
|
185
|
+
if (!res.success) {
|
|
186
|
+
throw new Error("error during query")
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (res.results === undefined) {
|
|
190
|
+
return []
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return res.results
|
|
194
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Migration } from "workers-qb";
|
|
2
|
+
|
|
3
|
+
export const migration: Migration = {
|
|
4
|
+
name: "0001_initial_schema",
|
|
5
|
+
sql: `
|
|
6
|
+
CREATE TABLE IF NOT EXISTS certificates (
|
|
7
|
+
id INTEGER PRIMARY KEY,
|
|
8
|
+
serial TEXT UNIQUE NOT NULL,
|
|
9
|
+
key_id TEXT NOT NULL,
|
|
10
|
+
principals TEXT NOT NULL,
|
|
11
|
+
extensions TEXT,
|
|
12
|
+
valid_after TIMESTAMP NOT NULL,
|
|
13
|
+
valid_before TIMESTAMP NOT NULL,
|
|
14
|
+
revoked_at TIMESTAMP,
|
|
15
|
+
certificate_type INTEGER NOT NULL DEFAULT 0,
|
|
16
|
+
public_key TEXT
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE INDEX IF NOT EXISTS idx_certificates_serial ON certificates(serial);
|
|
20
|
+
CREATE INDEX IF NOT EXISTS idx_certificates_valid_after ON certificates(valid_after);
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_certificates_valid_before ON certificates(valid_before);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS idx_certificates_revoked_at ON certificates(revoked_at);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_certificates_certificate_type ON certificates(certificate_type);
|
|
24
|
+
`
|
|
25
|
+
}
|