@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.
- package/README.md +55 -1
- package/dist/api/operations.d.ts +49 -0
- package/dist/api/operations.d.ts.map +1 -0
- package/dist/api/operations.js +175 -0
- package/dist/api/operations.js.map +1 -0
- package/dist/api/schema.d.ts +71 -0
- package/dist/api/schema.d.ts.map +1 -0
- package/dist/api/schema.js +107 -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 +25 -212
- package/dist/api/v3/index.js.map +1 -1
- package/dist/api/v3/schema.d.ts +30 -74
- package/dist/api/v3/schema.d.ts.map +1 -1
- package/dist/api/v3/schema.js +18 -73
- 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 +182 -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 +32 -24
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +101 -28
- package/dist/db/index.js.map +1 -1
- package/dist/db/migrations/0002_krl_version.d.ts +3 -0
- package/dist/db/migrations/0002_krl_version.d.ts.map +1 -0
- package/dist/db/migrations/0002_krl_version.js +15 -0
- package/dist/db/migrations/0002_krl_version.js.map +1 -0
- package/dist/db/migrations/index.d.ts.map +1 -1
- package/dist/db/migrations/index.js +3 -1
- package/dist/db/migrations/index.js.map +1 -1
- package/dist/db/types.d.ts +37 -0
- package/dist/db/types.d.ts.map +1 -0
- package/dist/db/types.js +13 -0
- package/dist/db/types.js.map +1 -0
- package/dist/krl.d.ts +9 -1
- package/dist/krl.d.ts.map +1 -1
- package/dist/krl.js +22 -162
- package/dist/krl.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.map +1 -1
- package/dist/router.js +3 -1
- 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 +80 -0
- package/dist/ssh/keys.d.ts.map +1 -0
- package/dist/ssh/keys.js +249 -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 +12 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +40 -21
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +87 -54
- package/dist/utils.js.map +1 -1
- package/openapi.json +1960 -82
- package/package.json +5 -17
- package/src/api/operations.ts +211 -0
- package/src/api/schema.ts +137 -0
- package/src/api/v3/index.ts +32 -259
- package/src/api/v3/schema.ts +34 -96
- package/src/api/v4/index.ts +265 -0
- package/src/api/v4/schema.ts +235 -0
- package/src/certificate.ts +62 -123
- package/src/db/index.ts +113 -49
- package/src/db/migrations/0002_krl_version.ts +16 -0
- package/src/db/migrations/index.ts +3 -1
- package/src/db/types.ts +40 -0
- package/src/krl.ts +32 -194
- package/src/proof.ts +65 -31
- package/src/router.ts +3 -1
- package/src/ssh/certificate.ts +236 -0
- package/src/ssh/encoding.ts +81 -0
- package/src/ssh/keys.ts +294 -0
- package/src/ssh/signer.ts +129 -0
- package/src/types.ts +12 -18
- package/src/utils.ts +99 -69
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,31 +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"
|
|
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:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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:
|
|
87
|
-
extensions: extensions,
|
|
88
|
-
valid_after: certificate.
|
|
89
|
-
valid_before: certificate.
|
|
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.
|
|
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
|
-
|
|
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
|
|
128
|
-
params: [`${serial}`,
|
|
124
|
+
conditions: "serial = ? AND certificate_type = ?",
|
|
125
|
+
params: [`${serial}`, certificateType]
|
|
129
126
|
}
|
|
130
127
|
}).execute()
|
|
131
128
|
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
]
|
package/src/db/types.ts
ADDED
|
@@ -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
|
+
}
|