@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
@@ -0,0 +1,327 @@
1
+ import { sha256 } from "@noble/hashes/sha2.js"
2
+ import { base64, base64nopad, base64urlnopad } from "@scure/base"
3
+ import * as P from "micro-packed"
4
+ import { toFixedWidth } from "../sshsig/sig_parser.js"
5
+ import { decodePublicKey, SSHBytes, SSHText } from "./encoding.js"
6
+
7
+ /**
8
+ * Thrown when a key cannot be parsed
9
+ */
10
+ export class KeyParseError extends Error {
11
+ constructor(message: string, cause?: unknown) {
12
+ super(message)
13
+ this.name = "KeyParseError"
14
+ this.cause = cause
15
+
16
+ Object.setPrototypeOf(this, KeyParseError.prototype)
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Thrown when a private key is of a type that cannot be used to sign
22
+ */
23
+ export class UnsupportedKeyError extends Error {
24
+ constructor(message: string) {
25
+ super(message)
26
+ this.name = "UnsupportedKeyError"
27
+
28
+ Object.setPrototypeOf(this, UnsupportedKeyError.prototype)
29
+ }
30
+ }
31
+
32
+ const equalBytes = (a: Uint8Array, b: Uint8Array): boolean => {
33
+ return a.length === b.length && a.every((v, i) => v === b[i])
34
+ }
35
+
36
+ /**
37
+ * An SSH public key
38
+ */
39
+ export class PublicKey {
40
+ /** The key type, for example "ssh-ed25519" */
41
+ readonly type: string
42
+ /** The wire encoding of the key (RFC 4253 section 6.6) */
43
+ readonly blob: Uint8Array
44
+ /** The comment of the key, if any */
45
+ readonly comment: string | undefined
46
+
47
+ /**
48
+ * @param blob The wire encoding of the key
49
+ * @param comment The comment of the key
50
+ * @throws {@link KeyParseError} when the key cannot be decoded or its type is not supported
51
+ */
52
+ constructor(blob: Uint8Array, comment?: string) {
53
+ try {
54
+ this.type = decodePublicKey(blob).type
55
+ } catch (err) {
56
+ throw new KeyParseError(err instanceof Error ? err.message : "public key could not be decoded", err)
57
+ }
58
+ this.blob = blob
59
+ this.comment = comment === "" ? undefined : comment
60
+ }
61
+
62
+ /**
63
+ * Parses a public key in OpenSSH format, `${type} ${base64}[ ${comment}]`
64
+ *
65
+ * @param text The public key
66
+ * @throws {@link KeyParseError} when the key cannot be parsed or its type is not supported
67
+ */
68
+ static parse(text: string): PublicKey {
69
+ const [type, data, ...comment] = text.trim().split(/\s+/)
70
+ if (type === undefined || type === "" || data === undefined) {
71
+ throw new KeyParseError("public key was not in OpenSSH format")
72
+ }
73
+
74
+ let blob: Uint8Array
75
+ try {
76
+ blob = base64.decode(data)
77
+ } catch (err) {
78
+ throw new KeyParseError("public key was not valid base64", err)
79
+ }
80
+
81
+ const key = new PublicKey(blob, comment.join(" "))
82
+ if (key.type !== type) {
83
+ throw new KeyParseError(`public key type ${type} did not match encoded type ${key.type}`)
84
+ }
85
+
86
+ return key
87
+ }
88
+
89
+ /**
90
+ * Returns the SHA256 fingerprint of the key, as shown by `ssh-keygen -l`,
91
+ * for example "SHA256:79mq0wtpQMTvS4+Of8VzLN0qmYWNUyTXYmqKwhEgSLs"
92
+ */
93
+ fingerprint(): string {
94
+ return `SHA256:${base64nopad.encode(sha256(this.blob))}`
95
+ }
96
+
97
+ /**
98
+ * Returns whether this is the same key as another, ignoring comments
99
+ */
100
+ equals(other: PublicKey): boolean {
101
+ return equalBytes(this.blob, other.blob)
102
+ }
103
+
104
+ /**
105
+ * Returns a copy of the key with a different comment
106
+ */
107
+ withComment(comment: string | undefined): PublicKey {
108
+ return new PublicKey(this.blob, comment)
109
+ }
110
+
111
+ /**
112
+ * Returns the key in OpenSSH format, `${type} ${base64}[ ${comment}]`
113
+ */
114
+ toString(): string {
115
+ const key = `${this.type} ${base64.encode(this.blob)}`
116
+
117
+ return this.comment === undefined ? key : `${key} ${this.comment}`
118
+ }
119
+ }
120
+
121
+ /**
122
+ * The smallest RSA key, in bits, that the CA will issue a certificate for.
123
+ * This matches the minimum accepted by ssh-ca-client.
124
+ */
125
+ export const MinimumRsaKeyBits = 2048
126
+
127
+ /**
128
+ * Returns the size in bits of the modulus of an RSA public key, or undefined
129
+ * if the key is not an RSA key.
130
+ *
131
+ * @param key The public key
132
+ */
133
+ export const rsaKeyBits = (key: PublicKey): number | undefined => {
134
+ if (key.type !== "ssh-rsa") {
135
+ return undefined
136
+ }
137
+
138
+ // the fields of an RSA key are the exponent and the modulus, an mpint
139
+ // that may have a leading zero byte to keep it positive
140
+ const modulus = decodePublicKey(key.blob).fields[1]
141
+ if (modulus === undefined) {
142
+ return undefined
143
+ }
144
+
145
+ const start = modulus.findIndex((byte) => byte !== 0)
146
+ const first = modulus[start]
147
+ if (start === -1 || first === undefined) {
148
+ return 0
149
+ }
150
+
151
+ return (modulus.length - start - 1) * 8 + first.toString(2).length
152
+ }
153
+
154
+ /** The ECDSA curves that can be used to sign */
155
+ export type EcdsaCurve = "nistp256" | "nistp384" | "nistp521"
156
+
157
+ const EcdsaCurves: Readonly<Record<EcdsaCurve, { namedCurve: string, size: number }>> = {
158
+ nistp256: { namedCurve: "P-256", size: 32 },
159
+ nistp384: { namedCurve: "P-384", size: 48 },
160
+ nistp521: { namedCurve: "P-521", size: 66 },
161
+ }
162
+
163
+ /**
164
+ * A private key that can be used to sign, with its key material as a JWK for
165
+ * WebCrypto
166
+ */
167
+ export type PrivateKey = {
168
+ readonly type: "ed25519"
169
+ readonly publicKey: PublicKey
170
+ readonly jwk: JsonWebKey
171
+ } | {
172
+ readonly type: "ecdsa"
173
+ readonly curve: EcdsaCurve
174
+ readonly publicKey: PublicKey
175
+ readonly jwk: JsonWebKey
176
+ }
177
+
178
+ // "openssh-key-v1\0"
179
+ const AUTH_MAGIC = new TextEncoder().encode("openssh-key-v1\0")
180
+
181
+ /** The container of an OpenSSH private key (PROTOCOL.key) */
182
+ const OpenSshPrivateKey = P.struct({
183
+ magic: P.magicBytes(AUTH_MAGIC),
184
+ cipherName: SSHText,
185
+ kdfName: SSHText,
186
+ kdfOptions: SSHBytes,
187
+ publicKeys: P.array(P.U32BE, SSHBytes),
188
+ privateKeys: SSHBytes,
189
+ })
190
+
191
+ /** The start of the (decrypted) private section of an OpenSSH private key */
192
+ const PrivateSectionStart = P.struct({
193
+ check1: P.U32BE,
194
+ check2: P.U32BE,
195
+ type: SSHText,
196
+ })
197
+
198
+ const Ed25519PrivateKey = P.struct({
199
+ check1: P.U32BE,
200
+ check2: P.U32BE,
201
+ type: SSHText,
202
+ publicKey: SSHBytes,
203
+ // the seed followed by the public key
204
+ privateKey: SSHBytes,
205
+ comment: SSHText,
206
+ })
207
+
208
+ const EcdsaPrivateKey = P.struct({
209
+ check1: P.U32BE,
210
+ check2: P.U32BE,
211
+ type: SSHText,
212
+ curve: SSHText,
213
+ publicKey: SSHBytes,
214
+ // an mpint
215
+ privateKey: SSHBytes,
216
+ comment: SSHText,
217
+ })
218
+
219
+ /**
220
+ * Returns a short name for the type of a key, for example "rsa" for "ssh-rsa"
221
+ */
222
+ const keyTypeName = (type: string): string => {
223
+ return type.replace(/^ssh-/, "").replace(/^ecdsa-sha2-.*$/, "ecdsa")
224
+ }
225
+
226
+ /**
227
+ * Parses an unencrypted Ed25519 or ECDSA private key in OpenSSH format, as
228
+ * created by `ssh-keygen`
229
+ *
230
+ * @param text The private key, starting with `-----BEGIN OPENSSH PRIVATE KEY-----`
231
+ * @returns The private key
232
+ * @throws {@link KeyParseError} when the key cannot be parsed or is encrypted
233
+ * @throws {@link UnsupportedKeyError} when the key is not an Ed25519 or ECDSA key
234
+ */
235
+ export const parsePrivateKey = (text: string): PrivateKey => {
236
+ const match = /-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]*?)-----END OPENSSH PRIVATE KEY-----/.exec(text)
237
+ if (match === null) {
238
+ throw new KeyParseError("private key was not in OpenSSH format, as created by ssh-keygen (starting with -----BEGIN OPENSSH PRIVATE KEY-----)")
239
+ }
240
+
241
+ let container: P.UnwrapCoder<typeof OpenSshPrivateKey>
242
+ try {
243
+ container = OpenSshPrivateKey.decode(base64.decode(match[1]!.replace(/\s+/g, "")))
244
+ } catch (err) {
245
+ throw new KeyParseError("private key could not be decoded", err)
246
+ }
247
+
248
+ if (container.cipherName !== "none" || container.kdfName !== "none") {
249
+ throw new KeyParseError("encrypted private keys are not supported")
250
+ }
251
+ if (container.publicKeys.length !== 1) {
252
+ throw new KeyParseError(`private key must contain one key, not ${container.publicKeys.length}`)
253
+ }
254
+
255
+ // the private section is padded to a block size, so may have trailing bytes
256
+ const options = { allowUnreadBytes: true }
257
+ let start: P.UnwrapCoder<typeof PrivateSectionStart>
258
+ try {
259
+ start = PrivateSectionStart.decode(container.privateKeys, options)
260
+ } catch (err) {
261
+ throw new KeyParseError("private key could not be decoded", err)
262
+ }
263
+ if (start.check1 !== start.check2) {
264
+ throw new KeyParseError("private key check values did not match")
265
+ }
266
+
267
+ try {
268
+ if (start.type === "ssh-ed25519") {
269
+ const key = Ed25519PrivateKey.decode(container.privateKeys, options)
270
+ if (key.publicKey.length !== 32 || key.privateKey.length !== 64) {
271
+ throw new KeyParseError("Ed25519 private key was an unexpected length")
272
+ }
273
+ const publicKey = new PublicKey(container.publicKeys[0]!, key.comment)
274
+ if (!equalBytes(P.struct({ type: SSHText, key: SSHBytes }).encode({ type: start.type, key: key.publicKey }), publicKey.blob)) {
275
+ throw new KeyParseError("private key did not match its public key")
276
+ }
277
+
278
+ return {
279
+ type: "ed25519",
280
+ publicKey,
281
+ jwk: {
282
+ kty: "OKP",
283
+ crv: "Ed25519",
284
+ d: base64urlnopad.encode(key.privateKey.subarray(0, 32)),
285
+ x: base64urlnopad.encode(key.publicKey),
286
+ },
287
+ }
288
+ }
289
+
290
+ if (start.type.startsWith("ecdsa-sha2-")) {
291
+ const key = EcdsaPrivateKey.decode(container.privateKeys, options)
292
+ const curve = EcdsaCurves[key.curve as EcdsaCurve]
293
+ if (curve === undefined || start.type !== `ecdsa-sha2-${key.curve}`) {
294
+ throw new UnsupportedKeyError(`ECDSA curve ${key.curve} is not supported`)
295
+ }
296
+ if (key.publicKey.length !== 1 + curve.size * 2 || key.publicKey[0] !== 0x04) {
297
+ throw new KeyParseError(`unexpected ECDSA public key encoding for ${key.curve}`)
298
+ }
299
+ const publicKey = new PublicKey(container.publicKeys[0]!, key.comment)
300
+ if (!equalBytes(P.struct({ type: SSHText, curve: SSHText, key: SSHBytes }).encode({ type: start.type, curve: key.curve, key: key.publicKey }), publicKey.blob)) {
301
+ throw new KeyParseError("private key did not match its public key")
302
+ }
303
+
304
+ return {
305
+ type: "ecdsa",
306
+ curve: key.curve as EcdsaCurve,
307
+ publicKey,
308
+ jwk: {
309
+ kty: "EC",
310
+ crv: curve.namedCurve,
311
+ // d is an mpint, so may be short or have a leading sign byte
312
+ d: base64urlnopad.encode(toFixedWidth(key.privateKey, curve.size)),
313
+ x: base64urlnopad.encode(key.publicKey.subarray(1, 1 + curve.size)),
314
+ y: base64urlnopad.encode(key.publicKey.subarray(1 + curve.size)),
315
+ },
316
+ }
317
+ }
318
+ } catch (err) {
319
+ if (err instanceof KeyParseError || err instanceof UnsupportedKeyError) {
320
+ throw err
321
+ }
322
+ throw new KeyParseError("private key could not be decoded", err)
323
+ }
324
+
325
+ throw new UnsupportedKeyError(`CA key type ${keyTypeName(start.type)} is not supported, the CA key must be Ed25519 or ECDSA`)
326
+ }
327
+
@@ -0,0 +1,129 @@
1
+ import * as P from "micro-packed"
2
+ import { toFixedWidth } from "../sshsig/sig_parser.js"
3
+ import { decodePublicKey, SSHBytes, SSHText, SSHUnsignedMpint } from "./encoding.js"
4
+ import type { EcdsaCurve, PrivateKey, PublicKey } from "./keys.js"
5
+
6
+ /**
7
+ * Signs data with a CA key using WebCrypto, returning signatures in the SSH
8
+ * wire format
9
+ */
10
+ export interface Signer {
11
+ /** The wire encoding of the public key of the signer */
12
+ readonly publicKey: Uint8Array
13
+ /**
14
+ * Signs data
15
+ *
16
+ * @param data The data to sign
17
+ * @returns The signature in the SSH wire format (RFC 4253 section 6.6)
18
+ */
19
+ sign(data: Uint8Array<ArrayBuffer>): Promise<Uint8Array>
20
+ }
21
+
22
+ /** An Ed25519 signature: string algorithm, string signature */
23
+ const Ed25519Signature = P.struct({
24
+ algorithm: SSHText,
25
+ signature: SSHBytes,
26
+ })
27
+
28
+ /** An ECDSA signature: string algorithm, string (mpint r, mpint s) */
29
+ const EcdsaSignature = P.struct({
30
+ algorithm: SSHText,
31
+ signature: P.prefix(P.U32BE, P.struct({
32
+ r: SSHUnsignedMpint,
33
+ s: SSHUnsignedMpint,
34
+ })),
35
+ })
36
+
37
+ const EcdsaCurves: Readonly<Record<EcdsaCurve, { namedCurve: string, hash: string, size: number }>> = {
38
+ nistp256: { namedCurve: "P-256", hash: "SHA-256", size: 32 },
39
+ nistp384: { namedCurve: "P-384", hash: "SHA-384", size: 48 },
40
+ nistp521: { namedCurve: "P-521", hash: "SHA-512", size: 66 },
41
+ }
42
+
43
+ /**
44
+ * Creates a {@link Signer} for an Ed25519 or ECDSA (P-256, P-384 or P-521)
45
+ * private key
46
+ *
47
+ * @param key The private key to sign with
48
+ * @returns The signer
49
+ */
50
+ export const createSigner = async (key: PrivateKey): Promise<Signer> => {
51
+ const publicKey = key.publicKey.blob
52
+
53
+ if (key.type === "ed25519") {
54
+ const cryptoKey = await crypto.subtle.importKey("jwk", key.jwk, { name: "Ed25519" }, false, ["sign"])
55
+
56
+ return {
57
+ publicKey,
58
+ sign: async (data) => Ed25519Signature.encode({
59
+ algorithm: "ssh-ed25519",
60
+ signature: new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, cryptoKey, data)),
61
+ }),
62
+ }
63
+ }
64
+
65
+ const curve = EcdsaCurves[key.curve]
66
+ const cryptoKey = await crypto.subtle.importKey("jwk", key.jwk, { name: "ECDSA", namedCurve: curve.namedCurve }, false, ["sign"])
67
+
68
+ return {
69
+ publicKey,
70
+ sign: async (data) => {
71
+ // WebCrypto returns r || s, each the size of the curve
72
+ const signature = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: { name: curve.hash } }, cryptoKey, data))
73
+
74
+ return EcdsaSignature.encode({
75
+ // the algorithm is named for the curve of the key, never
76
+ // worked out from the signature
77
+ algorithm: `ecdsa-sha2-${key.curve}`,
78
+ signature: {
79
+ r: signature.subarray(0, curve.size),
80
+ s: signature.subarray(curve.size),
81
+ },
82
+ })
83
+ },
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Verifies a signature in the SSH wire format made with an Ed25519 or ECDSA
89
+ * key
90
+ *
91
+ * @param publicKey The key the signature must have been made with
92
+ * @param signature The signature in the SSH wire format
93
+ * @param data The data that was signed
94
+ * @returns true if the signature is valid, false if it is not or the key type is not supported
95
+ */
96
+ export const verify = async (publicKey: PublicKey, signature: Uint8Array, data: Uint8Array<ArrayBuffer>): Promise<boolean> => {
97
+ try {
98
+ const { type, fields } = decodePublicKey(publicKey.blob)
99
+
100
+ if (type === "ssh-ed25519") {
101
+ const { algorithm, signature: raw } = Ed25519Signature.decode(signature)
102
+ if (algorithm !== type) {
103
+ return false
104
+ }
105
+ const cryptoKey = await crypto.subtle.importKey("raw", new Uint8Array(fields[0]!), { name: "Ed25519" }, false, ["verify"])
106
+
107
+ return await crypto.subtle.verify({ name: "Ed25519" }, cryptoKey, new Uint8Array(raw), data)
108
+ }
109
+
110
+ const curveName = type.replace(/^ecdsa-sha2-/, "") as EcdsaCurve
111
+ const curve = EcdsaCurves[curveName]
112
+ if (curve !== undefined && type === `ecdsa-sha2-${curveName}`) {
113
+ const { algorithm, signature: { r, s } } = EcdsaSignature.decode(signature)
114
+ if (algorithm !== type || r.length > curve.size || s.length > curve.size) {
115
+ return false
116
+ }
117
+ // fields are the curve name and the public point
118
+ const cryptoKey = await crypto.subtle.importKey("raw", new Uint8Array(fields[1]!), { name: "ECDSA", namedCurve: curve.namedCurve }, false, ["verify"])
119
+ const raw = P.utils.concatBytes(toFixedWidth(r, curve.size), toFixedWidth(s, curve.size))
120
+
121
+ return await crypto.subtle.verify({ name: "ECDSA", hash: { name: curve.hash } }, cryptoKey, new Uint8Array(raw), data)
122
+ }
123
+
124
+ return false
125
+ } catch {
126
+ // a signature or key that cannot be decoded or imported is not valid
127
+ return false
128
+ }
129
+ }
package/src/types.ts CHANGED
@@ -20,12 +20,6 @@ export type CertificateRequestJWTPayload = {
20
20
  [key: string]: string | string[]
21
21
  } & JWTPayload
22
22
 
23
- export type SSHExtension = {
24
- critical: boolean;
25
- name: string;
26
- data: Buffer<ArrayBuffer>
27
- }
28
-
29
23
  /**
30
24
  * The result of executing a statement via {@link CaPreparedStatement.run} or
31
25
  * {@link CaDatabase.batch}, modelled on a Cloudflare D1 result.
@@ -95,27 +89,73 @@ export interface CaSecret {
95
89
  }
96
90
 
97
91
  /**
98
- * The bindings (environment) the CA expects, see {@link CaDatabase} and
99
- * {@link CaSecret} for the non-string bindings.
92
+ * A rate limiter, with the same shape as a Cloudflare Workers rate limit
93
+ * binding.
94
+ *
95
+ * A Workers `RateLimit` binding satisfies this structurally. On other
96
+ * runtimes any limiter with the same shape may be used.
97
+ */
98
+ export interface CaRateLimiter {
99
+ /**
100
+ * Counts a request against the limit for a key
101
+ *
102
+ * @param options The key the request is counted against, for example the client IP address
103
+ * @returns Whether the request is within the limit
104
+ */
105
+ limit(options: { key: string }): Promise<{ success: boolean }>
106
+ }
107
+
108
+ /**
109
+ * A key-value cache, with the subset of a Cloudflare Workers KV namespace
110
+ * binding used by the CA.
111
+ *
112
+ * A Workers `KVNamespace` binding satisfies this structurally. On other
113
+ * runtimes any cache with the same shape may be used, such as the in-memory
114
+ * cache returned by `cacheInMemory` from `@andrewheberle/serverless-ssh-ca/node`.
115
+ */
116
+ export interface CaCache {
117
+ /** Returns the value for a key, or null if there is none */
118
+ get(key: string): Promise<string | null>
119
+ /** Sets the value for a key, which may expire after `expirationTtl` seconds */
120
+ put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>
121
+ }
122
+
123
+ /**
124
+ * The bindings (environment) the CA expects, see {@link CaDatabase},
125
+ * {@link CaSecret}, {@link CaRateLimiter} and {@link CaCache} for the non-string
126
+ * bindings.
100
127
  */
101
128
  export interface SshCaBindings {
102
- DB: CaDatabase
129
+ CERTIFICATE_REQUEST_TIME_SKEW_MAX: string
130
+ JWT_ALGORITHMS: string | string[]
131
+ /** The audience the identity token, and unless {@link SshCaBindings.JWT_ACCESS_TOKEN_AUD} is set the access token, must be issued for */
132
+ JWT_AUD: string | string[]
133
+ /** The audience the access token must be issued for, when it differs from {@link SshCaBindings.JWT_AUD} */
134
+ JWT_ACCESS_TOKEN_AUD?: string | string[]
135
+ JWT_ISSUER: string
136
+ JWT_JWKS_URL: string
137
+ JWT_SSH_CERTIFICATE_PRINCIPALS_CLAIM: string
138
+ LOG_LEVEL?: string
103
139
  DB_CERTIFICATE_RETENTION: string
140
+ DB: CaDatabase
141
+ ISSUER_DN: string
104
142
  PRIVATE_KEY: CaSecret
143
+ /**
144
+ * Limits requests to issue, renew and revoke certificates, see
145
+ * {@link CaRateLimiter}. Requests are not limited when this is not set.
146
+ */
147
+ RATE_LIMITER?: CaRateLimiter
148
+ /**
149
+ * Caches KRLs until a certificate is revoked, see {@link CaCache}. KRLs
150
+ * are generated for every request when this is not set.
151
+ */
152
+ CACHE?: CaCache
105
153
  SSH_CERTIFICATE_EXTENSIONS: string
106
- SSH_CERTIFICATE_LIFETIME: string
107
- SSH_CERTIFICATE_INCLUDE_SELF?: string | boolean
108
154
  SSH_CERTIFICATE_INCLUDE_SELF_EMAIL?: string | boolean
155
+ SSH_CERTIFICATE_INCLUDE_SELF?: string | boolean
156
+ SSH_CERTIFICATE_LIFETIME: string
109
157
  SSH_CERTIFICATE_PRINCIPALS?: string | string[]
110
- SSH_HOST_CERTIFICATE_LIFETIME: string
111
158
  SSH_HOST_CERTIFICATE_ALLOWED_EMAILS?: string | string[]
112
159
  SSH_HOST_CERTIFICATE_ALLOWED_ROLES?: string | string[]
113
- ISSUER_DN: string
114
- JWT_JWKS_URL: string
115
- JWT_AUD?: string | string[]
116
- JWT_ISSUER: string
117
- JWT_ALGORITHMS: string | string[]
118
- JWT_SSH_CERTIFICATE_PRINCIPALS_CLAIM: string
119
- CERTIFICATE_REQUEST_TIME_SKEW_MAX: string
120
- LOG_LEVEL?: string
160
+ SSH_HOST_CERTIFICATE_LIFETIME: string
121
161
  }