@andrewheberle/serverless-ssh-ca 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -0
- package/dist/api/operations.d.ts +2 -1
- package/dist/api/operations.d.ts.map +1 -1
- package/dist/api/operations.js +103 -14
- 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/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 +12 -0
- package/dist/ssh/keys.d.ts.map +1 -1
- package/dist/ssh/keys.js +28 -0
- 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 +115 -12
- 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/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 +33 -0
- package/src/types.ts +49 -3
- package/src/utils.ts +188 -14
- package/src/verify.ts +54 -14
package/src/db/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { D1QB } from "workers-qb"
|
|
|
6
6
|
import { z } from "zod"
|
|
7
7
|
import { migrateOnce } from "./migrate.js"
|
|
8
8
|
import { migrations } from "./migrations/index.js"
|
|
9
|
-
import { CertificateType, type
|
|
9
|
+
import { CertificateType, type DatabaseSchema, type KrlRecord } from "./types.js"
|
|
10
10
|
|
|
11
11
|
const connect = async (env: SshCaBindings): Promise<D1QB<DatabaseSchema>> => {
|
|
12
12
|
const l = logger(env)
|
|
@@ -25,6 +25,14 @@ const connect = async (env: SshCaBindings): Promise<D1QB<DatabaseSchema>> => {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// statements that increment the KRL revision, so a KRL cached for an earlier
|
|
29
|
+
// revision is not used again
|
|
30
|
+
const BumpAllRevisions = "UPDATE krl SET revision = revision + 1"
|
|
31
|
+
|
|
32
|
+
// increments the revision of the KRL for a certificate type, but only if the
|
|
33
|
+
// previous statement in the same batch changed a row
|
|
34
|
+
const BumpRevisionIfChanged = "UPDATE krl SET revision = revision + 1 WHERE certificate_type = ? AND changes() = 1"
|
|
35
|
+
|
|
28
36
|
export const dbCleanup = async (env: SshCaBindings) => {
|
|
29
37
|
const l = logger(env).with("retention", env.DB_CERTIFICATE_RETENTION)
|
|
30
38
|
|
|
@@ -44,28 +52,48 @@ export const dbCleanup = async (env: SshCaBindings) => {
|
|
|
44
52
|
}
|
|
45
53
|
}).execute()
|
|
46
54
|
l.info("completed database cleanup", "changes", res.meta?.changes)
|
|
55
|
+
|
|
56
|
+
// revoked certificates may have been removed from the KRLs, so cached
|
|
57
|
+
// KRLs must not be used again
|
|
58
|
+
if ((res.meta?.changes ?? 0) > 0) {
|
|
59
|
+
await env.DB.prepare(BumpAllRevisions).run()
|
|
60
|
+
}
|
|
47
61
|
} catch (err) {
|
|
48
62
|
l.error("error during database cleanup", "retention", env.DB_CERTIFICATE_RETENTION, "error", err)
|
|
49
63
|
}
|
|
50
64
|
}
|
|
51
65
|
|
|
52
|
-
|
|
66
|
+
// the columns recorded for an issued certificate, in the order of the values
|
|
67
|
+
// returned by certificateValues
|
|
68
|
+
const CertificateColumns = ["serial", "key_id", "principals", "extensions", "valid_after", "valid_before", "certificate_type", "public_key"] as const
|
|
69
|
+
|
|
70
|
+
const certificateRecord = (certificate: SshCertificate, keyid: string, certificateType: CertificateType) => {
|
|
53
71
|
// principals are recorded as distinguished names, as they were when
|
|
54
72
|
// certificates were created with sshpk
|
|
55
73
|
const principals = certificate.principals.map((p) => `${certificate.kind === "host" ? "CN" : "UID"}=${p}`).join(",")
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
serial: `${certificate.serial}`,
|
|
77
|
+
key_id: keyid,
|
|
78
|
+
principals: principals,
|
|
79
|
+
extensions: certificate.extensions.join(","),
|
|
80
|
+
valid_after: certificate.validAfter.toISOString(),
|
|
81
|
+
valid_before: certificate.validBefore.toISOString(),
|
|
82
|
+
certificate_type: certificateType,
|
|
83
|
+
public_key: certificate.publicKey.toString()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const certificateValues = (certificate: SshCertificate, keyid: string, certificateType: CertificateType): (string | number)[] => {
|
|
88
|
+
const record = certificateRecord(certificate, keyid, certificateType)
|
|
89
|
+
return CertificateColumns.map((column) => record[column])
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const recordCertificate = async (env: SshCaBindings, certificate: SshCertificate, keyid: string, certificateType: CertificateType = CertificateType.User) => {
|
|
56
93
|
const qb = await connect(env)
|
|
57
94
|
const res = await qb.insert({
|
|
58
95
|
tableName: "certificates",
|
|
59
|
-
data:
|
|
60
|
-
serial: `${certificate.serial}`,
|
|
61
|
-
key_id: keyid,
|
|
62
|
-
principals: principals,
|
|
63
|
-
extensions: certificate.extensions.join(","),
|
|
64
|
-
valid_after: certificate.validAfter.toISOString(),
|
|
65
|
-
valid_before: certificate.validBefore.toISOString(),
|
|
66
|
-
certificate_type: certificateType,
|
|
67
|
-
public_key: certificate.publicKey.toString()
|
|
68
|
-
},
|
|
96
|
+
data: certificateRecord(certificate, keyid, certificateType),
|
|
69
97
|
}).execute()
|
|
70
98
|
|
|
71
99
|
if (!res.success) {
|
|
@@ -73,6 +101,49 @@ export const recordCertificate = async (env: SshCaBindings, certificate: SshCert
|
|
|
73
101
|
}
|
|
74
102
|
}
|
|
75
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Records a host certificate issued by renewing another, revoking the
|
|
106
|
+
* certificate that was renewed so it cannot be used or renewed again.
|
|
107
|
+
*
|
|
108
|
+
* This is done in a single transaction, and the renewed certificate is only
|
|
109
|
+
* recorded if this call revoked the previous certificate, so when a
|
|
110
|
+
* certificate is renewed more than once concurrently only one renewal is
|
|
111
|
+
* recorded. A previous certificate that was never recorded (for example one
|
|
112
|
+
* signed with the CA key outside of the CA) is recorded so that it can be
|
|
113
|
+
* revoked.
|
|
114
|
+
*
|
|
115
|
+
* @param env The CA bindings
|
|
116
|
+
* @param previous The certificate that was presented for renewal
|
|
117
|
+
* @param certificate The certificate issued by the renewal
|
|
118
|
+
* @returns true if the renewal was recorded, or false if the previous certificate was already revoked
|
|
119
|
+
*/
|
|
120
|
+
export const recordRenewal = async (env: SshCaBindings, previous: SshCertificate, certificate: SshCertificate): Promise<boolean> => {
|
|
121
|
+
// ensure migrations have been applied
|
|
122
|
+
await connect(env)
|
|
123
|
+
|
|
124
|
+
const columns = CertificateColumns.join(", ")
|
|
125
|
+
const placeholders = CertificateColumns.map(() => "?").join(", ")
|
|
126
|
+
const revokedAt = (new Date).toISOString()
|
|
127
|
+
|
|
128
|
+
const results = await env.DB.batch([
|
|
129
|
+
env.DB.prepare(`INSERT INTO certificates (${columns}) VALUES (${placeholders}) ON CONFLICT (serial) DO NOTHING`)
|
|
130
|
+
.bind(...certificateValues(previous, previous.keyId, CertificateType.Host)),
|
|
131
|
+
env.DB.prepare("UPDATE certificates SET revoked_at = ? WHERE serial = ? AND certificate_type = ? AND revoked_at IS NULL")
|
|
132
|
+
.bind(revokedAt, `${previous.serial}`, CertificateType.Host),
|
|
133
|
+
// changes() is the number of rows revoked by the previous statement
|
|
134
|
+
env.DB.prepare(`INSERT INTO certificates (${columns}) SELECT ${placeholders} WHERE changes() = 1`)
|
|
135
|
+
.bind(...certificateValues(certificate, certificate.keyId, CertificateType.Host)),
|
|
136
|
+
// the previous certificate was revoked if the renewed one was recorded
|
|
137
|
+
env.DB.prepare(BumpRevisionIfChanged).bind(CertificateType.Host),
|
|
138
|
+
])
|
|
139
|
+
|
|
140
|
+
if (results.some((res) => !res.success)) {
|
|
141
|
+
throw new Error("error during query")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return results[2]?.meta?.changes === 1
|
|
145
|
+
}
|
|
146
|
+
|
|
76
147
|
export const isRevoked = async (env: SshCaBindings, serial: bigint): Promise<boolean> => {
|
|
77
148
|
const qb = await connect(env)
|
|
78
149
|
const res = await qb.fetchOne({
|
|
@@ -163,38 +234,89 @@ export const getRevocationList = async (env: SshCaBindings, certificateType: Cer
|
|
|
163
234
|
return res.results.map(item => item.serial)
|
|
164
235
|
}
|
|
165
236
|
|
|
166
|
-
|
|
237
|
+
/** The time a certificate was revoked, as an ISO 8601 string */
|
|
238
|
+
type RevokedCertificate = { revoked_at: string }
|
|
167
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Revokes a certificate that has not already been revoked.
|
|
242
|
+
*
|
|
243
|
+
* A certificate that is already revoked is left unchanged, so when several
|
|
244
|
+
* requests revoke the same certificate concurrently only one of them revokes
|
|
245
|
+
* it and the time it was first revoked is kept.
|
|
246
|
+
*
|
|
247
|
+
* @param env The CA bindings
|
|
248
|
+
* @param serial The serial of the certificate to revoke
|
|
249
|
+
* @returns The revocation time of the certificate, or an empty array if it was not revoked by this call
|
|
250
|
+
*/
|
|
168
251
|
export const revokeCertificate = async (env: SshCaBindings, serial: bigint): Promise<RevokedCertificate[]> => {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
tableName: "certificates",
|
|
172
|
-
data: {
|
|
173
|
-
revoked_at: (new Date).toISOString()
|
|
174
|
-
},
|
|
175
|
-
where: {
|
|
176
|
-
conditions: "serial = ?",
|
|
177
|
-
params: `${serial}`
|
|
178
|
-
},
|
|
179
|
-
returning: ["revoked_at"]
|
|
180
|
-
}).execute()
|
|
252
|
+
// ensure migrations have been applied
|
|
253
|
+
await connect(env)
|
|
181
254
|
|
|
182
|
-
|
|
255
|
+
const revokedAt = (new Date).toISOString()
|
|
256
|
+
|
|
257
|
+
// the KRL revision is incremented in the same transaction, so a cached KRL
|
|
258
|
+
// that does not include this certificate is never used again
|
|
259
|
+
const results = await env.DB.batch([
|
|
260
|
+
env.DB.prepare("UPDATE certificates SET revoked_at = ? WHERE serial = ? AND revoked_at IS NULL")
|
|
261
|
+
.bind(revokedAt, `${serial}`),
|
|
262
|
+
env.DB.prepare("UPDATE krl SET revision = revision + 1 WHERE certificate_type = (SELECT certificate_type FROM certificates WHERE serial = ?) AND changes() = 1")
|
|
263
|
+
.bind(`${serial}`),
|
|
264
|
+
])
|
|
265
|
+
|
|
266
|
+
if (results.some((res) => !res.success)) {
|
|
183
267
|
throw new Error("error during query")
|
|
184
268
|
}
|
|
185
269
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
270
|
+
return results[0]?.meta?.changes === 1 ? [{ revoked_at: revokedAt }] : []
|
|
271
|
+
}
|
|
189
272
|
|
|
190
|
-
|
|
273
|
+
const KrlRevisionRow = z.object({
|
|
274
|
+
revision: z.number().int().nonnegative(),
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Returns the revision of the KRL for a certificate type, which is
|
|
279
|
+
* incremented whenever a certificate of that type is revoked.
|
|
280
|
+
*
|
|
281
|
+
* @param env The CA bindings
|
|
282
|
+
* @param certificateType The type of certificates in the KRL
|
|
283
|
+
* @returns The current revision
|
|
284
|
+
*/
|
|
285
|
+
export const getKrlRevision = async (env: SshCaBindings, certificateType: CertificateType): Promise<bigint> => {
|
|
286
|
+
const qb = await connect(env)
|
|
287
|
+
const res = await qb.fetchOne({
|
|
288
|
+
tableName: "krl",
|
|
289
|
+
fields: ["revision"],
|
|
290
|
+
where: {
|
|
291
|
+
conditions: "certificate_type = ?",
|
|
292
|
+
params: certificateType
|
|
293
|
+
}
|
|
294
|
+
}).execute()
|
|
295
|
+
|
|
296
|
+
return BigInt(KrlRevisionRow.parse(res.results).revision)
|
|
191
297
|
}
|
|
192
298
|
|
|
193
299
|
const KrlVersionRow = z.object({
|
|
194
300
|
version: z.number().int().nonnegative(),
|
|
195
301
|
digest: z.string().nullable(),
|
|
302
|
+
updated_at: z.string(),
|
|
196
303
|
})
|
|
197
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Parses a timestamp from the database, which is either an ISO 8601 string or,
|
|
307
|
+
* when set by SQLite's CURRENT_TIMESTAMP, "YYYY-MM-DD HH:MM:SS" in UTC.
|
|
308
|
+
*/
|
|
309
|
+
const parseTimestamp = (value: string): Date => {
|
|
310
|
+
return new Date(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value) ? `${value.replace(" ", "T")}Z` : value)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The version of a KRL and when that version was generated */
|
|
314
|
+
export type KrlVersion = {
|
|
315
|
+
version: bigint
|
|
316
|
+
/** When the version was first generated, which is the same for every KRL with this version */
|
|
317
|
+
generatedAt: Date
|
|
318
|
+
}
|
|
319
|
+
|
|
198
320
|
// the number of times to retry when another request updates the KRL version concurrently
|
|
199
321
|
const KRL_VERSION_ATTEMPTS = 5
|
|
200
322
|
|
|
@@ -209,15 +331,16 @@ const KRL_VERSION_ATTEMPTS = 5
|
|
|
209
331
|
* @param env The bindings for the CA, see {@link SshCaBindings}
|
|
210
332
|
* @param certificateType The type of certificates in the KRL, see {@link CertificateType}
|
|
211
333
|
* @param digest A digest of the content of the KRL, which changes whenever the content does
|
|
212
|
-
* @returns The version to use for the KRL
|
|
334
|
+
* @returns The version to use for the KRL, and when that version was first
|
|
335
|
+
* generated so that every KRL with the version has the same generation date
|
|
213
336
|
*/
|
|
214
|
-
export const getKrlVersion = async (env: SshCaBindings, certificateType: CertificateType, digest: string): Promise<
|
|
337
|
+
export const getKrlVersion = async (env: SshCaBindings, certificateType: CertificateType, digest: string): Promise<KrlVersion> => {
|
|
215
338
|
const qb = await connect(env)
|
|
216
339
|
|
|
217
340
|
for (let attempt = 0; attempt < KRL_VERSION_ATTEMPTS; attempt++) {
|
|
218
341
|
const res = await qb.fetchOne({
|
|
219
342
|
tableName: "krl",
|
|
220
|
-
fields: ["version", "digest"],
|
|
343
|
+
fields: ["version", "digest", "updated_at"],
|
|
221
344
|
where: {
|
|
222
345
|
conditions: "certificate_type = ?",
|
|
223
346
|
params: certificateType
|
|
@@ -226,17 +349,19 @@ export const getKrlVersion = async (env: SshCaBindings, certificateType: Certifi
|
|
|
226
349
|
|
|
227
350
|
const current = KrlVersionRow.parse(res.results)
|
|
228
351
|
if (current.digest === digest) {
|
|
229
|
-
return BigInt(current.version)
|
|
352
|
+
return { version: BigInt(current.version), generatedAt: parseTimestamp(current.updated_at) }
|
|
230
353
|
}
|
|
231
354
|
|
|
232
|
-
// only update if the version has not been changed by another request
|
|
355
|
+
// only update if the version has not been changed by another request,
|
|
356
|
+
// recording the time to the second as that is all a KRL records
|
|
233
357
|
const next = current.version + 1
|
|
358
|
+
const generatedAt = new Date(Math.floor(Date.now() / 1000) * 1000)
|
|
234
359
|
const updated = await qb.update<Pick<KrlRecord, "version">>({
|
|
235
360
|
tableName: "krl",
|
|
236
361
|
data: {
|
|
237
362
|
version: next,
|
|
238
363
|
digest: digest,
|
|
239
|
-
updated_at:
|
|
364
|
+
updated_at: generatedAt.toISOString()
|
|
240
365
|
},
|
|
241
366
|
where: {
|
|
242
367
|
conditions: "certificate_type = ? AND version = ?",
|
|
@@ -250,7 +375,7 @@ export const getKrlVersion = async (env: SshCaBindings, certificateType: Certifi
|
|
|
250
375
|
}
|
|
251
376
|
|
|
252
377
|
if (updated.results !== undefined && updated.results.length > 0) {
|
|
253
|
-
return BigInt(next)
|
|
378
|
+
return { version: BigInt(next), generatedAt }
|
|
254
379
|
}
|
|
255
380
|
}
|
|
256
381
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Migration } from "../migrate.js"
|
|
2
|
+
|
|
3
|
+
export const migration: Migration = {
|
|
4
|
+
name: "0003_krl_revision",
|
|
5
|
+
statements: [
|
|
6
|
+
// incremented whenever a certificate is revoked, so a cached KRL can be
|
|
7
|
+
// keyed by the revision it was generated for
|
|
8
|
+
"ALTER TABLE krl ADD COLUMN revision INTEGER NOT NULL DEFAULT 0",
|
|
9
|
+
// KRLs were previously generated at the time of each request, but are
|
|
10
|
+
// now generated at the time their version last changed. Starting each
|
|
11
|
+
// version again from now ensures a KRL fetched before this migration
|
|
12
|
+
// is never newer than the same version fetched afterwards.
|
|
13
|
+
"UPDATE krl SET updated_at = CURRENT_TIMESTAMP",
|
|
14
|
+
]
|
|
15
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { Migration } from "../migrate.js"
|
|
2
2
|
import { migration as initialSchema0001 } from "./0001_initial_schema.js"
|
|
3
3
|
import { migration as krlVersion0002 } from "./0002_krl_version.js"
|
|
4
|
+
import { migration as krlRevision0003 } from "./0003_krl_revision.js"
|
|
4
5
|
|
|
5
6
|
export const migrations: Migration[] = [
|
|
6
7
|
initialSchema0001,
|
|
7
8
|
krlVersion0002,
|
|
9
|
+
krlRevision0003,
|
|
8
10
|
]
|
package/src/db/types.ts
CHANGED
|
@@ -31,6 +31,8 @@ export type KrlRecord = {
|
|
|
31
31
|
version: number
|
|
32
32
|
digest: string | null
|
|
33
33
|
updated_at: Date
|
|
34
|
+
/** Incremented whenever a certificate of this type is revoked */
|
|
35
|
+
revision: number
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
/** The tables in the database, used to type queries made with workers-qb */
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ExecutionContext } from "hono"
|
|
2
2
|
import { dbCleanup } from "./db/index.js"
|
|
3
|
-
import { createApp } from "./router.js"
|
|
3
|
+
import { createApp, type SshCaOptions } from "./router.js"
|
|
4
4
|
import type { SshCaBindings } from "./types.js"
|
|
5
|
-
export type { CaDatabase, CaDatabaseQueryResult, CaDatabaseResult, CaPreparedStatement, CaSecret, SshCaBindings } from "./types.js"
|
|
5
|
+
export type { CaCache, CaDatabase, CaDatabaseQueryResult, CaDatabaseResult, CaPreparedStatement, CaRateLimiter, CaSecret, SshCaBindings } from "./types.js"
|
|
6
|
+
export { cloudflareClientIp, type RateLimitKey, type SshCaOptions } from "./router.js"
|
|
6
7
|
|
|
7
8
|
// the handlers the CA exposes, compatible with a Cloudflare Workers ExportedHandler
|
|
8
9
|
type SshCaHandler = {
|
|
@@ -35,10 +36,11 @@ export interface SshCa {
|
|
|
35
36
|
* the runtime on each request.
|
|
36
37
|
*
|
|
37
38
|
* @param env The bindings (configuration, database and private key) for the CA, see {@link SshCaBindings}
|
|
39
|
+
* @param options Options for the CA, such as the key requests are rate limited by, see {@link SshCaOptions}
|
|
38
40
|
* @returns The CA's request handler and database cleanup task, see {@link SshCa}
|
|
39
41
|
*/
|
|
40
|
-
export const createSshCa = (env: SshCaBindings): SshCa => {
|
|
41
|
-
const app = createApp(env)
|
|
42
|
+
export const createSshCa = (env: SshCaBindings, options: SshCaOptions = {}): SshCa => {
|
|
43
|
+
const app = createApp(env, options)
|
|
42
44
|
|
|
43
45
|
return {
|
|
44
46
|
fetch: async (req: Request): Promise<Response> => await app.fetch(req, env),
|
package/src/krl.ts
CHANGED
|
@@ -150,14 +150,15 @@ export class KRLBuilder {
|
|
|
150
150
|
*
|
|
151
151
|
* @param krlVersion Optional KRL version number (default: 1).
|
|
152
152
|
* @param comment Optional comment string embedded in the header.
|
|
153
|
+
* @param generatedAt Optional time the KRL was generated (default: now).
|
|
153
154
|
*/
|
|
154
|
-
generate(krlVersion: bigint = 1n, comment = ""): ArrayBuffer {
|
|
155
|
+
generate(krlVersion: bigint = 1n, comment = "", generatedAt?: Date): ArrayBuffer {
|
|
155
156
|
if (this._krl !== null) {
|
|
156
157
|
return this._krl
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
// ── Header ──────────────────────────────────────────────────────────────
|
|
160
|
-
const generatedDate = BigInt(Math.floor(Date.now() / 1000))
|
|
161
|
+
const generatedDate = BigInt(Math.floor((generatedAt?.getTime() ?? Date.now()) / 1000))
|
|
161
162
|
|
|
162
163
|
const header = concat(
|
|
163
164
|
encodeUint64(KRL_MAGIC),
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { CaCache } from "../types.js"
|
|
2
|
+
|
|
3
|
+
type Entry = {
|
|
4
|
+
value: string
|
|
5
|
+
/** When the entry expires in milliseconds since the Unix epoch, if it does */
|
|
6
|
+
expires: number | undefined
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create an in-memory cache for the `CACHE` binding, with the same shape as a
|
|
11
|
+
* Cloudflare Workers KV namespace (see {@link CaCache}).
|
|
12
|
+
*
|
|
13
|
+
* Entries are kept in memory by the process, so they are lost on restart and
|
|
14
|
+
* not shared between processes.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* const env = bindingsFromEnv({
|
|
19
|
+
* DB: fromNodeSqlite(new DatabaseSync("ssh-ca.sqlite")),
|
|
20
|
+
* PRIVATE_KEY: secretFromFile("/etc/ssh-ca/ca_key"),
|
|
21
|
+
* CACHE: cacheInMemory(),
|
|
22
|
+
* })
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @returns A cache for the `CACHE` binding, see {@link CaCache}
|
|
26
|
+
*/
|
|
27
|
+
export const cacheInMemory = (): CaCache => {
|
|
28
|
+
const entries = new Map<string, Entry>()
|
|
29
|
+
|
|
30
|
+
const expired = (entry: Entry, now: number): boolean => entry.expires !== undefined && now >= entry.expires
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
get: async (key: string): Promise<string | null> => {
|
|
34
|
+
const entry = entries.get(key)
|
|
35
|
+
if (entry === undefined) {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (expired(entry, Date.now())) {
|
|
40
|
+
entries.delete(key)
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return entry.value
|
|
45
|
+
},
|
|
46
|
+
put: async (key: string, value: string, options?: { expirationTtl?: number }): Promise<void> => {
|
|
47
|
+
const now = Date.now()
|
|
48
|
+
|
|
49
|
+
// remove entries that have expired, as they are not read again
|
|
50
|
+
for (const [k, entry] of entries) {
|
|
51
|
+
if (expired(entry, now)) {
|
|
52
|
+
entries.delete(k)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
entries.set(key, {
|
|
57
|
+
value,
|
|
58
|
+
expires: options?.expirationTtl === undefined ? undefined : now + options.expirationTtl * 1000,
|
|
59
|
+
})
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/node/env.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { seconds } from "itty-time"
|
|
2
2
|
import z from "zod"
|
|
3
|
-
import type { CaDatabase, CaSecret, SshCaBindings } from "../types.js"
|
|
3
|
+
import type { CaCache, CaDatabase, CaRateLimiter, CaSecret, SshCaBindings } from "../types.js"
|
|
4
4
|
|
|
5
5
|
const required = z.string().trim().min(1, { message: "must be set" })
|
|
6
6
|
|
|
@@ -32,7 +32,8 @@ export const EnvSchema = z.object({
|
|
|
32
32
|
SSH_HOST_CERTIFICATE_ALLOWED_ROLES: z.string().optional(),
|
|
33
33
|
ISSUER_DN: required,
|
|
34
34
|
JWT_JWKS_URL: z.url({ protocol: /^https?$/, message: "must be an http or https URL" }),
|
|
35
|
-
JWT_AUD:
|
|
35
|
+
JWT_AUD: required,
|
|
36
|
+
JWT_ACCESS_TOKEN_AUD: z.string().optional(),
|
|
36
37
|
JWT_ISSUER: required,
|
|
37
38
|
JWT_ALGORITHMS: required,
|
|
38
39
|
JWT_SSH_CERTIFICATE_PRINCIPALS_CLAIM: required,
|
|
@@ -72,13 +73,13 @@ export class InvalidConfigurationError extends Error {
|
|
|
72
73
|
* })
|
|
73
74
|
* ```
|
|
74
75
|
*
|
|
75
|
-
* @param bindings The database
|
|
76
|
+
* @param bindings The database, private key, optional rate limiter and optional cache, see {@link CaDatabase}, {@link CaSecret}, {@link CaRateLimiter} and {@link CaCache}
|
|
76
77
|
* @param source The environment variables to read, `process.env` by default
|
|
77
78
|
* @returns The bindings to pass to `createSshCa`
|
|
78
79
|
* @throws {@link InvalidConfigurationError} if a variable is missing or invalid
|
|
79
80
|
*/
|
|
80
81
|
export const bindingsFromEnv = (
|
|
81
|
-
bindings: { DB: CaDatabase, PRIVATE_KEY: CaSecret },
|
|
82
|
+
bindings: { DB: CaDatabase, PRIVATE_KEY: CaSecret, RATE_LIMITER?: CaRateLimiter, CACHE?: CaCache },
|
|
82
83
|
source: Record<string, string | undefined> = process.env,
|
|
83
84
|
): SshCaBindings => {
|
|
84
85
|
const result = EnvSchema.safeParse(source)
|
|
@@ -86,5 +87,5 @@ export const bindingsFromEnv = (
|
|
|
86
87
|
throw new InvalidConfigurationError(`invalid configuration:\n${z.prettifyError(result.error)}`)
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
return { ...result.data, DB: bindings.DB, PRIVATE_KEY: bindings.PRIVATE_KEY }
|
|
90
|
+
return { ...result.data, DB: bindings.DB, PRIVATE_KEY: bindings.PRIVATE_KEY, RATE_LIMITER: bindings.RATE_LIMITER, CACHE: bindings.CACHE }
|
|
90
91
|
}
|
package/src/node/index.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* @packageDocumentation
|
|
18
18
|
*/
|
|
19
|
+
export { cacheInMemory } from "./cache.js"
|
|
19
20
|
export { bindingsFromEnv, EnvSchema, InvalidConfigurationError } from "./env.js"
|
|
20
21
|
export type { CaEnv } from "./env.js"
|
|
21
22
|
export { secretFromFile, secretFromString } from "./secret.js"
|
package/src/router.ts
CHANGED
|
@@ -4,12 +4,74 @@ import { createApi as apiv3 } from "./api/v3/index.js"
|
|
|
4
4
|
import { BasePath as v4BasePath, createApi as apiv4 } from "./api/v4/index.js"
|
|
5
5
|
import { HTTPException } from "hono/http-exception"
|
|
6
6
|
import { logger } from "./logger.js"
|
|
7
|
+
import { TooManyRequestsException } from "chanfana"
|
|
7
8
|
import type { SshCaBindings } from "./types.js"
|
|
8
9
|
|
|
9
10
|
export type AppContext = Context<{ Bindings: SshCaBindings }>
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Returns the key a request is rate limited by, or undefined if the request
|
|
14
|
+
* should not be rate limited.
|
|
15
|
+
*/
|
|
16
|
+
export type RateLimitKey = (req: Request) => string | undefined
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Options for the CA that are not part of its bindings.
|
|
20
|
+
*/
|
|
21
|
+
export interface SshCaOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Returns the key requests are rate limited by when `RATE_LIMITER` is
|
|
24
|
+
* set. Defaults to the client IP address in the `CF-Connecting-IP` header,
|
|
25
|
+
* which is set by Cloudflare, so other runtimes should provide one that
|
|
26
|
+
* suits their limiter, for example using a header set by a reverse proxy.
|
|
27
|
+
*/
|
|
28
|
+
rateLimitKey?: RateLimitKey
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Returns the client IP address set by Cloudflare in the `CF-Connecting-IP`
|
|
33
|
+
* header, which is the default key requests are rate limited by.
|
|
34
|
+
*/
|
|
35
|
+
export const cloudflareClientIp: RateLimitKey = (req) => req.headers.get("CF-Connecting-IP") ?? undefined
|
|
36
|
+
|
|
37
|
+
// requests that only read from the CA, which are not rate limited
|
|
38
|
+
const ReadMethods = new Set(["GET", "HEAD", "OPTIONS"])
|
|
39
|
+
|
|
40
|
+
export const createApp = (env: SshCaBindings, options: SshCaOptions = {}) => {
|
|
41
|
+
const hono = new Hono<{ Bindings: SshCaBindings }>()
|
|
42
|
+
const rateLimitKey = options.rateLimitKey ?? cloudflareClientIp
|
|
43
|
+
|
|
44
|
+
// limit requests to issue, renew and revoke certificates
|
|
45
|
+
hono.use("/api/*", async (c, next) => {
|
|
46
|
+
const limiter = c.env.RATE_LIMITER
|
|
47
|
+
if (limiter === undefined || ReadMethods.has(c.req.method)) {
|
|
48
|
+
return await next()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const l = logger(env).with("in", "rateLimit")
|
|
52
|
+
const key = rateLimitKey(c.req.raw)
|
|
53
|
+
if (key === undefined) {
|
|
54
|
+
l.debug("no rate limit key for request")
|
|
55
|
+
return await next()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let success: boolean
|
|
59
|
+
try {
|
|
60
|
+
success = (await limiter.limit({ key })).success
|
|
61
|
+
} catch (err) {
|
|
62
|
+
// a failing limiter must not stop certificates being issued or revoked
|
|
63
|
+
l.error("rate limiter failed", "error", err)
|
|
64
|
+
return await next()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!success) {
|
|
68
|
+
l.warn("request was rate limited", "key", key, "method", c.req.method, "path", c.req.path)
|
|
69
|
+
const err = new TooManyRequestsException()
|
|
70
|
+
return c.json({ success: false, errors: err.buildResponse(), result: {} }, 429)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return await next()
|
|
74
|
+
})
|
|
13
75
|
|
|
14
76
|
// add error handling
|
|
15
77
|
hono.onError((err, c) => {
|
package/src/ssh/keys.ts
CHANGED
|
@@ -118,6 +118,39 @@ export class PublicKey {
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* The smallest RSA key, in bits, that the CA will issue a certificate for.
|
|
123
|
+
* This matches the minimum accepted by ssh-ca-client.
|
|
124
|
+
*/
|
|
125
|
+
export const MinimumRsaKeyBits = 2048
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Returns the size in bits of the modulus of an RSA public key, or undefined
|
|
129
|
+
* if the key is not an RSA key.
|
|
130
|
+
*
|
|
131
|
+
* @param key The public key
|
|
132
|
+
*/
|
|
133
|
+
export const rsaKeyBits = (key: PublicKey): number | undefined => {
|
|
134
|
+
if (key.type !== "ssh-rsa") {
|
|
135
|
+
return undefined
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// the fields of an RSA key are the exponent and the modulus, an mpint
|
|
139
|
+
// that may have a leading zero byte to keep it positive
|
|
140
|
+
const modulus = decodePublicKey(key.blob).fields[1]
|
|
141
|
+
if (modulus === undefined) {
|
|
142
|
+
return undefined
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const start = modulus.findIndex((byte) => byte !== 0)
|
|
146
|
+
const first = modulus[start]
|
|
147
|
+
if (start === -1 || first === undefined) {
|
|
148
|
+
return 0
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return (modulus.length - start - 1) * 8 + first.toString(2).length
|
|
152
|
+
}
|
|
153
|
+
|
|
121
154
|
/** The ECDSA curves that can be used to sign */
|
|
122
155
|
export type EcdsaCurve = "nistp256" | "nistp384" | "nistp521"
|
|
123
156
|
|
package/src/types.ts
CHANGED
|
@@ -89,13 +89,49 @@ export interface CaSecret {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
92
|
+
* A rate limiter, with the same shape as a Cloudflare Workers rate limit
|
|
93
|
+
* binding.
|
|
94
|
+
*
|
|
95
|
+
* A Workers `RateLimit` binding satisfies this structurally. On other
|
|
96
|
+
* runtimes any limiter with the same shape may be used.
|
|
97
|
+
*/
|
|
98
|
+
export interface CaRateLimiter {
|
|
99
|
+
/**
|
|
100
|
+
* Counts a request against the limit for a key
|
|
101
|
+
*
|
|
102
|
+
* @param options The key the request is counted against, for example the client IP address
|
|
103
|
+
* @returns Whether the request is within the limit
|
|
104
|
+
*/
|
|
105
|
+
limit(options: { key: string }): Promise<{ success: boolean }>
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A key-value cache, with the subset of a Cloudflare Workers KV namespace
|
|
110
|
+
* binding used by the CA.
|
|
111
|
+
*
|
|
112
|
+
* A Workers `KVNamespace` binding satisfies this structurally. On other
|
|
113
|
+
* runtimes any cache with the same shape may be used, such as the in-memory
|
|
114
|
+
* cache returned by `cacheInMemory` from `@andrewheberle/serverless-ssh-ca/node`.
|
|
115
|
+
*/
|
|
116
|
+
export interface CaCache {
|
|
117
|
+
/** Returns the value for a key, or null if there is none */
|
|
118
|
+
get(key: string): Promise<string | null>
|
|
119
|
+
/** Sets the value for a key, which may expire after `expirationTtl` seconds */
|
|
120
|
+
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The bindings (environment) the CA expects, see {@link CaDatabase},
|
|
125
|
+
* {@link CaSecret}, {@link CaRateLimiter} and {@link CaCache} for the non-string
|
|
126
|
+
* bindings.
|
|
94
127
|
*/
|
|
95
128
|
export interface SshCaBindings {
|
|
96
129
|
CERTIFICATE_REQUEST_TIME_SKEW_MAX: string
|
|
97
130
|
JWT_ALGORITHMS: string | string[]
|
|
98
|
-
|
|
131
|
+
/** The audience the identity token, and unless {@link SshCaBindings.JWT_ACCESS_TOKEN_AUD} is set the access token, must be issued for */
|
|
132
|
+
JWT_AUD: string | string[]
|
|
133
|
+
/** The audience the access token must be issued for, when it differs from {@link SshCaBindings.JWT_AUD} */
|
|
134
|
+
JWT_ACCESS_TOKEN_AUD?: string | string[]
|
|
99
135
|
JWT_ISSUER: string
|
|
100
136
|
JWT_JWKS_URL: string
|
|
101
137
|
JWT_SSH_CERTIFICATE_PRINCIPALS_CLAIM: string
|
|
@@ -104,6 +140,16 @@ export interface SshCaBindings {
|
|
|
104
140
|
DB: CaDatabase
|
|
105
141
|
ISSUER_DN: string
|
|
106
142
|
PRIVATE_KEY: CaSecret
|
|
143
|
+
/**
|
|
144
|
+
* Limits requests to issue, renew and revoke certificates, see
|
|
145
|
+
* {@link CaRateLimiter}. Requests are not limited when this is not set.
|
|
146
|
+
*/
|
|
147
|
+
RATE_LIMITER?: CaRateLimiter
|
|
148
|
+
/**
|
|
149
|
+
* Caches KRLs until a certificate is revoked, see {@link CaCache}. KRLs
|
|
150
|
+
* are generated for every request when this is not set.
|
|
151
|
+
*/
|
|
152
|
+
CACHE?: CaCache
|
|
107
153
|
SSH_CERTIFICATE_EXTENSIONS: string
|
|
108
154
|
SSH_CERTIFICATE_INCLUDE_SELF_EMAIL?: string | boolean
|
|
109
155
|
SSH_CERTIFICATE_INCLUDE_SELF?: string | boolean
|