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