@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.
Files changed (94) hide show
  1. package/README.md +119 -0
  2. package/dist/api/operations.d.ts +2 -1
  3. package/dist/api/operations.d.ts.map +1 -1
  4. package/dist/api/operations.js +103 -14
  5. package/dist/api/operations.js.map +1 -1
  6. package/dist/api/schema.d.ts +1 -0
  7. package/dist/api/schema.d.ts.map +1 -1
  8. package/dist/api/schema.js +8 -1
  9. package/dist/api/schema.js.map +1 -1
  10. package/dist/api/v3/index.d.ts.map +1 -1
  11. package/dist/api/v3/index.js +1 -1
  12. package/dist/api/v3/index.js.map +1 -1
  13. package/dist/api/v3/schema.d.ts.map +1 -1
  14. package/dist/api/v3/schema.js +11 -7
  15. package/dist/api/v3/schema.js.map +1 -1
  16. package/dist/api/v4/index.d.ts.map +1 -1
  17. package/dist/api/v4/index.js +1 -1
  18. package/dist/api/v4/index.js.map +1 -1
  19. package/dist/api/v4/schema.d.ts.map +1 -1
  20. package/dist/api/v4/schema.js +11 -7
  21. package/dist/api/v4/schema.js.map +1 -1
  22. package/dist/db/index.d.ts +51 -4
  23. package/dist/db/index.d.ts.map +1 -1
  24. package/dist/db/index.js +133 -33
  25. package/dist/db/index.js.map +1 -1
  26. package/dist/db/migrations/0003_krl_revision.d.ts +3 -0
  27. package/dist/db/migrations/0003_krl_revision.d.ts.map +1 -0
  28. package/dist/db/migrations/0003_krl_revision.js +14 -0
  29. package/dist/db/migrations/0003_krl_revision.js.map +1 -0
  30. package/dist/db/migrations/index.d.ts.map +1 -1
  31. package/dist/db/migrations/index.js +2 -0
  32. package/dist/db/migrations/index.js.map +1 -1
  33. package/dist/db/types.d.ts +2 -0
  34. package/dist/db/types.d.ts.map +1 -1
  35. package/dist/index.d.ts +5 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +4 -2
  38. package/dist/index.js.map +1 -1
  39. package/dist/krl.d.ts +2 -1
  40. package/dist/krl.d.ts.map +1 -1
  41. package/dist/krl.js +3 -2
  42. package/dist/krl.js.map +1 -1
  43. package/dist/node/cache.d.ts +21 -0
  44. package/dist/node/cache.d.ts.map +1 -0
  45. package/dist/node/cache.js +49 -0
  46. package/dist/node/cache.js.map +1 -0
  47. package/dist/node/env.d.ts +6 -3
  48. package/dist/node/env.d.ts.map +1 -1
  49. package/dist/node/env.js +4 -3
  50. package/dist/node/env.js.map +1 -1
  51. package/dist/node/index.d.ts +1 -0
  52. package/dist/node/index.d.ts.map +1 -1
  53. package/dist/node/index.js +1 -0
  54. package/dist/node/index.js.map +1 -1
  55. package/dist/router.d.ts +25 -1
  56. package/dist/router.d.ts.map +1 -1
  57. package/dist/router.js +38 -1
  58. package/dist/router.js.map +1 -1
  59. package/dist/ssh/keys.d.ts +12 -0
  60. package/dist/ssh/keys.d.ts.map +1 -1
  61. package/dist/ssh/keys.js +28 -0
  62. package/dist/ssh/keys.js.map +1 -1
  63. package/dist/types.d.ts +53 -3
  64. package/dist/types.d.ts.map +1 -1
  65. package/dist/utils.d.ts +68 -1
  66. package/dist/utils.d.ts.map +1 -1
  67. package/dist/utils.js +166 -13
  68. package/dist/utils.js.map +1 -1
  69. package/dist/verify.d.ts +17 -5
  70. package/dist/verify.d.ts.map +1 -1
  71. package/dist/verify.js +47 -9
  72. package/dist/verify.js.map +1 -1
  73. package/openapi.json +402 -2
  74. package/package.json +1 -1
  75. package/src/api/operations.ts +115 -12
  76. package/src/api/schema.ts +9 -0
  77. package/src/api/v3/index.ts +1 -0
  78. package/src/api/v3/schema.ts +11 -4
  79. package/src/api/v4/index.ts +1 -0
  80. package/src/api/v4/schema.ts +11 -5
  81. package/src/db/index.ts +162 -37
  82. package/src/db/migrations/0003_krl_revision.ts +15 -0
  83. package/src/db/migrations/index.ts +2 -0
  84. package/src/db/types.ts +2 -0
  85. package/src/index.ts +6 -4
  86. package/src/krl.ts +3 -2
  87. package/src/node/cache.ts +62 -0
  88. package/src/node/env.ts +6 -5
  89. package/src/node/index.ts +1 -0
  90. package/src/router.ts +64 -2
  91. package/src/ssh/keys.ts +33 -0
  92. package/src/types.ts +49 -3
  93. package/src/utils.ts +188 -14
  94. 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 CertificateRecord, type DatabaseSchema, type KrlRecord } from "./types.js"
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
- export const recordCertificate = async (env: SshCaBindings, certificate: SshCertificate, keyid: string, certificateType: CertificateType = CertificateType.User) => {
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
- type RevokedCertificate = Pick<CertificateRecord, "revoked_at">
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
- const qb = await connect(env)
170
- const res = await qb.update<RevokedCertificate>({
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
- if (!res.success) {
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
- if (res.results === undefined) {
187
- return []
188
- }
270
+ return results[0]?.meta?.changes === 1 ? [{ revoked_at: revokedAt }] : []
271
+ }
189
272
 
190
- return res.results
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<bigint> => {
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: (new Date).toISOString()
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: z.string().optional(),
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 and private key, see {@link CaDatabase} and {@link CaSecret}
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
- export const createApp = (env: SshCaBindings) => {
12
- const hono = new Hono()
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
- * The bindings (environment) the CA expects, see {@link CaDatabase} and
93
- * {@link CaSecret} for the non-string bindings.
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
- JWT_AUD?: string | string[]
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