@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.
Files changed (122) hide show
  1. package/README.md +174 -1
  2. package/dist/api/operations.d.ts +50 -0
  3. package/dist/api/operations.d.ts.map +1 -0
  4. package/dist/api/operations.js +264 -0
  5. package/dist/api/operations.js.map +1 -0
  6. package/dist/api/schema.d.ts +72 -0
  7. package/dist/api/schema.d.ts.map +1 -0
  8. package/dist/api/schema.js +114 -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 +18 -104
  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 +186 -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 +68 -9
  30. package/dist/db/index.d.ts.map +1 -1
  31. package/dist/db/index.js +165 -49
  32. package/dist/db/index.js.map +1 -1
  33. package/dist/db/migrations/0003_krl_revision.d.ts +3 -0
  34. package/dist/db/migrations/0003_krl_revision.d.ts.map +1 -0
  35. package/dist/db/migrations/0003_krl_revision.js +14 -0
  36. package/dist/db/migrations/0003_krl_revision.js.map +1 -0
  37. package/dist/db/migrations/index.d.ts.map +1 -1
  38. package/dist/db/migrations/index.js +2 -0
  39. package/dist/db/migrations/index.js.map +1 -1
  40. package/dist/db/types.d.ts +2 -0
  41. package/dist/db/types.d.ts.map +1 -1
  42. package/dist/index.d.ts +5 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +4 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/krl.d.ts +3 -2
  47. package/dist/krl.d.ts.map +1 -1
  48. package/dist/krl.js +12 -165
  49. package/dist/krl.js.map +1 -1
  50. package/dist/node/cache.d.ts +21 -0
  51. package/dist/node/cache.d.ts.map +1 -0
  52. package/dist/node/cache.js +49 -0
  53. package/dist/node/cache.js.map +1 -0
  54. package/dist/node/env.d.ts +6 -3
  55. package/dist/node/env.d.ts.map +1 -1
  56. package/dist/node/env.js +4 -3
  57. package/dist/node/env.js.map +1 -1
  58. package/dist/node/index.d.ts +1 -0
  59. package/dist/node/index.d.ts.map +1 -1
  60. package/dist/node/index.js +1 -0
  61. package/dist/node/index.js.map +1 -1
  62. package/dist/proof.d.ts +36 -7
  63. package/dist/proof.d.ts.map +1 -1
  64. package/dist/proof.js +53 -21
  65. package/dist/proof.js.map +1 -1
  66. package/dist/router.d.ts +25 -1
  67. package/dist/router.d.ts.map +1 -1
  68. package/dist/router.js +41 -2
  69. package/dist/router.js.map +1 -1
  70. package/dist/ssh/certificate.d.ts +93 -0
  71. package/dist/ssh/certificate.d.ts.map +1 -0
  72. package/dist/ssh/certificate.js +178 -0
  73. package/dist/ssh/certificate.js.map +1 -0
  74. package/dist/ssh/encoding.d.ts +41 -0
  75. package/dist/ssh/encoding.d.ts.map +1 -0
  76. package/dist/ssh/encoding.js +70 -0
  77. package/dist/ssh/encoding.js.map +1 -0
  78. package/dist/ssh/keys.d.ts +92 -0
  79. package/dist/ssh/keys.d.ts.map +1 -0
  80. package/dist/ssh/keys.js +277 -0
  81. package/dist/ssh/keys.js.map +1 -0
  82. package/dist/ssh/signer.d.ts +35 -0
  83. package/dist/ssh/signer.d.ts.map +1 -0
  84. package/dist/ssh/signer.js +99 -0
  85. package/dist/ssh/signer.js.map +1 -0
  86. package/dist/types.d.ts +64 -19
  87. package/dist/types.d.ts.map +1 -1
  88. package/dist/utils.d.ts +107 -21
  89. package/dist/utils.d.ts.map +1 -1
  90. package/dist/utils.js +251 -65
  91. package/dist/utils.js.map +1 -1
  92. package/dist/verify.d.ts +17 -5
  93. package/dist/verify.d.ts.map +1 -1
  94. package/dist/verify.js +47 -9
  95. package/dist/verify.js.map +1 -1
  96. package/openapi.json +1942 -1
  97. package/package.json +4 -3
  98. package/src/api/operations.ts +314 -0
  99. package/src/api/schema.ts +146 -0
  100. package/src/api/v3/index.ts +21 -248
  101. package/src/api/v3/schema.ts +36 -128
  102. package/src/api/v4/index.ts +266 -0
  103. package/src/api/v4/schema.ts +241 -0
  104. package/src/certificate.ts +62 -123
  105. package/src/db/index.ts +196 -56
  106. package/src/db/migrations/0003_krl_revision.ts +15 -0
  107. package/src/db/migrations/index.ts +2 -0
  108. package/src/db/types.ts +2 -0
  109. package/src/index.ts +6 -4
  110. package/src/krl.ts +13 -197
  111. package/src/node/cache.ts +62 -0
  112. package/src/node/env.ts +6 -5
  113. package/src/node/index.ts +1 -0
  114. package/src/proof.ts +65 -31
  115. package/src/router.ts +67 -3
  116. package/src/ssh/certificate.ts +236 -0
  117. package/src/ssh/encoding.ts +81 -0
  118. package/src/ssh/keys.ts +327 -0
  119. package/src/ssh/signer.ts +129 -0
  120. package/src/types.ts +60 -20
  121. package/src/utils.ts +285 -81
  122. package/src/verify.ts +54 -14
package/src/router.ts CHANGED
@@ -1,14 +1,77 @@
1
1
  import { fromHono } from "chanfana"
2
2
  import { Hono, type Context } from "hono"
3
3
  import { createApi as apiv3 } from "./api/v3/index.js"
4
+ import { BasePath as v4BasePath, createApi as apiv4 } from "./api/v4/index.js"
4
5
  import { HTTPException } from "hono/http-exception"
5
6
  import { logger } from "./logger.js"
7
+ import { TooManyRequestsException } from "chanfana"
6
8
  import type { SshCaBindings } from "./types.js"
7
9
 
8
10
  export type AppContext = Context<{ Bindings: SshCaBindings }>
9
11
 
10
- export const createApp = (env: SshCaBindings) => {
11
- const hono = new Hono()
12
+ /**
13
+ * Returns the key a request is rate limited by, or undefined if the request
14
+ * should not be rate limited.
15
+ */
16
+ export type RateLimitKey = (req: Request) => string | undefined
17
+
18
+ /**
19
+ * Options for the CA that are not part of its bindings.
20
+ */
21
+ export interface SshCaOptions {
22
+ /**
23
+ * Returns the key requests are rate limited by when `RATE_LIMITER` is
24
+ * set. Defaults to the client IP address in the `CF-Connecting-IP` header,
25
+ * which is set by Cloudflare, so other runtimes should provide one that
26
+ * suits their limiter, for example using a header set by a reverse proxy.
27
+ */
28
+ rateLimitKey?: RateLimitKey
29
+ }
30
+
31
+ /**
32
+ * Returns the client IP address set by Cloudflare in the `CF-Connecting-IP`
33
+ * header, which is the default key requests are rate limited by.
34
+ */
35
+ export const cloudflareClientIp: RateLimitKey = (req) => req.headers.get("CF-Connecting-IP") ?? undefined
36
+
37
+ // requests that only read from the CA, which are not rate limited
38
+ const ReadMethods = new Set(["GET", "HEAD", "OPTIONS"])
39
+
40
+ export const createApp = (env: SshCaBindings, options: SshCaOptions = {}) => {
41
+ const hono = new Hono<{ Bindings: SshCaBindings }>()
42
+ const rateLimitKey = options.rateLimitKey ?? cloudflareClientIp
43
+
44
+ // limit requests to issue, renew and revoke certificates
45
+ hono.use("/api/*", async (c, next) => {
46
+ const limiter = c.env.RATE_LIMITER
47
+ if (limiter === undefined || ReadMethods.has(c.req.method)) {
48
+ return await next()
49
+ }
50
+
51
+ const l = logger(env).with("in", "rateLimit")
52
+ const key = rateLimitKey(c.req.raw)
53
+ if (key === undefined) {
54
+ l.debug("no rate limit key for request")
55
+ return await next()
56
+ }
57
+
58
+ let success: boolean
59
+ try {
60
+ success = (await limiter.limit({ key })).success
61
+ } catch (err) {
62
+ // a failing limiter must not stop certificates being issued or revoked
63
+ l.error("rate limiter failed", "error", err)
64
+ return await next()
65
+ }
66
+
67
+ if (!success) {
68
+ l.warn("request was rate limited", "key", key, "method", c.req.method, "path", c.req.path)
69
+ const err = new TooManyRequestsException()
70
+ return c.json({ success: false, errors: err.buildResponse(), result: {} }, 429)
71
+ }
72
+
73
+ return await next()
74
+ })
12
75
 
13
76
  // add error handling
14
77
  hono.onError((err, c) => {
@@ -24,7 +87,7 @@ export const createApp = (env: SshCaBindings) => {
24
87
  schema: {
25
88
  info: {
26
89
  title: "Serverless SSH CA API",
27
- version: "3.0.0"
90
+ version: "4.0.0"
28
91
  },
29
92
  }
30
93
  })
@@ -36,6 +99,7 @@ export const createApp = (env: SshCaBindings) => {
36
99
  })
37
100
 
38
101
  app.route("/api/v3", apiv3(env))
102
+ app.route(v4BasePath, apiv4(env))
39
103
 
40
104
  return app
41
105
  }
@@ -0,0 +1,236 @@
1
+ import * as P from "micro-packed"
2
+ import {
3
+ decodePublicKey,
4
+ PublicKeyFieldCounts,
5
+ SSHBytes,
6
+ SSHOptions,
7
+ SSHText,
8
+ SSHTextList,
9
+ } from "./encoding.js"
10
+ import { base64 } from "@scure/base"
11
+ import { KeyParseError, PublicKey } from "./keys.js"
12
+ import { verify, type Signer } from "./signer.js"
13
+
14
+ /**
15
+ * Encoding, decoding and signing of OpenSSH certificates as described in
16
+ * PROTOCOL.certkeys from OpenSSH
17
+ */
18
+
19
+ /** Whether a certificate is for a user or a host */
20
+ export type CertificateKind = "user" | "host"
21
+
22
+ const CertificateKinds: Readonly<Record<CertificateKind, number>> = {
23
+ user: 1,
24
+ host: 2,
25
+ }
26
+
27
+ /** The fields of a certificate that are set by the CA */
28
+ export type CertificateFields = {
29
+ /** Whether the certificate is for a user or a host */
30
+ kind: CertificateKind
31
+ /** The public key being certified */
32
+ publicKey: PublicKey
33
+ /** The serial number of the certificate */
34
+ serial: bigint
35
+ /** The key ID of the certificate, which is logged by sshd when it is used */
36
+ keyId: string
37
+ /** The users or hosts the certificate is valid for */
38
+ principals: string[]
39
+ /** The time from which the certificate is valid, to the second */
40
+ validAfter: Date
41
+ /** The time until which the certificate is valid, to the second */
42
+ validBefore: Date
43
+ /** The names of the extensions to include, which have no data */
44
+ extensions: string[]
45
+ }
46
+
47
+ // the fields of a certificate that follow its type, which are given the
48
+ // number of fields in the public key of the certificate
49
+ const fields = (keyFields: number) => ({
50
+ nonce: SSHBytes,
51
+ key: P.array(keyFields, SSHBytes),
52
+ serial: P.U64BE,
53
+ kind: P.U32BE,
54
+ keyId: SSHText,
55
+ principals: SSHTextList,
56
+ validAfter: P.U64BE,
57
+ validBefore: P.U64BE,
58
+ criticalOptions: SSHOptions,
59
+ extensions: SSHOptions,
60
+ reserved: SSHBytes,
61
+ signatureKey: SSHBytes,
62
+ })
63
+
64
+ const certificateType = (keyType: string): string => `${keyType}-cert-v01@openssh.com`
65
+
66
+ // a certificate without and with its signature, tagged by the certificate type
67
+ const ToBeSigned = P.tag(SSHText, Object.fromEntries(
68
+ Object.entries(PublicKeyFieldCounts).map(([keyType, count]) => [certificateType(keyType), P.struct(fields(count))]),
69
+ ))
70
+ const Certificate = P.tag(SSHText, Object.fromEntries(
71
+ Object.entries(PublicKeyFieldCounts).map(([keyType, count]) => [certificateType(keyType), P.struct({ ...fields(count), signature: SSHBytes })]),
72
+ ))
73
+
74
+ const toSeconds = (date: Date): bigint => BigInt(Math.floor(date.getTime() / 1000))
75
+
76
+ /**
77
+ * Thrown when a certificate cannot be parsed
78
+ */
79
+ export class CertificateParseError extends Error {
80
+ constructor(message: string, cause?: unknown) {
81
+ super(message)
82
+ this.name = "CertificateParseError"
83
+ this.cause = cause
84
+
85
+ Object.setPrototypeOf(this, CertificateParseError.prototype)
86
+ }
87
+ }
88
+
89
+ /**
90
+ * A signed OpenSSH certificate
91
+ */
92
+ export class SshCertificate {
93
+ /** The wire encoding of the certificate */
94
+ readonly bytes: Uint8Array
95
+ /** The certificate type, for example "ssh-ed25519-cert-v01@openssh.com" */
96
+ readonly type: string
97
+ readonly kind: CertificateKind
98
+ /** The public key being certified */
99
+ readonly publicKey: PublicKey
100
+ readonly serial: bigint
101
+ readonly keyId: string
102
+ readonly principals: string[]
103
+ readonly validAfter: Date
104
+ readonly validBefore: Date
105
+ /** The names of the extensions on the certificate */
106
+ readonly extensions: string[]
107
+ /** The wire encoding of the public key of the CA that signed the certificate */
108
+ readonly signatureKey: Uint8Array
109
+ /** The signature of the CA in the SSH wire format */
110
+ readonly signature: Uint8Array
111
+
112
+ /**
113
+ * Decodes a certificate from its wire encoding. This does not verify the
114
+ * signature on the certificate.
115
+ *
116
+ * @param bytes The wire encoding of the certificate
117
+ * @throws {@link CertificateParseError} when the certificate cannot be decoded or its type is not supported
118
+ */
119
+ constructor(bytes: Uint8Array) {
120
+ let decoded: P.UnwrapCoder<typeof Certificate>
121
+ try {
122
+ decoded = Certificate.decode(bytes)
123
+ } catch (err) {
124
+ throw new CertificateParseError("certificate could not be decoded", err)
125
+ }
126
+ const { TAG, data } = decoded
127
+ const kind = (Object.keys(CertificateKinds) as CertificateKind[]).find((k) => CertificateKinds[k] === data.kind)
128
+ if (kind === undefined) {
129
+ throw new CertificateParseError(`unknown certificate kind: ${data.kind}`)
130
+ }
131
+
132
+ const publicKeyType = TAG.replace(/-cert-v01@openssh\.com$/, "")
133
+ try {
134
+ this.publicKey = new PublicKey(P.struct({ type: SSHText, key: P.array(data.key.length, SSHBytes) }).encode({ type: publicKeyType, key: data.key }))
135
+ } catch (err) {
136
+ throw new CertificateParseError(err instanceof KeyParseError ? err.message : "certificate public key could not be decoded", err)
137
+ }
138
+
139
+ this.bytes = bytes
140
+ this.type = TAG
141
+ this.kind = kind
142
+ this.serial = data.serial
143
+ this.keyId = data.keyId
144
+ this.principals = data.principals
145
+ this.validAfter = new Date(Number(data.validAfter) * 1000)
146
+ this.validBefore = new Date(Number(data.validBefore) * 1000)
147
+ this.extensions = data.extensions.map((e) => e.name)
148
+ this.signatureKey = data.signatureKey
149
+ this.signature = data.signature
150
+ }
151
+
152
+ /**
153
+ * Parses a certificate in OpenSSH format, `${type} ${base64}[ ${comment}]`,
154
+ * as found in `-cert.pub` files. This does not verify the signature on the
155
+ * certificate.
156
+ *
157
+ * @param text The certificate
158
+ * @throws {@link CertificateParseError} when the certificate cannot be parsed or its type is not supported
159
+ */
160
+ static parse(text: string): SshCertificate {
161
+ const [type, data] = text.trim().split(/\s+/)
162
+ if (type === undefined || type === "" || data === undefined) {
163
+ throw new CertificateParseError("certificate was not in OpenSSH format")
164
+ }
165
+
166
+ let bytes: Uint8Array
167
+ try {
168
+ bytes = base64.decode(data)
169
+ } catch (err) {
170
+ throw new CertificateParseError("certificate was not valid base64", err)
171
+ }
172
+
173
+ const certificate = new SshCertificate(bytes)
174
+ if (certificate.type !== type) {
175
+ throw new CertificateParseError(`certificate type ${type} did not match encoded type ${certificate.type}`)
176
+ }
177
+
178
+ return certificate
179
+ }
180
+
181
+ /**
182
+ * Returns whether the certificate was signed by a CA key
183
+ *
184
+ * @param ca The public key of the CA
185
+ */
186
+ async isSignedBy(ca: PublicKey): Promise<boolean> {
187
+ if (!ca.equals(new PublicKey(this.signatureKey))) {
188
+ return false
189
+ }
190
+
191
+ // the signature is the last field, so everything before it is signed
192
+ const signed = this.bytes.subarray(0, this.bytes.length - 4 - this.signature.length)
193
+
194
+ return await verify(ca, this.signature, new Uint8Array(signed))
195
+ }
196
+
197
+ /**
198
+ * Returns the certificate in OpenSSH format, as used for `-cert.pub` files
199
+ */
200
+ toString(): string {
201
+ return `${this.type} ${base64.encode(this.bytes)}`
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Creates and signs an OpenSSH certificate
207
+ *
208
+ * @param fields The fields of the certificate
209
+ * @param signer The CA key to sign the certificate with
210
+ * @returns The signed certificate
211
+ * @throws Error when the type of the public key being certified is not supported
212
+ */
213
+ export const signCertificate = async (fields: CertificateFields, signer: Signer): Promise<SshCertificate> => {
214
+ const { type, fields: key } = decodePublicKey(fields.publicKey.blob)
215
+
216
+ const value = {
217
+ nonce: crypto.getRandomValues(new Uint8Array(32)),
218
+ key,
219
+ serial: fields.serial,
220
+ kind: CertificateKinds[fields.kind],
221
+ keyId: fields.keyId,
222
+ principals: fields.principals,
223
+ validAfter: toSeconds(fields.validAfter),
224
+ validBefore: toSeconds(fields.validBefore),
225
+ criticalOptions: [],
226
+ // options must be in lexical order and may each appear only once
227
+ extensions: [...new Set(fields.extensions)].sort().map((name) => ({ name, data: new Uint8Array(0) })),
228
+ reserved: new Uint8Array(0),
229
+ signatureKey: signer.publicKey,
230
+ }
231
+
232
+ const tbs = ToBeSigned.encode({ TAG: certificateType(type), data: value })
233
+ const signature = await signer.sign(new Uint8Array(tbs))
234
+
235
+ return new SshCertificate(Certificate.encode({ TAG: certificateType(type), data: { ...value, signature } }))
236
+ }
@@ -0,0 +1,81 @@
1
+ import * as P from "micro-packed"
2
+
3
+ /**
4
+ * Coders for the SSH wire format (RFC 4251 section 5)
5
+ */
6
+
7
+ /** A string of bytes, prefixed by its length as a uint32 */
8
+ export const SSHBytes = P.bytes(P.U32BE)
9
+
10
+ /** A UTF-8 string, prefixed by its length as a uint32 */
11
+ export const SSHText = P.string(P.U32BE)
12
+
13
+ /** A list of strings packed into a string, as used for certificate principals */
14
+ export const SSHTextList = P.prefix(P.U32BE, P.array(null, SSHText))
15
+
16
+ /**
17
+ * A list of named options packed into a string, as used for certificate
18
+ * critical options and extensions
19
+ */
20
+ export const SSHOptions = P.prefix(P.U32BE, P.array(null, P.struct({
21
+ name: SSHText,
22
+ data: SSHBytes,
23
+ })))
24
+
25
+ /**
26
+ * An mpint holding an unsigned integer, decoded to and encoded from its
27
+ * big-endian bytes without leading zeros.
28
+ *
29
+ * An mpint has no leading zero bytes, other than one to keep the value
30
+ * positive when the high bit of the first byte is set.
31
+ */
32
+ export const SSHUnsignedMpint = P.apply(SSHBytes, {
33
+ encode: (mpint: Uint8Array): Uint8Array => {
34
+ let start = 0
35
+ while (start < mpint.length && mpint[start] === 0) {
36
+ start++
37
+ }
38
+
39
+ return mpint.subarray(start)
40
+ },
41
+ decode: (value: Uint8Array): Uint8Array => {
42
+ let start = 0
43
+ while (start < value.length && value[start] === 0) {
44
+ start++
45
+ }
46
+ const trimmed = value.subarray(start)
47
+
48
+ return trimmed.length > 0 && trimmed[0]! & 0x80
49
+ ? P.utils.concatBytes(new Uint8Array([0]), trimmed)
50
+ : trimmed
51
+ },
52
+ })
53
+
54
+ /** The number of fields that follow the type in the wire encoding of a public key of each type */
55
+ export const PublicKeyFieldCounts: Readonly<Record<string, number>> = {
56
+ "ssh-ed25519": 1, // public key
57
+ "ecdsa-sha2-nistp256": 2, // curve, public key
58
+ "ecdsa-sha2-nistp384": 2,
59
+ "ecdsa-sha2-nistp521": 2,
60
+ "ssh-rsa": 2, // e, n
61
+ }
62
+
63
+ /**
64
+ * Splits the wire encoding of a public key into its type and the encoded
65
+ * fields that follow it
66
+ *
67
+ * @param blob The wire encoding of the public key
68
+ * @returns The key type and its fields
69
+ * @throws Error when the key type is not supported or the encoding is invalid
70
+ */
71
+ export const decodePublicKey = (blob: Uint8Array): { type: string, fields: Uint8Array[] } => {
72
+ const type = SSHText.decode(blob, { allowUnreadBytes: true })
73
+ const count = PublicKeyFieldCounts[type]
74
+ if (count === undefined) {
75
+ throw new Error(`unsupported public key type: ${type}`)
76
+ }
77
+
78
+ const { fields } = P.struct({ type: SSHText, fields: P.array(count, SSHBytes) }).decode(blob)
79
+
80
+ return { type, fields }
81
+ }