@andrewheberle/serverless-ssh-ca 0.17.13 → 0.17.16

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 (114) hide show
  1. package/README.md +8 -19
  2. package/dist/api/v3/index.d.ts +1 -0
  3. package/dist/api/v3/index.d.ts.map +1 -0
  4. package/dist/api/v3/index.js +2 -1
  5. package/dist/api/v3/index.js.map +1 -0
  6. package/dist/api/v3/schema.d.ts +47 -1
  7. package/dist/api/v3/schema.d.ts.map +1 -0
  8. package/dist/api/v3/schema.js +65 -61
  9. package/dist/api/v3/schema.js.map +1 -0
  10. package/dist/certificate.d.ts +1 -0
  11. package/dist/certificate.d.ts.map +1 -0
  12. package/dist/certificate.js +11 -6
  13. package/dist/certificate.js.map +1 -0
  14. package/dist/{db.d.ts → db/index.d.ts} +6 -6
  15. package/dist/db/index.d.ts.map +1 -0
  16. package/dist/db/index.js +151 -0
  17. package/dist/db/index.js.map +1 -0
  18. package/dist/db/migrations/0001_initial_schema.d.ts +3 -0
  19. package/dist/db/migrations/0001_initial_schema.d.ts.map +1 -0
  20. package/dist/db/migrations/0001_initial_schema.js +24 -0
  21. package/dist/db/migrations/0001_initial_schema.js.map +1 -0
  22. package/dist/db/migrations/index.d.ts +3 -0
  23. package/dist/db/migrations/index.d.ts.map +1 -0
  24. package/dist/db/migrations/index.js +5 -0
  25. package/dist/db/migrations/index.js.map +1 -0
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +1 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/krl.d.ts +1 -0
  31. package/dist/krl.d.ts.map +1 -0
  32. package/dist/krl.js +2 -1
  33. package/dist/krl.js.map +1 -0
  34. package/dist/logger.d.ts +1 -0
  35. package/dist/logger.d.ts.map +1 -0
  36. package/dist/logger.js +13 -1
  37. package/dist/logger.js.map +1 -0
  38. package/dist/proof.d.ts +2 -0
  39. package/dist/proof.d.ts.map +1 -0
  40. package/dist/proof.js +2 -1
  41. package/dist/proof.js.map +1 -0
  42. package/dist/router.d.ts +1 -0
  43. package/dist/router.d.ts.map +1 -0
  44. package/dist/router.js +9 -1
  45. package/dist/router.js.map +1 -0
  46. package/dist/sshsig/armor.d.ts +1 -0
  47. package/dist/sshsig/armor.d.ts.map +1 -0
  48. package/dist/sshsig/armor.js +1 -0
  49. package/dist/sshsig/armor.js.map +1 -0
  50. package/dist/sshsig/formats.d.ts +1 -0
  51. package/dist/sshsig/formats.d.ts.map +1 -0
  52. package/dist/sshsig/formats.js +1 -0
  53. package/dist/sshsig/formats.js.map +1 -0
  54. package/dist/sshsig/index.d.ts +1 -0
  55. package/dist/sshsig/index.d.ts.map +1 -0
  56. package/dist/sshsig/index.js +1 -0
  57. package/dist/sshsig/index.js.map +1 -0
  58. package/dist/sshsig/reader.d.ts +1 -0
  59. package/dist/sshsig/reader.d.ts.map +1 -0
  60. package/dist/sshsig/reader.js +1 -0
  61. package/dist/sshsig/reader.js.map +1 -0
  62. package/dist/sshsig/sig.d.ts +1 -0
  63. package/dist/sshsig/sig.d.ts.map +1 -0
  64. package/dist/sshsig/sig.js +1 -0
  65. package/dist/sshsig/sig.js.map +1 -0
  66. package/dist/sshsig/sig_parser.d.ts +1 -0
  67. package/dist/sshsig/sig_parser.d.ts.map +1 -0
  68. package/dist/sshsig/sig_parser.js +1 -0
  69. package/dist/sshsig/sig_parser.js.map +1 -0
  70. package/dist/sshsig/verifier.d.ts +1 -0
  71. package/dist/sshsig/verifier.d.ts.map +1 -0
  72. package/dist/sshsig/verifier.js +1 -0
  73. package/dist/sshsig/verifier.js.map +1 -0
  74. package/dist/sshsig/writer.d.ts +1 -0
  75. package/dist/sshsig/writer.d.ts.map +1 -0
  76. package/dist/sshsig/writer.js +1 -0
  77. package/dist/sshsig/writer.js.map +1 -0
  78. package/dist/types.d.ts +8 -8
  79. package/dist/types.d.ts.map +1 -0
  80. package/dist/types.js +1 -0
  81. package/dist/types.js.map +1 -0
  82. package/dist/utils.d.ts +2 -1
  83. package/dist/utils.d.ts.map +1 -0
  84. package/dist/utils.js +14 -11
  85. package/dist/utils.js.map +1 -0
  86. package/dist/verify.d.ts +1 -0
  87. package/dist/verify.d.ts.map +1 -0
  88. package/dist/verify.js +1 -0
  89. package/dist/verify.js.map +1 -0
  90. package/package.json +26 -10
  91. package/src/api/v3/index.ts +419 -0
  92. package/src/api/v3/schema.ts +273 -0
  93. package/src/certificate.ts +220 -0
  94. package/src/db/index.ts +194 -0
  95. package/src/db/migrations/0001_initial_schema.ts +25 -0
  96. package/src/db/migrations/index.ts +6 -0
  97. package/src/index.ts +18 -0
  98. package/src/krl.ts +414 -0
  99. package/src/logger.ts +16 -0
  100. package/src/proof.ts +187 -0
  101. package/src/router.ts +42 -0
  102. package/src/sshsig/armor.ts +24 -0
  103. package/src/sshsig/formats.ts +226 -0
  104. package/src/sshsig/index.ts +47 -0
  105. package/src/sshsig/reader.ts +59 -0
  106. package/src/sshsig/sig.ts +17 -0
  107. package/src/sshsig/sig_parser.ts +90 -0
  108. package/src/sshsig/verifier.ts +85 -0
  109. package/src/sshsig/writer.ts +56 -0
  110. package/src/types.ts +48 -0
  111. package/src/utils.ts +412 -0
  112. package/src/verify.ts +25 -0
  113. package/LICENSE +0 -201
  114. package/dist/db.js +0 -111
@@ -0,0 +1,273 @@
1
+ import z from "zod"
2
+ import type { SshCaBindings } from "../../types"
3
+ import {
4
+ split,
5
+ refineCertificateRequest,
6
+ transformAuthorizationHeader,
7
+ transformProofOfPossession,
8
+ transformPublicKey,
9
+ transformRenewalProofOfPossession,
10
+ refineHostCertificateRequest,
11
+ transformCertificate,
12
+ refineHostCertificateRenewal,
13
+ refineRevokeCertificate,
14
+ transformIdentityToken,
15
+ } from "../../utils"
16
+ import {
17
+ ConflictException,
18
+ contentJson,
19
+ ForbiddenException,
20
+ InputValidationException,
21
+ InternalServerErrorException,
22
+ UnauthorizedException,
23
+ UnprocessableEntityException,
24
+ } from "chanfana"
25
+ import { seconds } from "itty-time"
26
+ import { isRevoked } from "../../db"
27
+
28
+ const openapiStringByte = z.base64()
29
+ .transform((v) => {
30
+ const a = z.util.base64ToUint8Array(v)
31
+ return Buffer.from(
32
+ a.buffer,
33
+ a.byteOffset,
34
+ a.length
35
+ )
36
+ })
37
+ .openapi({ format: "byte" })
38
+
39
+ const proofOfPossession = z.string()
40
+ .meta({
41
+ description: "Proof of possession comprising of ${timestamp}.${fingerprint}.${signature}",
42
+ example: "1765696780805.SHA256:79mq0wtpQMTvS4+Of8VzLN0qmYWNUyTXYmqKwhEgSLs.LS0tLS1CRUdJTiBTU0ggU0lHTkFUVVJFLS0tLS0KVTFOSVUwbEhBQUFBQVFBQUFHZ0FBQUFUWldOa2MyRXRjMmhoTWkxdWFYTjBjREkxTmdBQUFBaHVhWE4wY0RJMQpOZ0FBQUVFRUR6Ymk4OE10alBYSDFCb1JURU9HaS96aEc4QnhQNVZUUW5OQkFNMGZtMExtekErUUZkUlNVYVRFCkdNN2l0QjhINmZNZFAydlAxcElQcGUyOWVLQkJZd0FBQUFSbWFXeGxBQUFBQUFBQUFBWnphR0UxTVRJQUFBQmsKQUFBQUUyVmpaSE5oTFhOb1lUSXRibWx6ZEhBeU5UWUFBQUJKQUFBQUlHdmZVZGNDQkRXa3ZwL0ZHS3NkakgrRQpKZzZXN0EvWUJVZng2Z0srQ01raEFBQUFJUUQxOFd5cDNZejZ0eXNvdllXbEJHTXprRnlYbFRrSHM5blVVRXJqCkczNWVPQT09Ci0tLS0tRU5EIFNTSCBTSUdOQVRVUkUtLS0tLQo="
43
+ })
44
+
45
+ const publicKey = openapiStringByte
46
+ .transform(transformPublicKey)
47
+ .meta({
48
+ description: "SSH public key to sign",
49
+ example: "ZWNkc2Etc2hhMi1uaXN0cDI1NiBBQUFBRTJWalpITmhMWE5vWVRJdGJtbHpkSEF5TlRZQUFBQUlibWx6ZEhBeU5UWUFBQUJCQkdNcFNKdHRmMEl0dE5DVmMyOFR6WEZSMUQweHZPM25wNjdWemVBUXNiZFpza3JEY1lqU2x3SjZIUERtVHBYYVUwbEVhWDlMNFloYy9jQ2YxTWU5RlRrPQo="
50
+ })
51
+
52
+ export const createIdentityTokenSchema = (env: SshCaBindings) => (
53
+ z.string()
54
+ .meta({
55
+ description: "Identity Token JWT from OIDC IdP",
56
+ example: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwiaWF0IjoxNzc1NzAzMDk1fQ.ri_neAWnhMNK3LzlsrBcQYymSM4yRjmNSZZSeZiXhrEqtEz6c3cXk0Esq765umGjpUsWcosL-OFrDJlyAjTDnhrd9oV08uc_CW0rQRsJIGEuRo3ryxkLdVu9mGoZWEUb9KwjGJrwxvr-0cPWx5jaDyKwJcqMvtV_bEITUD51sDB1Vm89QfYRO_pGJo2vrRzSvMjpUenRpwPay4lYIBxl41_4YpR9Rc6VrIZuYsjV2iqEZ4eBrygMA7zPR_hN7l7s95FddLOzj5NsK57VT4uLHwYohx2oqMzw3M-B9HsZIQin_9q61pZFQXepzJth0woXiZheU27llnfHX967PhNQyg"
57
+ })
58
+ .transform((val, ctx) => transformIdentityToken(env, val, ctx))
59
+ )
60
+
61
+ export const createAccessTokenSchema = (env: SshCaBindings) => (
62
+ z.string()
63
+ .meta({
64
+ description: "Access Token JWT from OIDC IdP",
65
+ example: "Bearer eyJ..."
66
+ })
67
+ .transform((val, ctx) => transformAuthorizationHeader(env, val, ctx))
68
+ )
69
+
70
+ const krl = openapiStringByte
71
+ .meta({ description: "Key Revocation List" })
72
+
73
+ export const createHeaderSchema = (env: SshCaBindings) => (
74
+ z.object({
75
+ "Authorization": createAccessTokenSchema(env)
76
+ })
77
+ )
78
+
79
+ const certificate = openapiStringByte
80
+ .meta({ description: "Issued Certificate" })
81
+
82
+ export const CaPublicKeyEndpointSchema = {
83
+ responses: {
84
+ "200": {
85
+ content: {
86
+ "text/plain": {
87
+ schema: z.string()
88
+ }
89
+ },
90
+ description: "Returns SSH Certificate Authority Public Key",
91
+ },
92
+ ...InternalServerErrorException.schema(),
93
+ }
94
+ }
95
+
96
+ export const userCertificateRequestEndpointBodySchema = (env: SshCaBindings) => (
97
+ z.object({
98
+ public_key: publicKey,
99
+ proof: proofOfPossession
100
+ .transform((val, ctx) => transformProofOfPossession(env, val, ctx)),
101
+ identity: createIdentityTokenSchema(env),
102
+ extensions: z.array(z.string())
103
+ .default(split(env.SSH_CERTIFICATE_EXTENSIONS))
104
+ .meta({ description: "Extensions to include in the issued SSH certificate" }),
105
+ lifetime: z.int()
106
+ .min(seconds("5 minutes"))
107
+ .max(seconds(env.SSH_CERTIFICATE_LIFETIME))
108
+ .default(seconds(env.SSH_CERTIFICATE_LIFETIME))
109
+ .meta({ description: "Lifetime of issued SSH certificate" }),
110
+ })
111
+ .superRefine((val, ctx) => refineCertificateRequest(env, val, ctx))
112
+ .openapi("User Certificate Request")
113
+ )
114
+
115
+ export const createUserCertificateRequestEndpointSchema = (env: SshCaBindings) => ({
116
+ security: [{ oidcAuth: [] }],
117
+ request: {
118
+ headers: createHeaderSchema(env),
119
+ body: contentJson(userCertificateRequestEndpointBodySchema(env)),
120
+ },
121
+ responses: {
122
+ "200": {
123
+ description: "SSH User Certificate issued successfully",
124
+ ...contentJson(z.object({
125
+ certificate: certificate,
126
+ })
127
+ .openapi("Certificate Response")
128
+ )
129
+ },
130
+ ...UnauthorizedException.schema(),
131
+ ...ForbiddenException.schema(),
132
+ ...InputValidationException.schema(),
133
+ ...UnprocessableEntityException.schema(),
134
+ ...InternalServerErrorException.schema(),
135
+ }
136
+ })
137
+
138
+ export const createRevocationListEndpointSchema = () => ({
139
+ request: {
140
+ params: z.object({
141
+ certificateType: z.enum(["user", "host"])
142
+ }),
143
+ },
144
+ responses: {
145
+ "200": {
146
+ description: "Returns an Open SSH Key Revocation List as BASE64 and an SSHSIG signature for verification",
147
+ ...contentJson(z.object({
148
+ krl: krl,
149
+ signature: z.string()
150
+ })
151
+ .openapi("Key Revocation List Response")
152
+ )
153
+ },
154
+ ...InternalServerErrorException.schema(),
155
+ }
156
+ })
157
+
158
+ export const hostCertificateRequestEndpointBodySchema = (env: SshCaBindings) => (
159
+ z.object({
160
+ public_key: publicKey,
161
+ proof: proofOfPossession
162
+ .transform((val, ctx) => transformProofOfPossession(env, val, ctx)),
163
+ identity: createIdentityTokenSchema(env),
164
+ principals: z.array(z.string()).min(1)
165
+ .meta({ description: "List of principals to include on the issued certificate" }),
166
+ lifetime: z.int()
167
+ .min(seconds("24 hours"))
168
+ .max(seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
169
+ .default(seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
170
+ .meta({ description: "Lifetime of issued Host SSH certificate" }),
171
+ })
172
+ .superRefine((val, ctx) => refineHostCertificateRequest(env, val, ctx))
173
+ .openapi("Host Certificate Request")
174
+ )
175
+
176
+ export const createHostCertificateRequestEndpointSchema = (env: SshCaBindings) => ({
177
+ security: [{ oidcAuth: [] }],
178
+ request: {
179
+ headers: createHeaderSchema(env),
180
+ body: contentJson(hostCertificateRequestEndpointBodySchema(env))
181
+ },
182
+ responses: {
183
+ "200": {
184
+ description: "SSH Host Certificate issued successfully",
185
+ ...contentJson(z.object({
186
+ certificate: certificate,
187
+ })
188
+ .openapi("Certificate Response")
189
+ )
190
+ },
191
+ ...UnauthorizedException.schema(),
192
+ ...ForbiddenException.schema(),
193
+ ...InputValidationException.schema(),
194
+ ...UnprocessableEntityException.schema(),
195
+ ...InternalServerErrorException.schema(),
196
+ }
197
+ })
198
+
199
+ export const hostCertificateRenewEndpointBodySchema = (env: SshCaBindings) => (
200
+ z.object({
201
+ certificate: openapiStringByte
202
+ .transform(transformCertificate)
203
+ .meta({ description: "SSH certificate to renew" }),
204
+ public_key: publicKey,
205
+ proof: proofOfPossession
206
+ .transform((val, ctx) => transformRenewalProofOfPossession(env, val, ctx)),
207
+ lifetime: z.int()
208
+ .min(seconds("24 hours"))
209
+ .max(seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
210
+ .default(seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
211
+ .meta({ description: "Lifetime of renewed Host SSH certificate" }),
212
+ })
213
+ .superRefine((val, ctx) => refineHostCertificateRenewal(env, isRevoked, val, ctx))
214
+ .openapi("Host Certificate Renew")
215
+ )
216
+
217
+ export const createHostCertificateRenewEndpointSchema = (env: SshCaBindings) => ({
218
+ request: {
219
+ body: contentJson(hostCertificateRenewEndpointBodySchema(env))
220
+ },
221
+ responses: {
222
+ "200": {
223
+ description: "SSH Host Certificate renewed successfully",
224
+ ...contentJson(z.object({
225
+ certificate: certificate
226
+ })
227
+ .openapi("Certificate Response")
228
+ ),
229
+ },
230
+ ...UnauthorizedException.schema(),
231
+ ...ForbiddenException.schema(),
232
+ ...InputValidationException.schema(),
233
+ ...UnprocessableEntityException.schema(),
234
+ ...InternalServerErrorException.schema(),
235
+ }
236
+ })
237
+
238
+ export const revokeCertificateEndpointBodySchema = (env: SshCaBindings) => (
239
+ z.object({
240
+ serial: z.bigint()
241
+ .meta({ description: "Serial number of certificate to revoke" }),
242
+ public_key: publicKey,
243
+ proof: proofOfPossession
244
+ .transform((val, ctx) => transformProofOfPossession(env, val, ctx)),
245
+ })
246
+ .openapi("Certificate Revocation")
247
+ .superRefine((val, ctx) => refineRevokeCertificate(env, val, ctx))
248
+ )
249
+
250
+ export const createRevokeCertificateEndpointSchema = (env: SshCaBindings) => ({
251
+ request: {
252
+ params: z.object({
253
+ certificateType: z.enum(["user", "host"])
254
+ }),
255
+ body: contentJson(revokeCertificateEndpointBodySchema(env))
256
+ },
257
+ responses: {
258
+ "200": {
259
+ description: "SSH certificate revoked successfully",
260
+ ...contentJson(z.object({
261
+ revoked_at: z.iso.datetime(),
262
+ })
263
+ .openapi("Revocation Response")
264
+ ),
265
+ },
266
+ ...UnauthorizedException.schema(),
267
+ ...ForbiddenException.schema(),
268
+ ...InputValidationException.schema(),
269
+ ...UnprocessableEntityException.schema(),
270
+ ...InternalServerErrorException.schema(),
271
+ ...ConflictException.schema()
272
+ }
273
+ })
@@ -0,0 +1,220 @@
1
+ import { seconds } from "itty-time"
2
+ import { Certificate, createCertificate, Identity, identityForHost, identityForUser, identityFromDN, Key, parsePrivateKey, PrivateKey } from "sshpk"
3
+ import { SSHExtension } from "./types"
4
+ import { split } from "./utils"
5
+ import type { SshCaBindings } from "./types"
6
+
7
+ // const sshCertificateExtensions = split(env.SSH_CERTIFICATE_EXTENSIONS)
8
+
9
+ export type CreateCertificateOptions = {
10
+ lifetime?: number
11
+ principals?: string[]
12
+ extensions?: string[]
13
+ }
14
+
15
+ /* const DefaultCreateCertificateOptions: CreateCertificateOptions = {
16
+ lifetime: seconds(env.SSH_CERTIFICATE_LIFETIME),
17
+ principals: [],
18
+ extensions: sshCertificateExtensions
19
+ } */
20
+
21
+ export class CertificateError extends Error {
22
+ constructor(message: string) {
23
+ super(message)
24
+ this.name = "CertificateError"
25
+
26
+ // This is necessary for proper stack trace in TypeScript
27
+ Object.setPrototypeOf(this, CertificateError.prototype)
28
+ }
29
+ }
30
+
31
+ export type GenerateCertificateOptions = {
32
+ /**
33
+ * @internal
34
+ * Override the certificate serial number.
35
+ * Should only be set in tests.
36
+ */
37
+ serial?: bigint
38
+ } & CreateCertificateOptions
39
+
40
+ export const generateCertificate = (env: SshCaBindings, email: string, key: PrivateKey, public_key: Key, options?: GenerateCertificateOptions): Certificate => {
41
+ let { lifetime } = options ?? {}
42
+ const { principals, extensions, serial } = options ?? {}
43
+
44
+ // add identities
45
+ const includeSelf = env.SSH_CERTIFICATE_INCLUDE_SELF === undefined
46
+ ? false
47
+ : typeof env.SSH_CERTIFICATE_INCLUDE_SELF === "string"
48
+ ? env.SSH_CERTIFICATE_INCLUDE_SELF === "true"
49
+ : env.SSH_CERTIFICATE_INCLUDE_SELF
50
+ const identity = includeSelf
51
+ ? [identityForUser(email.split("@")[0]!)]
52
+ : []
53
+ if (principals !== undefined) {
54
+ for (const p of principals) {
55
+ identity.push(identityForUser(p))
56
+ }
57
+ }
58
+
59
+ for (const p of split(env.SSH_CERTIFICATE_PRINCIPALS)) {
60
+ identity.push(identityForUser(p))
61
+ }
62
+
63
+
64
+ // lifetime is the smaller of what was provided in the options or the default
65
+ lifetime = lifetime !== undefined
66
+ ? Math.min(lifetime, seconds(env.SSH_CERTIFICATE_LIFETIME))
67
+ : seconds(env.SSH_CERTIFICATE_LIFETIME)
68
+
69
+ // generate value for serial of certificate
70
+ const s = generateSerial(serial)
71
+
72
+ // set issuer of certificate based on ISSUER_DN
73
+ const issuer = identityFromDN(env.ISSUER_DN)
74
+
75
+ // create certificate
76
+ const certificate = createCertificate(identity, public_key, issuer, key, { lifetime: lifetime, serial: s })
77
+
78
+ // ensure openssh info is included in certificate (should not occur)
79
+ if (certificate.signatures.openssh === undefined) {
80
+ throw new CertificateError("missing openssh information in certificate")
81
+ }
82
+
83
+ const sshCertificateExtensions = split(env.SSH_CERTIFICATE_EXTENSIONS)
84
+
85
+ // add usage extensions
86
+ const sshextensions: SSHExtension[] = []
87
+ if (extensions !== undefined) {
88
+ // if extensions are provided in request, ensure they do not include extra extensions beyond the defaults
89
+ for (const ext of extensions) {
90
+ if (!sshCertificateExtensions.includes(ext)) {
91
+ throw new CertificateError(`${ext} is not allowed`)
92
+ }
93
+
94
+ // add to list of allowed extensions
95
+ sshextensions.push({
96
+ critical: false,
97
+ name: ext,
98
+ data: Buffer.alloc(0)
99
+ })
100
+ }
101
+ } else {
102
+ // use defaults if not provided
103
+ sshCertificateExtensions.forEach((ext: string) => {
104
+ sshextensions.push({
105
+ critical: false,
106
+ name: ext,
107
+ data: Buffer.alloc(0)
108
+ })
109
+ })
110
+
111
+ }
112
+
113
+ // add info to certificate
114
+ certificate.signatures = {
115
+ openssh: {
116
+ nonce: certificate.signatures.openssh.nonce,
117
+ keyId: email,
118
+ signature: certificate.signatures.openssh.signature,
119
+ exts: sshextensions
120
+ }
121
+ }
122
+
123
+ // re-sign after changes
124
+ certificate.signWith(key)
125
+
126
+ return certificate
127
+ }
128
+
129
+ export async function createSignedCertificate(env: SshCaBindings, email: string, public_key: Key, options?: CreateCertificateOptions): Promise<Certificate> {
130
+ // grab private key from secret store
131
+ const secret = await env.PRIVATE_KEY.get()
132
+
133
+ // parse key
134
+ const key = parsePrivateKey(secret)
135
+
136
+ if (options === undefined) {
137
+ options = {
138
+ lifetime: seconds(env.SSH_CERTIFICATE_LIFETIME),
139
+ principals: [],
140
+ extensions: split(env.SSH_CERTIFICATE_EXTENSIONS)
141
+ }
142
+ }
143
+
144
+ return generateCertificate(env, email, key, public_key, options)
145
+ }
146
+
147
+ export type CreateHostCertificateOptions = {
148
+ lifetime?: number
149
+ principals?: string[]
150
+ subjects?: Identity[]
151
+ }
152
+
153
+ /* const DefaultCreateHostertificateOptions: CreateHostCertificateOptions = {
154
+ lifetime: seconds(env.SSH_HOST_CERTIFICATE_LIFETIME),
155
+ principals: [],
156
+ } */
157
+
158
+ export class BadIssuerError extends Error {
159
+ constructor(message: string) {
160
+ super(message)
161
+ this.name = "BadIssuerError"
162
+
163
+ Object.setPrototypeOf(this, BadIssuerError.prototype)
164
+ }
165
+ }
166
+
167
+ export async function createSignedHostCertificate(env: SshCaBindings, public_key: Key, options?: CreateHostCertificateOptions): Promise<Certificate> {
168
+ if (options === undefined) {
169
+ options = {
170
+ lifetime: seconds(env.SSH_HOST_CERTIFICATE_LIFETIME),
171
+ principals: []
172
+ }
173
+ }
174
+ // generate list of identities for host key
175
+ const identity: Identity[] = []
176
+ if (options.principals !== undefined) {
177
+ for (const p of options.principals) {
178
+ identity.push(identityForHost(p))
179
+ }
180
+ }
181
+
182
+ // add any already provided subjects (for renewals only)
183
+ if (options.subjects !== undefined) {
184
+ identity.push(...options.subjects)
185
+ }
186
+
187
+ // grab private key from secret store
188
+ const secret = await env.PRIVATE_KEY.get()
189
+
190
+ // parse private key
191
+ const key = parsePrivateKey(secret)
192
+
193
+ // lifetime is the smaller of what was provided in the options or the default
194
+ const lifetime = options.lifetime !== undefined
195
+ ? Math.min(options.lifetime, seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
196
+ : seconds(env.SSH_HOST_CERTIFICATE_LIFETIME)
197
+
198
+ // generate value for serial of certificate
199
+ const serial = generateSerial()
200
+
201
+ // set issuer of certificate based on ISSUER_DN
202
+ const issuer = identityFromDN(env.ISSUER_DN)
203
+
204
+ // create certificate
205
+ const certificate = createCertificate(identity, public_key, issuer, key, { lifetime: lifetime, serial: serial })
206
+
207
+ return certificate
208
+ }
209
+
210
+ export const generateSerial = (serial?: bigint): Buffer<ArrayBuffer> => {
211
+ if (serial !== undefined) {
212
+ const s = Buffer.alloc(8)
213
+ s.writeBigUInt64BE(serial)
214
+
215
+ return s
216
+ }
217
+
218
+ const randomBytes = crypto.getRandomValues(new Uint8Array(8))
219
+ return Buffer.from(randomBytes)
220
+ }
@@ -0,0 +1,194 @@
1
+ import { Certificate, Format, Identity } from "sshpk"
2
+ import { logger } from "../logger"
3
+ import type { SshCaBindings } from "../types"
4
+ import { D1QB } from "workers-qb"
5
+ import { migrations } from "./migrations"
6
+
7
+ export enum CertificateType {
8
+ User,
9
+ Host
10
+ }
11
+
12
+ export type CertificateRecord = {
13
+ id: number
14
+ serial: string
15
+ key_id: string
16
+ principals: string
17
+ extensions: string | null
18
+ valid_after: Date
19
+ valid_before: Date
20
+ revoked_at: Date | null
21
+ certificate_type: CertificateType
22
+ public_key: string | null
23
+ }
24
+
25
+ export type DatabaseSchema = {
26
+ certificates: CertificateRecord
27
+ }
28
+
29
+ const connect = async (env: SshCaBindings): Promise<D1QB<DatabaseSchema>> => {
30
+ const l = logger(env)
31
+
32
+ try {
33
+ l.info("connecting to database")
34
+
35
+ const qb = new D1QB<DatabaseSchema>(env.DB)
36
+ const migrationBuilder = qb.migrations({ migrations })
37
+ const appliedMigrations = await migrationBuilder.apply()
38
+
39
+ l.info("applied database migrations", "migrations", appliedMigrations.length, "migration_names", appliedMigrations.map(m => m.name))
40
+
41
+ return qb
42
+ } catch (err) {
43
+ l.error("error connecting to database", "error", err)
44
+ throw err
45
+ }
46
+ }
47
+
48
+ export const dbCleanup = async (env: SshCaBindings) => {
49
+ const l = logger(env).with("retention", env.DB_CERTIFICATE_RETENTION)
50
+
51
+ if (env.DB_CERTIFICATE_RETENTION === "infinite") {
52
+ l.info("skipping database cleanup")
53
+ }
54
+
55
+ l.info("starting database cleanup")
56
+ try {
57
+ const qb = await connect(env)
58
+ const res = await qb.delete({
59
+ tableName: "certificates",
60
+ where: {
61
+ conditions: "unixepoch('subsec') > unixepoch(valid_before,'subsec',?)",
62
+ params: env.DB_CERTIFICATE_RETENTION
63
+ }
64
+ }).execute()
65
+ l.info("completed database cleanup", "changes", res.meta?.changes)
66
+ } catch (err) {
67
+ l.error("error during database cleanup", "retention", env.DB_CERTIFICATE_RETENTION, "error", err)
68
+ }
69
+ }
70
+
71
+ export const recordCertificate = async (env: SshCaBindings, certificate: Certificate, keyid: string, certificateType: CertificateType = CertificateType.User) => {
72
+ const serial = certificate.serial.readBigUInt64BE(0)
73
+ const subjects = certificate.subjects.map((v: Identity): string => {
74
+ return v.toString()
75
+ }).join(",")
76
+ const extensions = certificate.getExtensions().map((v: Format.OpenSshSignatureExt | Format.x509SignatureExt): string => {
77
+ // @ts-expect-error: the name property does exist
78
+ return v.name as string
79
+ }).join(",")
80
+ const qb = await connect(env)
81
+ const res = await qb.insert({
82
+ tableName: "certificates",
83
+ data: {
84
+ serial: `${serial}`,
85
+ key_id: keyid,
86
+ principals: subjects,
87
+ extensions: extensions,
88
+ valid_after: certificate.validFrom.toISOString(),
89
+ valid_before: certificate.validUntil.toISOString(),
90
+ certificate_type: certificateType,
91
+ public_key: certificate.subjectKey.toString("ssh")
92
+ },
93
+ }).execute()
94
+
95
+ if (!res.success) {
96
+ throw new Error("error during query")
97
+ }
98
+ }
99
+
100
+ export const isRevoked = async (env: SshCaBindings, serial: bigint): Promise<boolean> => {
101
+ const qb = await connect(env)
102
+ const res = await qb.fetchOne({
103
+ tableName: "certificates",
104
+ fields: ["serial"],
105
+ where: {
106
+ conditions: "serial = ? AND revoked_at NOTNULL",
107
+ params: `${serial}`
108
+ },
109
+ }).execute()
110
+
111
+ return res.results !== undefined
112
+ }
113
+
114
+ export enum RevocationStatus {
115
+ NotFound,
116
+ Revoked,
117
+ Revokable,
118
+ Unrevokable
119
+ }
120
+
121
+ export const revocationStatus = async (env: SshCaBindings, serial: bigint, publickey: string, certificateType: CertificateType): Promise<RevocationStatus> => {
122
+ const qb = await connect(env)
123
+ const res = await qb.fetchOne({
124
+ tableName: "certificates",
125
+ fields: ["public_key", "revoked_at"],
126
+ where: {
127
+ conditions: "serial = ? AND public_key = ? AND certificate_type = ?",
128
+ params: [`${serial}`, publickey, certificateType]
129
+ }
130
+ }).execute()
131
+
132
+ if (res.results === undefined) {
133
+ return RevocationStatus.NotFound
134
+ }
135
+
136
+ if (res.results.revoked_at !== null) {
137
+ return RevocationStatus.Revoked
138
+ }
139
+
140
+ if (publickey === res.results.public_key) {
141
+ return RevocationStatus.Revokable
142
+ }
143
+
144
+ return RevocationStatus.Unrevokable
145
+ }
146
+
147
+ export const getRevocationList = async (env: SshCaBindings, certificateType: CertificateType): Promise<string[]> => {
148
+ const qb = await connect(env)
149
+ const res = await qb.fetchAll({
150
+ tableName: "certificates",
151
+ fields: ["serial"],
152
+ where: {
153
+ conditions: "revoked_at NOTNULL AND certificate_type = ? AND unixepoch('subsec') < unixepoch(valid_before,'subsec','24 hours')",
154
+ params: certificateType
155
+ }
156
+ }).execute()
157
+
158
+ if (!res.success) {
159
+ throw new Error("error during query")
160
+ }
161
+
162
+ if (res.results === undefined) {
163
+ return []
164
+ }
165
+
166
+ return res.results.map(item => item.serial)
167
+ }
168
+
169
+ type RevokedCertificate = Pick<CertificateRecord, "revoked_at">
170
+
171
+ export const revokeCertificate = async (env: SshCaBindings, serial: bigint): Promise<RevokedCertificate[]> => {
172
+ const qb = await connect(env)
173
+ const res = await qb.update<RevokedCertificate>({
174
+ tableName: "certificates",
175
+ data: {
176
+ revoked_at: (new Date).toISOString()
177
+ },
178
+ where: {
179
+ conditions: "serial = ?",
180
+ params: `${serial}`
181
+ },
182
+ returning: ["revoked_at"]
183
+ }).execute()
184
+
185
+ if (!res.success) {
186
+ throw new Error("error during query")
187
+ }
188
+
189
+ if (res.results === undefined) {
190
+ return []
191
+ }
192
+
193
+ return res.results
194
+ }
@@ -0,0 +1,25 @@
1
+ import { type Migration } from "workers-qb";
2
+
3
+ export const migration: Migration = {
4
+ name: "0001_initial_schema",
5
+ sql: `
6
+ CREATE TABLE IF NOT EXISTS certificates (
7
+ id INTEGER PRIMARY KEY,
8
+ serial TEXT UNIQUE NOT NULL,
9
+ key_id TEXT NOT NULL,
10
+ principals TEXT NOT NULL,
11
+ extensions TEXT,
12
+ valid_after TIMESTAMP NOT NULL,
13
+ valid_before TIMESTAMP NOT NULL,
14
+ revoked_at TIMESTAMP,
15
+ certificate_type INTEGER NOT NULL DEFAULT 0,
16
+ public_key TEXT
17
+ );
18
+
19
+ CREATE INDEX IF NOT EXISTS idx_certificates_serial ON certificates(serial);
20
+ CREATE INDEX IF NOT EXISTS idx_certificates_valid_after ON certificates(valid_after);
21
+ CREATE INDEX IF NOT EXISTS idx_certificates_valid_before ON certificates(valid_before);
22
+ CREATE INDEX IF NOT EXISTS idx_certificates_revoked_at ON certificates(revoked_at);
23
+ CREATE INDEX IF NOT EXISTS idx_certificates_certificate_type ON certificates(certificate_type);
24
+ `
25
+ }