@andrewheberle/serverless-ssh-ca 0.21.0 → 0.22.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.
@@ -24,6 +24,43 @@ import {
24
24
  } from "chanfana"
25
25
  import { seconds } from "itty-time"
26
26
  import { isRevoked } from "../../db/index.js"
27
+ import { CertificateType } from "../../db/types.js"
28
+
29
+ /**
30
+ * Returns the name of a {@link CertificateType} as used in API paths and logs
31
+ * @param certificateType The type of certificate
32
+ * @returns "user" or "host"
33
+ */
34
+ export const certificateTypeName = (certificateType: CertificateType): "user" | "host" => {
35
+ switch (certificateType) {
36
+ case CertificateType.User:
37
+ return "user"
38
+ case CertificateType.Host:
39
+ return "host"
40
+ default: {
41
+ const unknownType: never = certificateType
42
+ throw new Error(`unknown certificate type: ${String(unknownType)}`)
43
+ }
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Returns the OpenAPI tag that groups the endpoints for a {@link CertificateType}
49
+ * @param certificateType The type of certificate
50
+ * @returns "User" or "Host"
51
+ */
52
+ const certificateTypeTag = (certificateType: CertificateType): "User" | "Host" => {
53
+ switch (certificateType) {
54
+ case CertificateType.User:
55
+ return "User"
56
+ case CertificateType.Host:
57
+ return "Host"
58
+ default: {
59
+ const unknownType: never = certificateType
60
+ throw new Error(`unknown certificate type: ${String(unknownType)}`)
61
+ }
62
+ }
63
+ }
27
64
 
28
65
  const openapiStringByte = z.base64()
29
66
  .transform((v) => {
@@ -80,6 +117,7 @@ const certificate = openapiStringByte
80
117
  .meta({ description: "Issued Certificate" })
81
118
 
82
119
  export const CaPublicKeyEndpointSchema = {
120
+ tags: ["CA"],
83
121
  responses: {
84
122
  "200": {
85
123
  content: {
@@ -113,6 +151,7 @@ export const userCertificateRequestEndpointBodySchema = (env: SshCaBindings) =>
113
151
  )
114
152
 
115
153
  export const createUserCertificateRequestEndpointSchema = (env: SshCaBindings) => ({
154
+ tags: [certificateTypeTag(CertificateType.User)],
116
155
  security: [{ oidcAuth: [] }],
117
156
  request: {
118
157
  headers: createHeaderSchema(env),
@@ -135,15 +174,12 @@ export const createUserCertificateRequestEndpointSchema = (env: SshCaBindings) =
135
174
  }
136
175
  })
137
176
 
138
- export const createRevocationListEndpointSchema = () => ({
139
- request: {
140
- params: z.object({
141
- certificateType: z.enum(["user", "host"])
142
- }),
143
- },
177
+ export const createRevocationListEndpointSchema = (certificateType: CertificateType) => ({
178
+ tags: [certificateTypeTag(certificateType)],
179
+ summary: `Get the Key Revocation List for ${certificateTypeName(certificateType)} certificates`,
144
180
  responses: {
145
181
  "200": {
146
- description: "Returns an Open SSH Key Revocation List as BASE64 and an SSHSIG signature for verification",
182
+ description: `Returns an Open SSH Key Revocation List of revoked ${certificateTypeName(certificateType)} certificates as BASE64 and an SSHSIG signature for verification`,
147
183
  ...contentJson(z.object({
148
184
  krl: krl,
149
185
  signature: z.string()
@@ -174,6 +210,7 @@ export const hostCertificateRequestEndpointBodySchema = (env: SshCaBindings) =>
174
210
  )
175
211
 
176
212
  export const createHostCertificateRequestEndpointSchema = (env: SshCaBindings) => ({
213
+ tags: [certificateTypeTag(CertificateType.Host)],
177
214
  security: [{ oidcAuth: [] }],
178
215
  request: {
179
216
  headers: createHeaderSchema(env),
@@ -215,6 +252,7 @@ export const hostCertificateRenewEndpointBodySchema = (env: SshCaBindings) => (
215
252
  )
216
253
 
217
254
  export const createHostCertificateRenewEndpointSchema = (env: SshCaBindings) => ({
255
+ tags: [certificateTypeTag(CertificateType.Host)],
218
256
  request: {
219
257
  body: contentJson(hostCertificateRenewEndpointBodySchema(env))
220
258
  },
@@ -252,16 +290,15 @@ export const revokeCertificateEndpointBodySchema = (env: SshCaBindings) => (
252
290
  .superRefine((val, ctx) => refineRevokeCertificate(env, val, ctx))
253
291
  )
254
292
 
255
- export const createRevokeCertificateEndpointSchema = (env: SshCaBindings) => ({
293
+ export const createRevokeCertificateEndpointSchema = (env: SshCaBindings, certificateType: CertificateType) => ({
294
+ tags: [certificateTypeTag(certificateType)],
295
+ summary: `Revoke a ${certificateTypeName(certificateType)} certificate`,
256
296
  request: {
257
- params: z.object({
258
- certificateType: z.enum(["user", "host"])
259
- }),
260
297
  body: contentJson(revokeCertificateEndpointBodySchema(env))
261
298
  },
262
299
  responses: {
263
300
  "200": {
264
- description: "SSH certificate revoked successfully",
301
+ description: `SSH ${certificateTypeName(certificateType)} certificate revoked successfully`,
265
302
  ...contentJson(z.object({
266
303
  revoked_at: z.iso.datetime(),
267
304
  })
package/src/db/index.ts CHANGED
@@ -2,30 +2,10 @@ import type { Certificate, Format, Identity } from "sshpk"
2
2
  import { logger } from "../logger.js"
3
3
  import type { SshCaBindings } from "../types.js"
4
4
  import { D1QB } from "workers-qb"
5
+ import { z } from "zod"
5
6
  import { migrateOnce } from "./migrate.js"
6
7
  import { migrations } from "./migrations/index.js"
7
-
8
- export enum CertificateType {
9
- User,
10
- Host
11
- }
12
-
13
- export type CertificateRecord = {
14
- id: number
15
- serial: string
16
- key_id: string
17
- principals: string
18
- extensions: string | null
19
- valid_after: Date
20
- valid_before: Date
21
- revoked_at: Date | null
22
- certificate_type: CertificateType
23
- public_key: string | null
24
- }
25
-
26
- export type DatabaseSchema = {
27
- certificates: CertificateRecord
28
- }
8
+ import { CertificateType, type CertificateRecord, type DatabaseSchema, type KrlRecord } from "./types.js"
29
9
 
30
10
  const connect = async (env: SshCaBindings): Promise<D1QB<DatabaseSchema>> => {
31
11
  const l = logger(env)
@@ -146,7 +126,9 @@ export const revocationStatus = async (env: SshCaBindings, serial: bigint, publi
146
126
 
147
127
  export const getRevocationList = async (env: SshCaBindings, certificateType: CertificateType): Promise<string[]> => {
148
128
  const qb = await connect(env)
149
- const res = await qb.fetchAll({
129
+ // fetchAll cannot infer the table from its parameters, so it is given explicitly
130
+ // as otherwise the result is untyped once the schema has more than one table
131
+ const res = await qb.fetchAll<"certificates", "serial">({
150
132
  tableName: "certificates",
151
133
  fields: ["serial"],
152
134
  where: {
@@ -192,3 +174,70 @@ export const revokeCertificate = async (env: SshCaBindings, serial: bigint): Pro
192
174
 
193
175
  return res.results
194
176
  }
177
+
178
+ const KrlVersionRow = z.object({
179
+ version: z.number().int().nonnegative(),
180
+ digest: z.string().nullable(),
181
+ })
182
+
183
+ // the number of times to retry when another request updates the KRL version concurrently
184
+ const KRL_VERSION_ATTEMPTS = 5
185
+
186
+ /**
187
+ * Get the version of the KRL for a certificate type, incrementing it if the
188
+ * content of the KRL has changed since the version was last returned.
189
+ *
190
+ * The version is only ever incremented, so clients can use it to detect a
191
+ * newer KRL. When several requests see the same change concurrently the
192
+ * version is incremented once.
193
+ *
194
+ * @param env The bindings for the CA, see {@link SshCaBindings}
195
+ * @param certificateType The type of certificates in the KRL, see {@link CertificateType}
196
+ * @param digest A digest of the content of the KRL, which changes whenever the content does
197
+ * @returns The version to use for the KRL
198
+ */
199
+ export const getKrlVersion = async (env: SshCaBindings, certificateType: CertificateType, digest: string): Promise<bigint> => {
200
+ const qb = await connect(env)
201
+
202
+ for (let attempt = 0; attempt < KRL_VERSION_ATTEMPTS; attempt++) {
203
+ const res = await qb.fetchOne({
204
+ tableName: "krl",
205
+ fields: ["version", "digest"],
206
+ where: {
207
+ conditions: "certificate_type = ?",
208
+ params: certificateType
209
+ }
210
+ }).execute()
211
+
212
+ const current = KrlVersionRow.parse(res.results)
213
+ if (current.digest === digest) {
214
+ return BigInt(current.version)
215
+ }
216
+
217
+ // only update if the version has not been changed by another request
218
+ const next = current.version + 1
219
+ const updated = await qb.update<Pick<KrlRecord, "version">>({
220
+ tableName: "krl",
221
+ data: {
222
+ version: next,
223
+ digest: digest,
224
+ updated_at: (new Date).toISOString()
225
+ },
226
+ where: {
227
+ conditions: "certificate_type = ? AND version = ?",
228
+ params: [certificateType, current.version]
229
+ },
230
+ returning: ["version"]
231
+ }).execute()
232
+
233
+ if (!updated.success) {
234
+ throw new Error("error during query")
235
+ }
236
+
237
+ if (updated.results !== undefined && updated.results.length > 0) {
238
+ return BigInt(next)
239
+ }
240
+ }
241
+
242
+ throw new Error("unable to update KRL version due to concurrent updates")
243
+ }
@@ -0,0 +1,16 @@
1
+ import type { Migration } from "../migrate.js"
2
+
3
+ export const migration: Migration = {
4
+ name: "0002_krl_version",
5
+ statements: [
6
+ `CREATE TABLE IF NOT EXISTS krl (
7
+ certificate_type INTEGER PRIMARY KEY,
8
+ version INTEGER NOT NULL,
9
+ digest TEXT,
10
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
11
+ )`,
12
+ // KRLs were previously always version 1 and have no digest, so the
13
+ // first KRL generated after this migration is version 2
14
+ "INSERT OR IGNORE INTO krl (certificate_type, version, digest) VALUES (0, 1, NULL), (1, 1, NULL)",
15
+ ]
16
+ }
@@ -1,6 +1,8 @@
1
1
  import type { Migration } from "../migrate.js"
2
2
  import { migration as initialSchema0001 } from "./0001_initial_schema.js"
3
+ import { migration as krlVersion0002 } from "./0002_krl_version.js"
3
4
 
4
5
  export const migrations: Migration[] = [
5
- initialSchema0001
6
+ initialSchema0001,
7
+ krlVersion0002,
6
8
  ]
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Database types used by workers-qb to type queries.
3
+ *
4
+ * These must match the schema created by the migrations in `./migrations`, so
5
+ * update them with any change to the database.
6
+ */
7
+
8
+ /** The type of certificate, as stored in the `certificate_type` column */
9
+ export enum CertificateType {
10
+ User,
11
+ Host
12
+ }
13
+
14
+ /** A row in the `certificates` table */
15
+ export type CertificateRecord = {
16
+ id: number
17
+ serial: string
18
+ key_id: string
19
+ principals: string
20
+ extensions: string | null
21
+ valid_after: Date
22
+ valid_before: Date
23
+ revoked_at: Date | null
24
+ certificate_type: CertificateType
25
+ public_key: string | null
26
+ }
27
+
28
+ /** A row in the `krl` table, holding the current version of the KRL for a {@link CertificateType} */
29
+ export type KrlRecord = {
30
+ certificate_type: CertificateType
31
+ version: number
32
+ digest: string | null
33
+ updated_at: Date
34
+ }
35
+
36
+ /** The tables in the database, used to type queries made with workers-qb */
37
+ export type DatabaseSchema = {
38
+ certificates: CertificateRecord
39
+ krl: KrlRecord
40
+ }
package/src/krl.ts CHANGED
@@ -298,6 +298,29 @@ export class KRLBuilder {
298
298
  return this
299
299
  }
300
300
 
301
+ /**
302
+ * Return a hex encoded SHA-256 digest of the content of the KRL.
303
+ *
304
+ * The digest covers the CA public key and the revoked serials and key IDs,
305
+ * but not the order they were added in or the header, so it only changes
306
+ * when the set of revoked certificates or the CA does.
307
+ */
308
+ async digest(): Promise<string> {
309
+ const serials = [...this._serials].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
310
+ const keyIds = [...this._keyIds].sort()
311
+
312
+ const content = concat(
313
+ encodeString(caKeyWireBytes(this._caKey)),
314
+ encodeUint32(serials.length),
315
+ ...serials.map(encodeUint64),
316
+ encodeUint32(keyIds.length),
317
+ ...keyIds.map((id) => encodeString(id)),
318
+ )
319
+
320
+ const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", content))
321
+ return Array.from(hash, (b) => b.toString(16).padStart(2, "0")).join("")
322
+ }
323
+
301
324
  /**
302
325
  * Generate the binary KRL
303
326
  *