@andrewheberle/serverless-ssh-ca 0.21.0 → 0.23.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 (98) hide show
  1. package/README.md +55 -1
  2. package/dist/api/operations.d.ts +49 -0
  3. package/dist/api/operations.d.ts.map +1 -0
  4. package/dist/api/operations.js +175 -0
  5. package/dist/api/operations.js.map +1 -0
  6. package/dist/api/schema.d.ts +71 -0
  7. package/dist/api/schema.d.ts.map +1 -0
  8. package/dist/api/schema.js +107 -0
  9. package/dist/api/schema.js.map +1 -0
  10. package/dist/api/v3/index.d.ts.map +1 -1
  11. package/dist/api/v3/index.js +25 -212
  12. package/dist/api/v3/index.js.map +1 -1
  13. package/dist/api/v3/schema.d.ts +30 -74
  14. package/dist/api/v3/schema.d.ts.map +1 -1
  15. package/dist/api/v3/schema.js +18 -73
  16. package/dist/api/v3/schema.js.map +1 -1
  17. package/dist/api/v4/index.d.ts +8 -0
  18. package/dist/api/v4/index.d.ts.map +1 -0
  19. package/dist/api/v4/index.js +155 -0
  20. package/dist/api/v4/index.js.map +1 -0
  21. package/dist/api/v4/schema.d.ts +268 -0
  22. package/dist/api/v4/schema.d.ts.map +1 -0
  23. package/dist/api/v4/schema.js +182 -0
  24. package/dist/api/v4/schema.js.map +1 -0
  25. package/dist/certificate.d.ts +9 -6
  26. package/dist/certificate.d.ts.map +1 -1
  27. package/dist/certificate.js +55 -99
  28. package/dist/certificate.js.map +1 -1
  29. package/dist/db/index.d.ts +32 -24
  30. package/dist/db/index.d.ts.map +1 -1
  31. package/dist/db/index.js +101 -28
  32. package/dist/db/index.js.map +1 -1
  33. package/dist/db/migrations/0002_krl_version.d.ts +3 -0
  34. package/dist/db/migrations/0002_krl_version.d.ts.map +1 -0
  35. package/dist/db/migrations/0002_krl_version.js +15 -0
  36. package/dist/db/migrations/0002_krl_version.js.map +1 -0
  37. package/dist/db/migrations/index.d.ts.map +1 -1
  38. package/dist/db/migrations/index.js +3 -1
  39. package/dist/db/migrations/index.js.map +1 -1
  40. package/dist/db/types.d.ts +37 -0
  41. package/dist/db/types.d.ts.map +1 -0
  42. package/dist/db/types.js +13 -0
  43. package/dist/db/types.js.map +1 -0
  44. package/dist/krl.d.ts +9 -1
  45. package/dist/krl.d.ts.map +1 -1
  46. package/dist/krl.js +22 -162
  47. package/dist/krl.js.map +1 -1
  48. package/dist/proof.d.ts +36 -7
  49. package/dist/proof.d.ts.map +1 -1
  50. package/dist/proof.js +53 -21
  51. package/dist/proof.js.map +1 -1
  52. package/dist/router.d.ts.map +1 -1
  53. package/dist/router.js +3 -1
  54. package/dist/router.js.map +1 -1
  55. package/dist/ssh/certificate.d.ts +93 -0
  56. package/dist/ssh/certificate.d.ts.map +1 -0
  57. package/dist/ssh/certificate.js +178 -0
  58. package/dist/ssh/certificate.js.map +1 -0
  59. package/dist/ssh/encoding.d.ts +41 -0
  60. package/dist/ssh/encoding.d.ts.map +1 -0
  61. package/dist/ssh/encoding.js +70 -0
  62. package/dist/ssh/encoding.js.map +1 -0
  63. package/dist/ssh/keys.d.ts +80 -0
  64. package/dist/ssh/keys.d.ts.map +1 -0
  65. package/dist/ssh/keys.js +249 -0
  66. package/dist/ssh/keys.js.map +1 -0
  67. package/dist/ssh/signer.d.ts +35 -0
  68. package/dist/ssh/signer.d.ts.map +1 -0
  69. package/dist/ssh/signer.js +99 -0
  70. package/dist/ssh/signer.js.map +1 -0
  71. package/dist/types.d.ts +12 -17
  72. package/dist/types.d.ts.map +1 -1
  73. package/dist/utils.d.ts +40 -21
  74. package/dist/utils.d.ts.map +1 -1
  75. package/dist/utils.js +87 -54
  76. package/dist/utils.js.map +1 -1
  77. package/openapi.json +1960 -82
  78. package/package.json +5 -17
  79. package/src/api/operations.ts +211 -0
  80. package/src/api/schema.ts +137 -0
  81. package/src/api/v3/index.ts +32 -259
  82. package/src/api/v3/schema.ts +34 -96
  83. package/src/api/v4/index.ts +265 -0
  84. package/src/api/v4/schema.ts +235 -0
  85. package/src/certificate.ts +62 -123
  86. package/src/db/index.ts +113 -49
  87. package/src/db/migrations/0002_krl_version.ts +16 -0
  88. package/src/db/migrations/index.ts +3 -1
  89. package/src/db/types.ts +40 -0
  90. package/src/krl.ts +32 -194
  91. package/src/proof.ts +65 -31
  92. package/src/router.ts +3 -1
  93. package/src/ssh/certificate.ts +236 -0
  94. package/src/ssh/encoding.ts +81 -0
  95. package/src/ssh/keys.ts +294 -0
  96. package/src/ssh/signer.ts +129 -0
  97. package/src/types.ts +12 -18
  98. package/src/utils.ts +99 -69
@@ -1,14 +1,9 @@
1
1
  import { seconds } from "itty-time"
2
- import sshpk from "sshpk"
3
- import type { Certificate, Identity, Key, PrivateKey } from "sshpk"
4
- import { SSHExtension } from "./types.js"
2
+ import type { PrivateKey, PublicKey } from "./ssh/keys.js"
5
3
  import { getPrivateKey, split } from "./utils.js"
6
4
  import type { SshCaBindings } from "./types.js"
7
-
8
- // sshpk is CommonJS and Node's ESM loader cannot detect most of its named exports, so values are taken from the default import
9
- const { createCertificate, identityForHost, identityForUser, identityFromDN } = sshpk
10
-
11
- // const sshCertificateExtensions = split(env.SSH_CERTIFICATE_EXTENSIONS)
5
+ import { signCertificate, type CertificateFields, type SshCertificate } from "./ssh/certificate.js"
6
+ import { createSigner } from "./ssh/signer.js"
12
7
 
13
8
  export type CreateCertificateOptions = {
14
9
  lifetime?: number
@@ -16,12 +11,6 @@ export type CreateCertificateOptions = {
16
11
  extensions?: string[]
17
12
  }
18
13
 
19
- /* const DefaultCreateCertificateOptions: CreateCertificateOptions = {
20
- lifetime: seconds(env.SSH_CERTIFICATE_LIFETIME),
21
- principals: [],
22
- extensions: sshCertificateExtensions
23
- } */
24
-
25
14
  export class CertificateError extends Error {
26
15
  constructor(message: string) {
27
16
  super(message)
@@ -41,18 +30,36 @@ export type GenerateCertificateOptions = {
41
30
  serial?: bigint
42
31
  } & CreateCertificateOptions
43
32
 
44
- export const generateCertificate = (env: SshCaBindings, email: string, key: PrivateKey, public_key: Key, options?: GenerateCertificateOptions): Certificate => {
45
- let { lifetime } = options ?? {}
46
- const { principals, extensions, serial } = options ?? {}
33
+ /**
34
+ * Signs a certificate with the CA key
35
+ *
36
+ * @throws {@link CertificateError} when the certificate has no principals,
37
+ * as OpenSSH accepts a certificate without principals for any user or host
38
+ */
39
+ const sign = async (key: PrivateKey, fields: Omit<CertificateFields, "validAfter" | "validBefore">, lifetime: number): Promise<SshCertificate> => {
40
+ if (fields.principals.length === 0) {
41
+ throw new CertificateError("a certificate must have at least one principal")
42
+ }
43
+
44
+ // the validity is to the second, so starts at the current second rather
45
+ // than in the future
46
+ const validAfter = new Date(Math.floor(Date.now() / 1000) * 1000)
47
+ const validBefore = new Date(validAfter.getTime() + lifetime * 1000)
47
48
 
48
- // add identities
49
+ return await signCertificate({ ...fields, validAfter, validBefore }, await createSigner(key))
50
+ }
51
+
52
+ export const generateCertificate = async (env: SshCaBindings, email: string, key: PrivateKey, public_key: PublicKey, options?: GenerateCertificateOptions): Promise<SshCertificate> => {
53
+ const { lifetime, principals, extensions, serial } = options ?? {}
54
+
55
+ // add principals
49
56
  const includeSelf = env.SSH_CERTIFICATE_INCLUDE_SELF === undefined
50
57
  ? false
51
58
  : typeof env.SSH_CERTIFICATE_INCLUDE_SELF === "string"
52
59
  ? env.SSH_CERTIFICATE_INCLUDE_SELF === "true"
53
60
  : env.SSH_CERTIFICATE_INCLUDE_SELF
54
- const identity = includeSelf
55
- ? [identityForUser(email.split("@")[0]!)]
61
+ const certificatePrincipals = includeSelf
62
+ ? [email.split("@")[0]!]
56
63
  : []
57
64
 
58
65
  // add email?
@@ -62,86 +69,43 @@ export const generateCertificate = (env: SshCaBindings, email: string, key: Priv
62
69
  ? env.SSH_CERTIFICATE_INCLUDE_SELF_EMAIL === "true"
63
70
  : env.SSH_CERTIFICATE_INCLUDE_SELF_EMAIL
64
71
  if (includeSelfEmail) {
65
- identity.push(identityForUser(email))
72
+ certificatePrincipals.push(email)
66
73
  }
67
74
 
68
75
  if (principals !== undefined) {
69
- for (const p of principals) {
70
- identity.push(identityForUser(p))
71
- }
72
- }
73
-
74
- for (const p of split(env.SSH_CERTIFICATE_PRINCIPALS)) {
75
- identity.push(identityForUser(p))
76
+ certificatePrincipals.push(...principals)
76
77
  }
77
78
 
78
-
79
- // lifetime is the smaller of what was provided in the options or the default
80
- lifetime = lifetime !== undefined
81
- ? Math.min(lifetime, seconds(env.SSH_CERTIFICATE_LIFETIME))
82
- : seconds(env.SSH_CERTIFICATE_LIFETIME)
83
-
84
- // generate value for serial of certificate
85
- const s = generateSerial(serial)
86
-
87
- // set issuer of certificate based on ISSUER_DN
88
- const issuer = identityFromDN(env.ISSUER_DN)
89
-
90
- // create certificate
91
- const certificate = createCertificate(identity, public_key, issuer, key, { lifetime: lifetime, serial: s })
92
-
93
- // ensure openssh info is included in certificate (should not occur)
94
- if (certificate.signatures.openssh === undefined) {
95
- throw new CertificateError("missing openssh information in certificate")
96
- }
79
+ certificatePrincipals.push(...split(env.SSH_CERTIFICATE_PRINCIPALS))
97
80
 
98
81
  const sshCertificateExtensions = split(env.SSH_CERTIFICATE_EXTENSIONS)
99
82
 
100
- // add usage extensions
101
- const sshextensions: SSHExtension[] = []
83
+ // if extensions are provided in request, ensure they do not include extra extensions beyond the defaults
102
84
  if (extensions !== undefined) {
103
- // if extensions are provided in request, ensure they do not include extra extensions beyond the defaults
104
85
  for (const ext of extensions) {
105
86
  if (!sshCertificateExtensions.includes(ext)) {
106
87
  throw new CertificateError(`${ext} is not allowed`)
107
88
  }
108
-
109
- // add to list of allowed extensions
110
- sshextensions.push({
111
- critical: false,
112
- name: ext,
113
- data: Buffer.alloc(0)
114
- })
115
- }
116
- } else {
117
- // use defaults if not provided
118
- sshCertificateExtensions.forEach((ext: string) => {
119
- sshextensions.push({
120
- critical: false,
121
- name: ext,
122
- data: Buffer.alloc(0)
123
- })
124
- })
125
-
126
- }
127
-
128
- // add info to certificate
129
- certificate.signatures = {
130
- openssh: {
131
- nonce: certificate.signatures.openssh.nonce,
132
- keyId: email,
133
- signature: certificate.signatures.openssh.signature,
134
- exts: sshextensions
135
89
  }
136
90
  }
137
91
 
138
- // re-sign after changes
139
- certificate.signWith(key)
92
+ // lifetime is the smaller of what was provided in the options or the default
93
+ const certificateLifetime = lifetime !== undefined
94
+ ? Math.min(lifetime, seconds(env.SSH_CERTIFICATE_LIFETIME))
95
+ : seconds(env.SSH_CERTIFICATE_LIFETIME)
140
96
 
141
- return certificate
97
+ return await sign(key, {
98
+ kind: "user",
99
+ publicKey: public_key,
100
+ serial: serial ?? generateSerial(),
101
+ keyId: email,
102
+ principals: certificatePrincipals,
103
+ // use defaults if not provided
104
+ extensions: extensions ?? sshCertificateExtensions,
105
+ }, certificateLifetime)
142
106
  }
143
107
 
144
- export async function createSignedCertificate(env: SshCaBindings, email: string, public_key: Key, options?: CreateCertificateOptions): Promise<Certificate> {
108
+ export async function createSignedCertificate(env: SshCaBindings, email: string, public_key: PublicKey, options?: CreateCertificateOptions): Promise<SshCertificate> {
145
109
  // grab private key from secret store
146
110
  const key = await getPrivateKey(env)
147
111
 
@@ -153,13 +117,12 @@ export async function createSignedCertificate(env: SshCaBindings, email: string,
153
117
  }
154
118
  }
155
119
 
156
- return generateCertificate(env, email, key, public_key, options)
120
+ return await generateCertificate(env, email, key, public_key, options)
157
121
  }
158
122
 
159
123
  export type CreateHostCertificateOptions = {
160
124
  lifetime?: number
161
125
  principals?: string[]
162
- subjects?: Identity[]
163
126
  }
164
127
 
165
128
  export class BadIssuerError extends Error {
@@ -171,54 +134,30 @@ export class BadIssuerError extends Error {
171
134
  }
172
135
  }
173
136
 
174
- export async function createSignedHostCertificate(env: SshCaBindings, public_key: Key, options?: CreateHostCertificateOptions): Promise<Certificate> {
175
- if (options === undefined) {
176
- options = {
177
- lifetime: seconds(env.SSH_HOST_CERTIFICATE_LIFETIME),
178
- principals: []
179
- }
180
- }
181
- // generate list of identities for host key
182
- const identity: Identity[] = []
183
- if (options.principals !== undefined) {
184
- for (const p of options.principals) {
185
- identity.push(identityForHost(p))
186
- }
187
- }
188
-
189
- // add any already provided subjects (for renewals only)
190
- if (options.subjects !== undefined) {
191
- identity.push(...options.subjects)
192
- }
137
+ export async function createSignedHostCertificate(env: SshCaBindings, public_key: PublicKey, options?: CreateHostCertificateOptions): Promise<SshCertificate> {
138
+ const principals = options?.principals ?? []
193
139
 
194
140
  // grab private key from secret store
195
141
  const key = await getPrivateKey(env)
196
142
 
197
143
  // lifetime is the smaller of what was provided in the options or the default
198
- const lifetime = options.lifetime !== undefined
144
+ const lifetime = options?.lifetime !== undefined
199
145
  ? Math.min(options.lifetime, seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
200
146
  : seconds(env.SSH_HOST_CERTIFICATE_LIFETIME)
201
147
 
202
- // generate value for serial of certificate
203
- const serial = generateSerial()
204
-
205
- // set issuer of certificate based on ISSUER_DN
206
- const issuer = identityFromDN(env.ISSUER_DN)
207
-
208
- // create certificate
209
- const certificate = createCertificate(identity, public_key, issuer, key, { lifetime: lifetime, serial: serial })
210
-
211
- return certificate
148
+ return await sign(key, {
149
+ kind: "host",
150
+ publicKey: public_key,
151
+ serial: generateSerial(),
152
+ keyId: `host_${principals[0]}`,
153
+ principals,
154
+ extensions: [],
155
+ }, lifetime)
212
156
  }
213
157
 
214
- export const generateSerial = (serial?: bigint): Buffer<ArrayBuffer> => {
215
- if (serial !== undefined) {
216
- const s = Buffer.alloc(8)
217
- s.writeBigUInt64BE(serial)
218
-
219
- return s
220
- }
221
-
222
- const randomBytes = crypto.getRandomValues(new Uint8Array(8))
223
- return Buffer.from(randomBytes)
158
+ /**
159
+ * Returns a random certificate serial number
160
+ */
161
+ export const generateSerial = (): bigint => {
162
+ return new DataView(crypto.getRandomValues(new Uint8Array(8)).buffer).getBigUint64(0)
224
163
  }
package/src/db/index.ts CHANGED
@@ -1,31 +1,12 @@
1
- import type { Certificate, Format, Identity } from "sshpk"
1
+ import { PublicKey } from "../ssh/keys.js"
2
+ import type { SshCertificate } from "../ssh/certificate.js"
2
3
  import { logger } from "../logger.js"
3
4
  import type { SshCaBindings } from "../types.js"
4
5
  import { D1QB } from "workers-qb"
6
+ import { z } from "zod"
5
7
  import { migrateOnce } from "./migrate.js"
6
8
  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
- }
9
+ import { CertificateType, type CertificateRecord, type DatabaseSchema, type KrlRecord } from "./types.js"
29
10
 
30
11
  const connect = async (env: SshCaBindings): Promise<D1QB<DatabaseSchema>> => {
31
12
  const l = logger(env)
@@ -68,27 +49,22 @@ export const dbCleanup = async (env: SshCaBindings) => {
68
49
  }
69
50
  }
70
51
 
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(",")
52
+ export const recordCertificate = async (env: SshCaBindings, certificate: SshCertificate, keyid: string, certificateType: CertificateType = CertificateType.User) => {
53
+ // principals are recorded as distinguished names, as they were when
54
+ // certificates were created with sshpk
55
+ const principals = certificate.principals.map((p) => `${certificate.kind === "host" ? "CN" : "UID"}=${p}`).join(",")
80
56
  const qb = await connect(env)
81
57
  const res = await qb.insert({
82
58
  tableName: "certificates",
83
59
  data: {
84
- serial: `${serial}`,
60
+ serial: `${certificate.serial}`,
85
61
  key_id: keyid,
86
- principals: subjects,
87
- extensions: extensions,
88
- valid_after: certificate.validFrom.toISOString(),
89
- valid_before: certificate.validUntil.toISOString(),
62
+ principals: principals,
63
+ extensions: certificate.extensions.join(","),
64
+ valid_after: certificate.validAfter.toISOString(),
65
+ valid_before: certificate.validBefore.toISOString(),
90
66
  certificate_type: certificateType,
91
- public_key: certificate.subjectKey.toString("ssh")
67
+ public_key: certificate.publicKey.toString()
92
68
  },
93
69
  }).execute()
94
70
 
@@ -115,21 +91,44 @@ export enum RevocationStatus {
115
91
  NotFound,
116
92
  Revoked,
117
93
  Revokable,
118
- Unrevokable
119
94
  }
120
95
 
121
- export const revocationStatus = async (env: SshCaBindings, serial: bigint, publickey: string, certificateType: CertificateType): Promise<RevocationStatus> => {
96
+ // whether a public key recorded in OpenSSH format is the same as another key,
97
+ // treating a recorded key that cannot be parsed as a different key
98
+ const isKey = (recorded: string, publicKey: PublicKey): boolean => {
99
+ try {
100
+ return PublicKey.parse(recorded).equals(publicKey)
101
+ } catch {
102
+ return false
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Returns whether a certificate can be revoked by the holder of a public key.
108
+ *
109
+ * The public key is compared by fingerprint, so a key with a different
110
+ * comment to the one the certificate was issued for still matches.
111
+ *
112
+ * @param env The CA bindings
113
+ * @param serial The serial of the certificate
114
+ * @param publicKey The public key the requester has proven possession of
115
+ * @param certificateType The type of certificate
116
+ * @returns The revocation status of the certificate
117
+ */
118
+ export const revocationStatus = async (env: SshCaBindings, serial: bigint, publicKey: PublicKey, certificateType: CertificateType): Promise<RevocationStatus> => {
122
119
  const qb = await connect(env)
123
120
  const res = await qb.fetchOne({
124
121
  tableName: "certificates",
125
122
  fields: ["public_key", "revoked_at"],
126
123
  where: {
127
- conditions: "serial = ? AND public_key = ? AND certificate_type = ?",
128
- params: [`${serial}`, publickey, certificateType]
124
+ conditions: "serial = ? AND certificate_type = ?",
125
+ params: [`${serial}`, certificateType]
129
126
  }
130
127
  }).execute()
131
128
 
132
- if (res.results === undefined) {
129
+ // a certificate issued for another key is reported as not found, so
130
+ // requests cannot be used to discover which serials exist
131
+ if (res.results?.public_key == null || !isKey(res.results.public_key, publicKey)) {
133
132
  return RevocationStatus.NotFound
134
133
  }
135
134
 
@@ -137,16 +136,14 @@ export const revocationStatus = async (env: SshCaBindings, serial: bigint, publi
137
136
  return RevocationStatus.Revoked
138
137
  }
139
138
 
140
- if (publickey === res.results.public_key) {
141
- return RevocationStatus.Revokable
142
- }
143
-
144
- return RevocationStatus.Unrevokable
139
+ return RevocationStatus.Revokable
145
140
  }
146
141
 
147
142
  export const getRevocationList = async (env: SshCaBindings, certificateType: CertificateType): Promise<string[]> => {
148
143
  const qb = await connect(env)
149
- const res = await qb.fetchAll({
144
+ // fetchAll cannot infer the table from its parameters, so it is given explicitly
145
+ // as otherwise the result is untyped once the schema has more than one table
146
+ const res = await qb.fetchAll<"certificates", "serial">({
150
147
  tableName: "certificates",
151
148
  fields: ["serial"],
152
149
  where: {
@@ -192,3 +189,70 @@ export const revokeCertificate = async (env: SshCaBindings, serial: bigint): Pro
192
189
 
193
190
  return res.results
194
191
  }
192
+
193
+ const KrlVersionRow = z.object({
194
+ version: z.number().int().nonnegative(),
195
+ digest: z.string().nullable(),
196
+ })
197
+
198
+ // the number of times to retry when another request updates the KRL version concurrently
199
+ const KRL_VERSION_ATTEMPTS = 5
200
+
201
+ /**
202
+ * Get the version of the KRL for a certificate type, incrementing it if the
203
+ * content of the KRL has changed since the version was last returned.
204
+ *
205
+ * The version is only ever incremented, so clients can use it to detect a
206
+ * newer KRL. When several requests see the same change concurrently the
207
+ * version is incremented once.
208
+ *
209
+ * @param env The bindings for the CA, see {@link SshCaBindings}
210
+ * @param certificateType The type of certificates in the KRL, see {@link CertificateType}
211
+ * @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
213
+ */
214
+ export const getKrlVersion = async (env: SshCaBindings, certificateType: CertificateType, digest: string): Promise<bigint> => {
215
+ const qb = await connect(env)
216
+
217
+ for (let attempt = 0; attempt < KRL_VERSION_ATTEMPTS; attempt++) {
218
+ const res = await qb.fetchOne({
219
+ tableName: "krl",
220
+ fields: ["version", "digest"],
221
+ where: {
222
+ conditions: "certificate_type = ?",
223
+ params: certificateType
224
+ }
225
+ }).execute()
226
+
227
+ const current = KrlVersionRow.parse(res.results)
228
+ if (current.digest === digest) {
229
+ return BigInt(current.version)
230
+ }
231
+
232
+ // only update if the version has not been changed by another request
233
+ const next = current.version + 1
234
+ const updated = await qb.update<Pick<KrlRecord, "version">>({
235
+ tableName: "krl",
236
+ data: {
237
+ version: next,
238
+ digest: digest,
239
+ updated_at: (new Date).toISOString()
240
+ },
241
+ where: {
242
+ conditions: "certificate_type = ? AND version = ?",
243
+ params: [certificateType, current.version]
244
+ },
245
+ returning: ["version"]
246
+ }).execute()
247
+
248
+ if (!updated.success) {
249
+ throw new Error("error during query")
250
+ }
251
+
252
+ if (updated.results !== undefined && updated.results.length > 0) {
253
+ return BigInt(next)
254
+ }
255
+ }
256
+
257
+ throw new Error("unable to update KRL version due to concurrent updates")
258
+ }
@@ -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
+ }