@andrewheberle/serverless-ssh-ca 0.22.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.
Files changed (84) hide show
  1. package/README.md +55 -1
  2. package/dist/api/operations.d.ts +49 -0
  3. package/dist/api/operations.d.ts.map +1 -0
  4. package/dist/api/operations.js +175 -0
  5. package/dist/api/operations.js.map +1 -0
  6. package/dist/api/schema.d.ts +71 -0
  7. package/dist/api/schema.d.ts.map +1 -0
  8. package/dist/api/schema.js +107 -0
  9. package/dist/api/schema.js.map +1 -0
  10. package/dist/api/v3/index.d.ts.map +1 -1
  11. package/dist/api/v3/index.js +13 -202
  12. package/dist/api/v3/index.js.map +1 -1
  13. package/dist/api/v3/schema.d.ts +23 -68
  14. package/dist/api/v3/schema.d.ts.map +1 -1
  15. package/dist/api/v3/schema.js +10 -100
  16. package/dist/api/v3/schema.js.map +1 -1
  17. package/dist/api/v4/index.d.ts +8 -0
  18. package/dist/api/v4/index.d.ts.map +1 -0
  19. package/dist/api/v4/index.js +155 -0
  20. package/dist/api/v4/index.js.map +1 -0
  21. package/dist/api/v4/schema.d.ts +268 -0
  22. package/dist/api/v4/schema.d.ts.map +1 -0
  23. package/dist/api/v4/schema.js +182 -0
  24. package/dist/api/v4/schema.js.map +1 -0
  25. package/dist/certificate.d.ts +9 -6
  26. package/dist/certificate.d.ts.map +1 -1
  27. package/dist/certificate.js +55 -99
  28. package/dist/certificate.js.map +1 -1
  29. package/dist/db/index.d.ts +17 -5
  30. package/dist/db/index.d.ts.map +1 -1
  31. package/dist/db/index.js +39 -23
  32. package/dist/db/index.js.map +1 -1
  33. package/dist/krl.d.ts +1 -1
  34. package/dist/krl.d.ts.map +1 -1
  35. package/dist/krl.js +9 -163
  36. package/dist/krl.js.map +1 -1
  37. package/dist/proof.d.ts +36 -7
  38. package/dist/proof.d.ts.map +1 -1
  39. package/dist/proof.js +53 -21
  40. package/dist/proof.js.map +1 -1
  41. package/dist/router.d.ts.map +1 -1
  42. package/dist/router.js +3 -1
  43. package/dist/router.js.map +1 -1
  44. package/dist/ssh/certificate.d.ts +93 -0
  45. package/dist/ssh/certificate.d.ts.map +1 -0
  46. package/dist/ssh/certificate.js +178 -0
  47. package/dist/ssh/certificate.js.map +1 -0
  48. package/dist/ssh/encoding.d.ts +41 -0
  49. package/dist/ssh/encoding.d.ts.map +1 -0
  50. package/dist/ssh/encoding.js +70 -0
  51. package/dist/ssh/encoding.js.map +1 -0
  52. package/dist/ssh/keys.d.ts +80 -0
  53. package/dist/ssh/keys.d.ts.map +1 -0
  54. package/dist/ssh/keys.js +249 -0
  55. package/dist/ssh/keys.js.map +1 -0
  56. package/dist/ssh/signer.d.ts +35 -0
  57. package/dist/ssh/signer.d.ts.map +1 -0
  58. package/dist/ssh/signer.js +99 -0
  59. package/dist/ssh/signer.js.map +1 -0
  60. package/dist/types.d.ts +12 -17
  61. package/dist/types.d.ts.map +1 -1
  62. package/dist/utils.d.ts +40 -21
  63. package/dist/utils.d.ts.map +1 -1
  64. package/dist/utils.js +87 -54
  65. package/dist/utils.js.map +1 -1
  66. package/openapi.json +1542 -1
  67. package/package.json +4 -3
  68. package/src/api/operations.ts +211 -0
  69. package/src/api/schema.ts +137 -0
  70. package/src/api/v3/index.ts +20 -248
  71. package/src/api/v3/schema.ts +26 -125
  72. package/src/api/v4/index.ts +265 -0
  73. package/src/api/v4/schema.ts +235 -0
  74. package/src/certificate.ts +62 -123
  75. package/src/db/index.ts +41 -26
  76. package/src/krl.ts +10 -195
  77. package/src/proof.ts +65 -31
  78. package/src/router.ts +3 -1
  79. package/src/ssh/certificate.ts +236 -0
  80. package/src/ssh/encoding.ts +81 -0
  81. package/src/ssh/keys.ts +294 -0
  82. package/src/ssh/signer.ts +129 -0
  83. package/src/types.ts +12 -18
  84. package/src/utils.ts +99 -69
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrewheberle/serverless-ssh-ca",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "A serverless SSH CA built for Cloudflare Workers that can also run on Node.js",
5
5
  "type": "module",
6
6
  "types": "./dist/index.d.ts",
@@ -31,18 +31,19 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@andrewheberle/ts-slog": "^1.3.0",
34
+ "@noble/hashes": "^2.4.0",
35
+ "@scure/base": "^2.4.0",
34
36
  "chanfana": "^3.0.0",
35
37
  "hono": "^4.10.6",
36
38
  "itty-time": "^2.0.2",
37
39
  "jose": "^6.0.12",
38
- "sshpk": "^1.18.0",
40
+ "micro-packed": "^0.11.1",
39
41
  "workers-qb": "^1.14.0",
40
42
  "zod": "^4.0.0"
41
43
  },
42
44
  "devDependencies": {
43
45
  "@eslint/js": "^10.0.1",
44
46
  "@types/node": "^24.19.1",
45
- "@types/sshpk": "^1.17.4",
46
47
  "@vitest/coverage-v8": "^4.1.7",
47
48
  "eslint": "^10.4.1",
48
49
  "tsx": "^4.21.0",
@@ -0,0 +1,211 @@
1
+ import { group, Logger } from "@andrewheberle/ts-slog"
2
+ import {
3
+ ApiException,
4
+ ConflictException,
5
+ InternalServerErrorException,
6
+ NotFoundException,
7
+ UnprocessableEntityException,
8
+ } from "chanfana"
9
+ import type { AppContext } from "../router.js"
10
+ import type { CertificateSignerResponse } from "../types.js"
11
+ import { BadIssuerError } from "../certificate.js"
12
+ import type { SshCertificate } from "../ssh/certificate.js"
13
+ import { KeyParseError, type PublicKey } from "../ssh/keys.js"
14
+ import { getPrivateKey, getPublic, UnsupportedKeyError } from "../utils.js"
15
+ import { CertificateType } from "../db/types.js"
16
+ import { KRLBuilder } from "../krl.js"
17
+ import {
18
+ getKrlVersion,
19
+ getRevocationList,
20
+ recordCertificate,
21
+ RevocationStatus,
22
+ revocationStatus,
23
+ revokeCertificate,
24
+ } from "../db/index.js"
25
+
26
+ /**
27
+ * Maps an error from an operation that uses the CA key to an API error.
28
+ */
29
+ const caKeyError = (l: Logger, err: unknown): ApiException => {
30
+ switch (true) {
31
+ case (err instanceof UnsupportedKeyError):
32
+ l.error(err.message)
33
+ return new InternalServerErrorException(err.message)
34
+ case (err instanceof KeyParseError):
35
+ l.error("error parsing private key")
36
+ return new InternalServerErrorException("error parsing private key")
37
+ case (err instanceof ApiException):
38
+ // re-throw any exisiting chanfana error
39
+ return err
40
+ default:
41
+ // otherwise throw as InternalServerErrorException
42
+ l.error("unhandled error", "error", err)
43
+ return new InternalServerErrorException("internal server error")
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Returns the public key of the CA in OpenSSH format.
49
+ *
50
+ * @param c The request context
51
+ * @param l The logger for the request
52
+ * @returns The response containing the CA public key
53
+ */
54
+ export const caPublicKey = async (c: AppContext, l: Logger): Promise<Response> => {
55
+ try {
56
+ const pub = await getPublic(c.env)
57
+
58
+ return c.text(`${pub.toString()}\n`)
59
+ } catch (err) {
60
+ throw caKeyError(l, err)
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Returns the signed Key Revocation List for a type of certificate.
66
+ *
67
+ * @param c The request context
68
+ * @param l The logger for the request
69
+ * @param certificateType The type of certificate to return the KRL for
70
+ * @returns The response containing the KRL and its signature
71
+ */
72
+ export const revocationList = async (c: AppContext, l: Logger, certificateType: CertificateType): Promise<Response> => {
73
+ try {
74
+ // get revoked serials
75
+ const serials = (await getRevocationList(c.env, certificateType)).map(v => BigInt(v))
76
+
77
+ // grab private key from secret store
78
+ const key = await getPrivateKey(c.env)
79
+
80
+ // generate KRL
81
+ const krl = new KRLBuilder(key)
82
+ .addSerials(serials)
83
+
84
+ // the version only changes when the content of the KRL does
85
+ const version = await getKrlVersion(c.env, certificateType, await krl.digest())
86
+
87
+ const krlBytes = krl.generate(version)
88
+
89
+ // convert to base64
90
+ const bytes = new Uint8Array(krlBytes)
91
+ let binary = ""
92
+ for (const byte of bytes) {
93
+ binary += String.fromCharCode(byte)
94
+ }
95
+ const base64Krl = btoa(binary)
96
+
97
+ const signature = await krl.signature()
98
+
99
+ return c.json({
100
+ krl: base64Krl,
101
+ signature: signature,
102
+ })
103
+ } catch (err) {
104
+ throw caKeyError(l, err)
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Signs a certificate, records it so it can later be revoked and returns it
110
+ * as the response to a certificate request or renewal.
111
+ *
112
+ * These are the steps shared by every API version once a request has been
113
+ * validated and authorised.
114
+ *
115
+ * @param c The request context
116
+ * @param l The logger for the request
117
+ * @param sign Signs and returns the certificate
118
+ * @param certificateType The type of certificate being issued
119
+ * @returns The response containing the issued certificate
120
+ */
121
+ export const issueCertificate = async (c: AppContext, l: Logger, sign: () => Promise<SshCertificate>, certificateType: CertificateType): Promise<Response> => {
122
+ try {
123
+ const certificate = await sign()
124
+ const response: CertificateSignerResponse = {
125
+ certificate: btoa(certificate.toString())
126
+ }
127
+
128
+ try {
129
+ await recordCertificate(c.env, certificate, certificate.keyId, certificateType)
130
+ } catch (err) {
131
+ // a certificate that is not recorded cannot be revoked, so do not issue it
132
+ l.error("there was a problem adding issued certificate to database", "error", err)
133
+ throw new InternalServerErrorException("there was a problem recording the issued certificate")
134
+ }
135
+
136
+ l.info("completed issuing certificate",
137
+ ...group(
138
+ "certificate", "principals",
139
+ certificate.principals.join(","), "serial", certificate.serial,
140
+ ),
141
+ )
142
+
143
+ return c.json(response)
144
+ } catch (err) {
145
+ switch (true) {
146
+ case (err instanceof UnsupportedKeyError):
147
+ l.error(err.message)
148
+ throw new InternalServerErrorException(err.message)
149
+ case (err instanceof ApiException):
150
+ // re-throw any exisiting chanfana error
151
+ throw err
152
+ case (err instanceof BadIssuerError):
153
+ throw new UnprocessableEntityException(err.message)
154
+ default:
155
+ // otherwise throw as InternalServerErrorException
156
+ l.error("unhandled error", "error", err)
157
+ throw new InternalServerErrorException("internal server error")
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Revokes a certificate once the requester has proven possession of the
164
+ * private key for the public key it was issued for.
165
+ *
166
+ * @param c The request context
167
+ * @param l The logger for the request
168
+ * @param serial The serial of the certificate to revoke
169
+ * @param publicKey The public key the requester has proven possession of
170
+ * @param certificateType The type of certificate being revoked
171
+ * @returns The response containing the time of revocation
172
+ */
173
+ export const revoke = async (c: AppContext, l: Logger, serial: bigint, publicKey: PublicKey, certificateType: CertificateType): Promise<Response> => {
174
+ // ensure certificate can be revoked
175
+ const status = await revocationStatus(c.env, serial, publicKey, certificateType)
176
+ switch (status) {
177
+ case RevocationStatus.Revoked:
178
+ l.warn("certificate is already revoked")
179
+ throw new ConflictException("certificate is already revoked")
180
+ case RevocationStatus.NotFound:
181
+ l.warn("certificate not found")
182
+ throw new NotFoundException("certificate not found")
183
+ }
184
+
185
+ try {
186
+ const res = await revokeCertificate(c.env, serial)
187
+
188
+ if (res.length === 0) {
189
+ throw new InternalServerErrorException("no results were returned for revocation")
190
+ }
191
+
192
+ if (res.length > 1) {
193
+ l.warn("more than one revocation result was returned", "count", res.length)
194
+ }
195
+
196
+ l.info("completed revocation")
197
+ return c.json({
198
+ revoked_at: res[0]!.revoked_at
199
+ })
200
+ } catch (err) {
201
+ switch (true) {
202
+ case (err instanceof ApiException):
203
+ // re-throw any exisiting chanfana error
204
+ throw err
205
+ default:
206
+ // otherwise throw as InternalServerErrorException
207
+ l.error("unhandled error", "error", err)
208
+ throw new InternalServerErrorException("internal server error")
209
+ }
210
+ }
211
+ }
@@ -0,0 +1,137 @@
1
+ import z from "zod"
2
+ import type { SshCaBindings } from "../types.js"
3
+ import {
4
+ transformAuthorizationHeader,
5
+ transformPublicKey,
6
+ transformIdentityToken,
7
+ } from "../utils.js"
8
+ import {
9
+ contentJson,
10
+ InternalServerErrorException,
11
+ } from "chanfana"
12
+ import { CertificateType } from "../db/types.js"
13
+
14
+ /**
15
+ * Returns the name of a {@link CertificateType} as used in API paths and logs
16
+ * @param certificateType The type of certificate
17
+ * @returns "user" or "host"
18
+ */
19
+ export const certificateTypeName = (certificateType: CertificateType): "user" | "host" => {
20
+ switch (certificateType) {
21
+ case CertificateType.User:
22
+ return "user"
23
+ case CertificateType.Host:
24
+ return "host"
25
+ default: {
26
+ const unknownType: never = certificateType
27
+ throw new Error(`unknown certificate type: ${String(unknownType)}`)
28
+ }
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Returns the OpenAPI tag that groups the endpoints for a {@link CertificateType}
34
+ * @param certificateType The type of certificate
35
+ * @returns "User" or "Host"
36
+ */
37
+ export const certificateTypeTag = (certificateType: CertificateType): "User" | "Host" => {
38
+ switch (certificateType) {
39
+ case CertificateType.User:
40
+ return "User"
41
+ case CertificateType.Host:
42
+ return "Host"
43
+ default: {
44
+ const unknownType: never = certificateType
45
+ throw new Error(`unknown certificate type: ${String(unknownType)}`)
46
+ }
47
+ }
48
+ }
49
+
50
+ export const openapiStringByte = z.base64()
51
+ .transform((v) => {
52
+ const a = z.util.base64ToUint8Array(v)
53
+ return Buffer.from(
54
+ a.buffer,
55
+ a.byteOffset,
56
+ a.length
57
+ )
58
+ })
59
+ .openapi({ format: "byte" })
60
+
61
+ export const proofOfPossession = z.string()
62
+ .meta({
63
+ description: "Proof of possession comprising of ${timestamp}.${fingerprint}.${signature}",
64
+ example: "1765696780805.SHA256:79mq0wtpQMTvS4+Of8VzLN0qmYWNUyTXYmqKwhEgSLs.LS0tLS1CRUdJTiBTU0ggU0lHTkFUVVJFLS0tLS0KVTFOSVUwbEhBQUFBQVFBQUFHZ0FBQUFUWldOa2MyRXRjMmhoTWkxdWFYTjBjREkxTmdBQUFBaHVhWE4wY0RJMQpOZ0FBQUVFRUR6Ymk4OE10alBYSDFCb1JURU9HaS96aEc4QnhQNVZUUW5OQkFNMGZtMExtekErUUZkUlNVYVRFCkdNN2l0QjhINmZNZFAydlAxcElQcGUyOWVLQkJZd0FBQUFSbWFXeGxBQUFBQUFBQUFBWnphR0UxTVRJQUFBQmsKQUFBQUUyVmpaSE5oTFhOb1lUSXRibWx6ZEhBeU5UWUFBQUJKQUFBQUlHdmZVZGNDQkRXa3ZwL0ZHS3NkakgrRQpKZzZXN0EvWUJVZng2Z0srQ01raEFBQUFJUUQxOFd5cDNZejZ0eXNvdllXbEJHTXprRnlYbFRrSHM5blVVRXJqCkczNWVPQT09Ci0tLS0tRU5EIFNTSCBTSUdOQVRVUkUtLS0tLQo="
65
+ })
66
+
67
+ export const publicKey = openapiStringByte
68
+ .transform(transformPublicKey)
69
+ .meta({
70
+ description: "SSH public key to sign",
71
+ example: "ZWNkc2Etc2hhMi1uaXN0cDI1NiBBQUFBRTJWalpITmhMWE5vWVRJdGJtbHpkSEF5TlRZQUFBQUlibWx6ZEhBeU5UWUFBQUJCQkdNcFNKdHRmMEl0dE5DVmMyOFR6WEZSMUQweHZPM25wNjdWemVBUXNiZFpza3JEY1lqU2x3SjZIUERtVHBYYVUwbEVhWDlMNFloYy9jQ2YxTWU5RlRrPQo="
72
+ })
73
+
74
+ export const createIdentityTokenSchema = (env: SshCaBindings) => (
75
+ z.string()
76
+ .meta({
77
+ description: "Identity Token JWT from OIDC IdP",
78
+ example: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwiaWF0IjoxNzc1NzAzMDk1fQ.ri_neAWnhMNK3LzlsrBcQYymSM4yRjmNSZZSeZiXhrEqtEz6c3cXk0Esq765umGjpUsWcosL-OFrDJlyAjTDnhrd9oV08uc_CW0rQRsJIGEuRo3ryxkLdVu9mGoZWEUb9KwjGJrwxvr-0cPWx5jaDyKwJcqMvtV_bEITUD51sDB1Vm89QfYRO_pGJo2vrRzSvMjpUenRpwPay4lYIBxl41_4YpR9Rc6VrIZuYsjV2iqEZ4eBrygMA7zPR_hN7l7s95FddLOzj5NsK57VT4uLHwYohx2oqMzw3M-B9HsZIQin_9q61pZFQXepzJth0woXiZheU27llnfHX967PhNQyg"
79
+ })
80
+ .transform((val, ctx) => transformIdentityToken(env, val, ctx))
81
+ )
82
+
83
+ export const createAccessTokenSchema = (env: SshCaBindings) => (
84
+ z.string()
85
+ .meta({
86
+ description: "Access Token JWT from OIDC IdP",
87
+ example: "Bearer eyJ..."
88
+ })
89
+ .transform((val, ctx) => transformAuthorizationHeader(env, val, ctx))
90
+ )
91
+
92
+ export const krl = openapiStringByte
93
+ .meta({ description: "Key Revocation List" })
94
+
95
+ export const createHeaderSchema = (env: SshCaBindings) => (
96
+ z.object({
97
+ "Authorization": createAccessTokenSchema(env)
98
+ })
99
+ )
100
+
101
+ export const certificate = openapiStringByte
102
+ .meta({ description: "Issued Certificate" })
103
+
104
+ export const CaPublicKeyEndpointSchema = {
105
+ tags: ["CA"],
106
+ summary: "Get the CA public key",
107
+ description: "Returns the public key of the SSH Certificate Authority in OpenSSH format, for use in `TrustedUserCAKeys` on SSH servers or `@cert-authority` entries in `known_hosts` on SSH clients.",
108
+ responses: {
109
+ "200": {
110
+ content: {
111
+ "text/plain": {
112
+ schema: z.string()
113
+ }
114
+ },
115
+ description: "Returns SSH Certificate Authority Public Key",
116
+ },
117
+ ...InternalServerErrorException.schema(),
118
+ }
119
+ }
120
+
121
+ export const createRevocationListEndpointSchema = (certificateType: CertificateType) => ({
122
+ tags: [certificateTypeTag(certificateType)],
123
+ summary: `Get the Key Revocation List for ${certificateTypeName(certificateType)} certificates`,
124
+ description: `Returns the OpenSSH Key Revocation List (KRL) of revoked ${certificateTypeName(certificateType)} certificates issued by this CA, along with an SSHSIG signature made by the CA key so the KRL can be verified before use.`,
125
+ responses: {
126
+ "200": {
127
+ description: `Returns an Open SSH Key Revocation List of revoked ${certificateTypeName(certificateType)} certificates as BASE64 and an SSHSIG signature for verification`,
128
+ ...contentJson(z.object({
129
+ krl: krl,
130
+ signature: z.string()
131
+ })
132
+ .openapi("Key Revocation List Response")
133
+ )
134
+ },
135
+ ...InternalServerErrorException.schema(),
136
+ }
137
+ })