@andrewheberle/serverless-ssh-ca 0.22.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 (122) hide show
  1. package/README.md +174 -1
  2. package/dist/api/operations.d.ts +50 -0
  3. package/dist/api/operations.d.ts.map +1 -0
  4. package/dist/api/operations.js +264 -0
  5. package/dist/api/operations.js.map +1 -0
  6. package/dist/api/schema.d.ts +72 -0
  7. package/dist/api/schema.d.ts.map +1 -0
  8. package/dist/api/schema.js +114 -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 +13 -202
  12. package/dist/api/v3/index.js.map +1 -1
  13. package/dist/api/v3/schema.d.ts +23 -68
  14. package/dist/api/v3/schema.d.ts.map +1 -1
  15. package/dist/api/v3/schema.js +18 -104
  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 +186 -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 +68 -9
  30. package/dist/db/index.d.ts.map +1 -1
  31. package/dist/db/index.js +165 -49
  32. package/dist/db/index.js.map +1 -1
  33. package/dist/db/migrations/0003_krl_revision.d.ts +3 -0
  34. package/dist/db/migrations/0003_krl_revision.d.ts.map +1 -0
  35. package/dist/db/migrations/0003_krl_revision.js +14 -0
  36. package/dist/db/migrations/0003_krl_revision.js.map +1 -0
  37. package/dist/db/migrations/index.d.ts.map +1 -1
  38. package/dist/db/migrations/index.js +2 -0
  39. package/dist/db/migrations/index.js.map +1 -1
  40. package/dist/db/types.d.ts +2 -0
  41. package/dist/db/types.d.ts.map +1 -1
  42. package/dist/index.d.ts +5 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +4 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/krl.d.ts +3 -2
  47. package/dist/krl.d.ts.map +1 -1
  48. package/dist/krl.js +12 -165
  49. package/dist/krl.js.map +1 -1
  50. package/dist/node/cache.d.ts +21 -0
  51. package/dist/node/cache.d.ts.map +1 -0
  52. package/dist/node/cache.js +49 -0
  53. package/dist/node/cache.js.map +1 -0
  54. package/dist/node/env.d.ts +6 -3
  55. package/dist/node/env.d.ts.map +1 -1
  56. package/dist/node/env.js +4 -3
  57. package/dist/node/env.js.map +1 -1
  58. package/dist/node/index.d.ts +1 -0
  59. package/dist/node/index.d.ts.map +1 -1
  60. package/dist/node/index.js +1 -0
  61. package/dist/node/index.js.map +1 -1
  62. package/dist/proof.d.ts +36 -7
  63. package/dist/proof.d.ts.map +1 -1
  64. package/dist/proof.js +53 -21
  65. package/dist/proof.js.map +1 -1
  66. package/dist/router.d.ts +25 -1
  67. package/dist/router.d.ts.map +1 -1
  68. package/dist/router.js +41 -2
  69. package/dist/router.js.map +1 -1
  70. package/dist/ssh/certificate.d.ts +93 -0
  71. package/dist/ssh/certificate.d.ts.map +1 -0
  72. package/dist/ssh/certificate.js +178 -0
  73. package/dist/ssh/certificate.js.map +1 -0
  74. package/dist/ssh/encoding.d.ts +41 -0
  75. package/dist/ssh/encoding.d.ts.map +1 -0
  76. package/dist/ssh/encoding.js +70 -0
  77. package/dist/ssh/encoding.js.map +1 -0
  78. package/dist/ssh/keys.d.ts +92 -0
  79. package/dist/ssh/keys.d.ts.map +1 -0
  80. package/dist/ssh/keys.js +277 -0
  81. package/dist/ssh/keys.js.map +1 -0
  82. package/dist/ssh/signer.d.ts +35 -0
  83. package/dist/ssh/signer.d.ts.map +1 -0
  84. package/dist/ssh/signer.js +99 -0
  85. package/dist/ssh/signer.js.map +1 -0
  86. package/dist/types.d.ts +64 -19
  87. package/dist/types.d.ts.map +1 -1
  88. package/dist/utils.d.ts +107 -21
  89. package/dist/utils.d.ts.map +1 -1
  90. package/dist/utils.js +251 -65
  91. package/dist/utils.js.map +1 -1
  92. package/dist/verify.d.ts +17 -5
  93. package/dist/verify.d.ts.map +1 -1
  94. package/dist/verify.js +47 -9
  95. package/dist/verify.js.map +1 -1
  96. package/openapi.json +1942 -1
  97. package/package.json +4 -3
  98. package/src/api/operations.ts +314 -0
  99. package/src/api/schema.ts +146 -0
  100. package/src/api/v3/index.ts +21 -248
  101. package/src/api/v3/schema.ts +36 -128
  102. package/src/api/v4/index.ts +266 -0
  103. package/src/api/v4/schema.ts +241 -0
  104. package/src/certificate.ts +62 -123
  105. package/src/db/index.ts +196 -56
  106. package/src/db/migrations/0003_krl_revision.ts +15 -0
  107. package/src/db/migrations/index.ts +2 -0
  108. package/src/db/types.ts +2 -0
  109. package/src/index.ts +6 -4
  110. package/src/krl.ts +13 -197
  111. package/src/node/cache.ts +62 -0
  112. package/src/node/env.ts +6 -5
  113. package/src/node/index.ts +1 -0
  114. package/src/proof.ts +65 -31
  115. package/src/router.ts +67 -3
  116. package/src/ssh/certificate.ts +236 -0
  117. package/src/ssh/encoding.ts +81 -0
  118. package/src/ssh/keys.ts +327 -0
  119. package/src/ssh/signer.ts +129 -0
  120. package/src/types.ts +60 -20
  121. package/src/utils.ts +285 -81
  122. package/src/verify.ts +54 -14
@@ -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,11 +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"
5
6
  import { z } from "zod"
6
7
  import { migrateOnce } from "./migrate.js"
7
8
  import { migrations } from "./migrations/index.js"
8
- import { CertificateType, type CertificateRecord, type DatabaseSchema, type KrlRecord } from "./types.js"
9
+ import { CertificateType, type DatabaseSchema, type KrlRecord } from "./types.js"
9
10
 
10
11
  const connect = async (env: SshCaBindings): Promise<D1QB<DatabaseSchema>> => {
11
12
  const l = logger(env)
@@ -24,6 +25,14 @@ const connect = async (env: SshCaBindings): Promise<D1QB<DatabaseSchema>> => {
24
25
  }
25
26
  }
26
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
+
27
36
  export const dbCleanup = async (env: SshCaBindings) => {
28
37
  const l = logger(env).with("retention", env.DB_CERTIFICATE_RETENTION)
29
38
 
@@ -43,33 +52,48 @@ export const dbCleanup = async (env: SshCaBindings) => {
43
52
  }
44
53
  }).execute()
45
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
+ }
46
61
  } catch (err) {
47
62
  l.error("error during database cleanup", "retention", env.DB_CERTIFICATE_RETENTION, "error", err)
48
63
  }
49
64
  }
50
65
 
51
- export const recordCertificate = async (env: SshCaBindings, certificate: Certificate, keyid: string, certificateType: CertificateType = CertificateType.User) => {
52
- const serial = certificate.serial.readBigUInt64BE(0)
53
- const subjects = certificate.subjects.map((v: Identity): string => {
54
- return v.toString()
55
- }).join(",")
56
- const extensions = certificate.getExtensions().map((v: Format.OpenSshSignatureExt | Format.x509SignatureExt): string => {
57
- // @ts-expect-error: the name property does exist
58
- return v.name as string
59
- }).join(",")
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) => {
71
+ // principals are recorded as distinguished names, as they were when
72
+ // certificates were created with sshpk
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) => {
60
93
  const qb = await connect(env)
61
94
  const res = await qb.insert({
62
95
  tableName: "certificates",
63
- data: {
64
- serial: `${serial}`,
65
- key_id: keyid,
66
- principals: subjects,
67
- extensions: extensions,
68
- valid_after: certificate.validFrom.toISOString(),
69
- valid_before: certificate.validUntil.toISOString(),
70
- certificate_type: certificateType,
71
- public_key: certificate.subjectKey.toString("ssh")
72
- },
96
+ data: certificateRecord(certificate, keyid, certificateType),
73
97
  }).execute()
74
98
 
75
99
  if (!res.success) {
@@ -77,6 +101,49 @@ export const recordCertificate = async (env: SshCaBindings, certificate: Certifi
77
101
  }
78
102
  }
79
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
+
80
147
  export const isRevoked = async (env: SshCaBindings, serial: bigint): Promise<boolean> => {
81
148
  const qb = await connect(env)
82
149
  const res = await qb.fetchOne({
@@ -95,21 +162,44 @@ export enum RevocationStatus {
95
162
  NotFound,
96
163
  Revoked,
97
164
  Revokable,
98
- Unrevokable
99
165
  }
100
166
 
101
- export const revocationStatus = async (env: SshCaBindings, serial: bigint, publickey: string, certificateType: CertificateType): Promise<RevocationStatus> => {
167
+ // whether a public key recorded in OpenSSH format is the same as another key,
168
+ // treating a recorded key that cannot be parsed as a different key
169
+ const isKey = (recorded: string, publicKey: PublicKey): boolean => {
170
+ try {
171
+ return PublicKey.parse(recorded).equals(publicKey)
172
+ } catch {
173
+ return false
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Returns whether a certificate can be revoked by the holder of a public key.
179
+ *
180
+ * The public key is compared by fingerprint, so a key with a different
181
+ * comment to the one the certificate was issued for still matches.
182
+ *
183
+ * @param env The CA bindings
184
+ * @param serial The serial of the certificate
185
+ * @param publicKey The public key the requester has proven possession of
186
+ * @param certificateType The type of certificate
187
+ * @returns The revocation status of the certificate
188
+ */
189
+ export const revocationStatus = async (env: SshCaBindings, serial: bigint, publicKey: PublicKey, certificateType: CertificateType): Promise<RevocationStatus> => {
102
190
  const qb = await connect(env)
103
191
  const res = await qb.fetchOne({
104
192
  tableName: "certificates",
105
193
  fields: ["public_key", "revoked_at"],
106
194
  where: {
107
- conditions: "serial = ? AND public_key = ? AND certificate_type = ?",
108
- params: [`${serial}`, publickey, certificateType]
195
+ conditions: "serial = ? AND certificate_type = ?",
196
+ params: [`${serial}`, certificateType]
109
197
  }
110
198
  }).execute()
111
199
 
112
- if (res.results === undefined) {
200
+ // a certificate issued for another key is reported as not found, so
201
+ // requests cannot be used to discover which serials exist
202
+ if (res.results?.public_key == null || !isKey(res.results.public_key, publicKey)) {
113
203
  return RevocationStatus.NotFound
114
204
  }
115
205
 
@@ -117,11 +207,7 @@ export const revocationStatus = async (env: SshCaBindings, serial: bigint, publi
117
207
  return RevocationStatus.Revoked
118
208
  }
119
209
 
120
- if (publickey === res.results.public_key) {
121
- return RevocationStatus.Revokable
122
- }
123
-
124
- return RevocationStatus.Unrevokable
210
+ return RevocationStatus.Revokable
125
211
  }
126
212
 
127
213
  export const getRevocationList = async (env: SshCaBindings, certificateType: CertificateType): Promise<string[]> => {
@@ -148,38 +234,89 @@ export const getRevocationList = async (env: SshCaBindings, certificateType: Cer
148
234
  return res.results.map(item => item.serial)
149
235
  }
150
236
 
151
- type RevokedCertificate = Pick<CertificateRecord, "revoked_at">
237
+ /** The time a certificate was revoked, as an ISO 8601 string */
238
+ type RevokedCertificate = { revoked_at: string }
152
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
+ */
153
251
  export const revokeCertificate = async (env: SshCaBindings, serial: bigint): Promise<RevokedCertificate[]> => {
154
- const qb = await connect(env)
155
- const res = await qb.update<RevokedCertificate>({
156
- tableName: "certificates",
157
- data: {
158
- revoked_at: (new Date).toISOString()
159
- },
160
- where: {
161
- conditions: "serial = ?",
162
- params: `${serial}`
163
- },
164
- returning: ["revoked_at"]
165
- }).execute()
252
+ // ensure migrations have been applied
253
+ await connect(env)
166
254
 
167
- 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)) {
168
267
  throw new Error("error during query")
169
268
  }
170
269
 
171
- if (res.results === undefined) {
172
- return []
173
- }
270
+ return results[0]?.meta?.changes === 1 ? [{ revoked_at: revokedAt }] : []
271
+ }
272
+
273
+ const KrlRevisionRow = z.object({
274
+ revision: z.number().int().nonnegative(),
275
+ })
174
276
 
175
- return res.results
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)
176
297
  }
177
298
 
178
299
  const KrlVersionRow = z.object({
179
300
  version: z.number().int().nonnegative(),
180
301
  digest: z.string().nullable(),
302
+ updated_at: z.string(),
181
303
  })
182
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
+
183
320
  // the number of times to retry when another request updates the KRL version concurrently
184
321
  const KRL_VERSION_ATTEMPTS = 5
185
322
 
@@ -194,15 +331,16 @@ const KRL_VERSION_ATTEMPTS = 5
194
331
  * @param env The bindings for the CA, see {@link SshCaBindings}
195
332
  * @param certificateType The type of certificates in the KRL, see {@link CertificateType}
196
333
  * @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
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
198
336
  */
199
- 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> => {
200
338
  const qb = await connect(env)
201
339
 
202
340
  for (let attempt = 0; attempt < KRL_VERSION_ATTEMPTS; attempt++) {
203
341
  const res = await qb.fetchOne({
204
342
  tableName: "krl",
205
- fields: ["version", "digest"],
343
+ fields: ["version", "digest", "updated_at"],
206
344
  where: {
207
345
  conditions: "certificate_type = ?",
208
346
  params: certificateType
@@ -211,17 +349,19 @@ export const getKrlVersion = async (env: SshCaBindings, certificateType: Certifi
211
349
 
212
350
  const current = KrlVersionRow.parse(res.results)
213
351
  if (current.digest === digest) {
214
- return BigInt(current.version)
352
+ return { version: BigInt(current.version), generatedAt: parseTimestamp(current.updated_at) }
215
353
  }
216
354
 
217
- // 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
218
357
  const next = current.version + 1
358
+ const generatedAt = new Date(Math.floor(Date.now() / 1000) * 1000)
219
359
  const updated = await qb.update<Pick<KrlRecord, "version">>({
220
360
  tableName: "krl",
221
361
  data: {
222
362
  version: next,
223
363
  digest: digest,
224
- updated_at: (new Date).toISOString()
364
+ updated_at: generatedAt.toISOString()
225
365
  },
226
366
  where: {
227
367
  conditions: "certificate_type = ? AND version = ?",
@@ -235,7 +375,7 @@ export const getKrlVersion = async (env: SshCaBindings, certificateType: Certifi
235
375
  }
236
376
 
237
377
  if (updated.results !== undefined && updated.results.length > 0) {
238
- return BigInt(next)
378
+ return { version: BigInt(next), generatedAt }
239
379
  }
240
380
  }
241
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 */