@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.
- package/README.md +174 -1
- package/dist/api/operations.d.ts +50 -0
- package/dist/api/operations.d.ts.map +1 -0
- package/dist/api/operations.js +264 -0
- package/dist/api/operations.js.map +1 -0
- package/dist/api/schema.d.ts +72 -0
- package/dist/api/schema.d.ts.map +1 -0
- package/dist/api/schema.js +114 -0
- package/dist/api/schema.js.map +1 -0
- package/dist/api/v3/index.d.ts.map +1 -1
- package/dist/api/v3/index.js +13 -202
- package/dist/api/v3/index.js.map +1 -1
- package/dist/api/v3/schema.d.ts +23 -68
- package/dist/api/v3/schema.d.ts.map +1 -1
- package/dist/api/v3/schema.js +18 -104
- package/dist/api/v3/schema.js.map +1 -1
- package/dist/api/v4/index.d.ts +8 -0
- package/dist/api/v4/index.d.ts.map +1 -0
- package/dist/api/v4/index.js +155 -0
- package/dist/api/v4/index.js.map +1 -0
- package/dist/api/v4/schema.d.ts +268 -0
- package/dist/api/v4/schema.d.ts.map +1 -0
- package/dist/api/v4/schema.js +186 -0
- package/dist/api/v4/schema.js.map +1 -0
- package/dist/certificate.d.ts +9 -6
- package/dist/certificate.d.ts.map +1 -1
- package/dist/certificate.js +55 -99
- package/dist/certificate.js.map +1 -1
- package/dist/db/index.d.ts +68 -9
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +165 -49
- package/dist/db/index.js.map +1 -1
- package/dist/db/migrations/0003_krl_revision.d.ts +3 -0
- package/dist/db/migrations/0003_krl_revision.d.ts.map +1 -0
- package/dist/db/migrations/0003_krl_revision.js +14 -0
- package/dist/db/migrations/0003_krl_revision.js.map +1 -0
- package/dist/db/migrations/index.d.ts.map +1 -1
- package/dist/db/migrations/index.js +2 -0
- package/dist/db/migrations/index.js.map +1 -1
- package/dist/db/types.d.ts +2 -0
- package/dist/db/types.d.ts.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/krl.d.ts +3 -2
- package/dist/krl.d.ts.map +1 -1
- package/dist/krl.js +12 -165
- package/dist/krl.js.map +1 -1
- package/dist/node/cache.d.ts +21 -0
- package/dist/node/cache.d.ts.map +1 -0
- package/dist/node/cache.js +49 -0
- package/dist/node/cache.js.map +1 -0
- package/dist/node/env.d.ts +6 -3
- package/dist/node/env.d.ts.map +1 -1
- package/dist/node/env.js +4 -3
- package/dist/node/env.js.map +1 -1
- package/dist/node/index.d.ts +1 -0
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -0
- package/dist/node/index.js.map +1 -1
- package/dist/proof.d.ts +36 -7
- package/dist/proof.d.ts.map +1 -1
- package/dist/proof.js +53 -21
- package/dist/proof.js.map +1 -1
- package/dist/router.d.ts +25 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +41 -2
- package/dist/router.js.map +1 -1
- package/dist/ssh/certificate.d.ts +93 -0
- package/dist/ssh/certificate.d.ts.map +1 -0
- package/dist/ssh/certificate.js +178 -0
- package/dist/ssh/certificate.js.map +1 -0
- package/dist/ssh/encoding.d.ts +41 -0
- package/dist/ssh/encoding.d.ts.map +1 -0
- package/dist/ssh/encoding.js +70 -0
- package/dist/ssh/encoding.js.map +1 -0
- package/dist/ssh/keys.d.ts +92 -0
- package/dist/ssh/keys.d.ts.map +1 -0
- package/dist/ssh/keys.js +277 -0
- package/dist/ssh/keys.js.map +1 -0
- package/dist/ssh/signer.d.ts +35 -0
- package/dist/ssh/signer.d.ts.map +1 -0
- package/dist/ssh/signer.js +99 -0
- package/dist/ssh/signer.js.map +1 -0
- package/dist/types.d.ts +64 -19
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +107 -21
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +251 -65
- package/dist/utils.js.map +1 -1
- package/dist/verify.d.ts +17 -5
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +47 -9
- package/dist/verify.js.map +1 -1
- package/openapi.json +1942 -1
- package/package.json +4 -3
- package/src/api/operations.ts +314 -0
- package/src/api/schema.ts +146 -0
- package/src/api/v3/index.ts +21 -248
- package/src/api/v3/schema.ts +36 -128
- package/src/api/v4/index.ts +266 -0
- package/src/api/v4/schema.ts +241 -0
- package/src/certificate.ts +62 -123
- package/src/db/index.ts +196 -56
- package/src/db/migrations/0003_krl_revision.ts +15 -0
- package/src/db/migrations/index.ts +2 -0
- package/src/db/types.ts +2 -0
- package/src/index.ts +6 -4
- package/src/krl.ts +13 -197
- package/src/node/cache.ts +62 -0
- package/src/node/env.ts +6 -5
- package/src/node/index.ts +1 -0
- package/src/proof.ts +65 -31
- package/src/router.ts +67 -3
- package/src/ssh/certificate.ts +236 -0
- package/src/ssh/encoding.ts +81 -0
- package/src/ssh/keys.ts +327 -0
- package/src/ssh/signer.ts +129 -0
- package/src/types.ts +60 -20
- package/src/utils.ts +285 -81
- package/src/verify.ts +54 -14
package/src/certificate.ts
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
import { seconds } from "itty-time"
|
|
2
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
55
|
-
? [
|
|
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
|
-
|
|
72
|
+
certificatePrincipals.push(email)
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
if (principals !== undefined) {
|
|
69
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
139
|
-
|
|
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
|
|
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:
|
|
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:
|
|
175
|
-
|
|
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
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
108
|
-
params: [`${serial}`,
|
|
195
|
+
conditions: "serial = ? AND certificate_type = ?",
|
|
196
|
+
params: [`${serial}`, certificateType]
|
|
109
197
|
}
|
|
110
198
|
}).execute()
|
|
111
199
|
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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<
|
|
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:
|
|
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 */
|