@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/index.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import type { ExecutionContext } from "hono"
2
2
  import { dbCleanup } from "./db/index.js"
3
- import { createApp } from "./router.js"
3
+ import { createApp, type SshCaOptions } from "./router.js"
4
4
  import type { SshCaBindings } from "./types.js"
5
- export type { CaDatabase, CaDatabaseQueryResult, CaDatabaseResult, CaPreparedStatement, CaSecret, SshCaBindings } from "./types.js"
5
+ export type { CaCache, CaDatabase, CaDatabaseQueryResult, CaDatabaseResult, CaPreparedStatement, CaRateLimiter, CaSecret, SshCaBindings } from "./types.js"
6
+ export { cloudflareClientIp, type RateLimitKey, type SshCaOptions } from "./router.js"
6
7
 
7
8
  // the handlers the CA exposes, compatible with a Cloudflare Workers ExportedHandler
8
9
  type SshCaHandler = {
@@ -35,10 +36,11 @@ export interface SshCa {
35
36
  * the runtime on each request.
36
37
  *
37
38
  * @param env The bindings (configuration, database and private key) for the CA, see {@link SshCaBindings}
39
+ * @param options Options for the CA, such as the key requests are rate limited by, see {@link SshCaOptions}
38
40
  * @returns The CA's request handler and database cleanup task, see {@link SshCa}
39
41
  */
40
- export const createSshCa = (env: SshCaBindings): SshCa => {
41
- const app = createApp(env)
42
+ export const createSshCa = (env: SshCaBindings, options: SshCaOptions = {}): SshCa => {
43
+ const app = createApp(env, options)
42
44
 
43
45
  return {
44
46
  fetch: async (req: Request): Promise<Response> => await app.fetch(req, env),
package/src/krl.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { AlgorithmPart, PrivateKey } from "sshpk"
2
- import { toFixedWidth } from "./sshsig/sig_parser.js"
1
+ import type { PrivateKey } from "./ssh/keys.js"
2
+ import { createSigner } from "./ssh/signer.js"
3
3
 
4
4
  // ── Constants ────────────────────────────────────────────────────────────────
5
5
 
@@ -59,32 +59,6 @@ function encodeSection(sectionType: number, data: Uint8Array): Uint8Array {
59
59
  return concat(new Uint8Array([sectionType]), encodeString(data))
60
60
  }
61
61
 
62
- // ── CA public-key extraction ─────────────────────────────────────────────────
63
-
64
- /**
65
- * Extract the SSH wire-format public key bytes from an sshpk PrivateKey.
66
- *
67
- * sshpk's "ssh" format produces a string like:
68
- * ssh-ed25519 AAAA...base64... [comment]
69
- *
70
- * The base64 blob *is* the SSH wire encoding of the public key, so we just
71
- * decode it. This works for both Ed25519 and ECDSA keys.
72
- */
73
- function caKeyWireBytes(caKey: PrivateKey): Uint8Array {
74
- const sshString = caKey.toPublic().toString("ssh")
75
- // Format: "<type> <base64> [<comment>]" — we want the second token.
76
- const base64 = sshString.trim().split(/\s+/)[1]
77
- if (!base64) {
78
- throw new Error("Unexpected sshpk 'ssh' format output: " + sshString)
79
- }
80
- const binary = atob(base64)
81
- const bytes = new Uint8Array(binary.length)
82
- for (let i = 0; i < binary.length; i++) {
83
- bytes[i] = binary.charCodeAt(i)
84
- }
85
- return bytes
86
- }
87
-
88
62
  // ── Constants ────────────────────────────────────────────────────────────────
89
63
 
90
64
  const SSHSIG_MAGIC = new TextEncoder().encode("SSHSIG")
@@ -92,156 +66,6 @@ const SIG_VERSION = 1
92
66
  const NAMESPACE = "krl@com.github.serverless-ssh-ca.andrewheberle"
93
67
  const HASH_ALGORITHM = "sha512"
94
68
 
95
- // ── Helpers ──────────────────────────────────────────────────────────────────
96
-
97
- /**
98
- * Get the raw bytes of a named part of an sshpk key.
99
- */
100
- function keyPart(key: PrivateKey, name: AlgorithmPart): Uint8Array {
101
- const part = key.parts.find((p) => p.name === name)
102
- if (part === undefined) {
103
- throw new Error(`Missing ${name} part in ${key.type} key`)
104
- }
105
- return part.data
106
- }
107
-
108
- /**
109
- * Base64url-encode bytes without padding, as used for JWK values.
110
- */
111
- function base64url(bytes: Uint8Array): string {
112
- let binary = ""
113
- for (const byte of bytes) {
114
- binary += String.fromCharCode(byte)
115
- }
116
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
117
- }
118
-
119
- /**
120
- * Import an sshpk PrivateKey into a WebCrypto CryptoKey.
121
- * Supports Ed25519 and ECDSA (P-256 / P-384 / P-521).
122
- */
123
- async function importPrivateKey(caKey: PrivateKey): Promise<{
124
- cryptoKey: CryptoKey
125
- sigAlgo: string // SSH signature algorithm name
126
- webCryptoAlgo: Parameters<SubtleCrypto["sign"]>[0]
127
- }> {
128
- const type = caKey.type // "ed25519" | "ecdsa" | etc.
129
- const curve = (caKey as unknown as { curve?: string }).curve
130
-
131
- if (type === "ed25519") {
132
- // import from the raw key parts as a JWK rather than via sshpk's PKCS#8
133
- // output, which drops a leading zero byte from the seed (about 1 key in
134
- // 512) and is then rejected by WebCrypto
135
- const seed = keyPart(caKey, "k")
136
- const publicKey = keyPart(caKey, "A")
137
- if (seed.length !== 32 || publicKey.length !== 32) {
138
- throw new Error(`Unexpected Ed25519 key part lengths: k=${seed.length} A=${publicKey.length}`)
139
- }
140
- const cryptoKey = await crypto.subtle.importKey(
141
- "jwk",
142
- { kty: "OKP", crv: "Ed25519", d: base64url(seed), x: base64url(publicKey) },
143
- { name: "Ed25519" },
144
- false,
145
- ["sign"],
146
- )
147
- return {
148
- cryptoKey,
149
- sigAlgo: "ssh-ed25519",
150
- webCryptoAlgo: { name: "Ed25519" },
151
- }
152
- } else if (type === "ecdsa") {
153
- let namedCurve: string
154
- let sigAlgo: string
155
- let hash: string
156
- let size: number // bytes in each coordinate and the private key
157
-
158
- if (curve === "nistp256") {
159
- namedCurve = "P-256"
160
- sigAlgo = "ecdsa-sha2-nistp256"
161
- hash = "SHA-256"
162
- size = 32
163
- } else if (curve === "nistp384") {
164
- namedCurve = "P-384"
165
- sigAlgo = "ecdsa-sha2-nistp384"
166
- hash = "SHA-384"
167
- size = 48
168
- } else if (curve === "nistp521") {
169
- namedCurve = "P-521"
170
- sigAlgo = "ecdsa-sha2-nistp521"
171
- hash = "SHA-512"
172
- size = 66
173
- } else {
174
- throw new Error(`Unsupported ECDSA curve: ${curve}`)
175
- }
176
-
177
- // import from the raw key parts as a JWK rather than via sshpk's PKCS#8
178
- // output, which drops leading zero bytes from the private key so it is
179
- // shorter than RFC 5915 requires (about half of P-521 keys)
180
- const point = keyPart(caKey, "Q")
181
- if (point.length !== 1 + size * 2 || point[0] !== 0x04) {
182
- throw new Error(`Unexpected ECDSA public key encoding for ${curve}`)
183
- }
184
- // d is an SSH mpint, so may be short or have a leading sign byte
185
- const d = toFixedWidth(keyPart(caKey, "d"), size)
186
- const cryptoKey = await crypto.subtle.importKey(
187
- "jwk",
188
- {
189
- kty: "EC",
190
- crv: namedCurve,
191
- d: base64url(d),
192
- x: base64url(point.subarray(1, 1 + size)),
193
- y: base64url(point.subarray(1 + size)),
194
- },
195
- { name: "ECDSA", namedCurve },
196
- false,
197
- ["sign"],
198
- )
199
- return {
200
- cryptoKey,
201
- sigAlgo,
202
- webCryptoAlgo: { name: "ECDSA", hash: { name: hash } },
203
- }
204
- } else {
205
- throw new Error(`Unsupported key type: ${type}`)
206
- }
207
- }
208
-
209
- /**
210
- * Convert a WebCrypto ECDSA signature (IEEE P1363: raw r||s) to the SSH wire
211
- * encoding: string(algo) + string(mpint(r) + mpint(s)).
212
- *
213
- * SSH mpints are big-endian, with a leading 0x00 byte if the high bit is set.
214
- */
215
- function p1363ToSshEcdsaSig(
216
- p1363: Uint8Array,
217
- sigAlgo: string,
218
- ): Uint8Array {
219
- const half = p1363.length / 2
220
- const r = p1363.slice(0, half)
221
- const s = p1363.slice(half)
222
-
223
- function encodeMpint(n: Uint8Array): Uint8Array {
224
- // Strip leading zero bytes, then re-pad if high bit set.
225
- let start = 0
226
- while (start < n.length - 1 && n[start] === 0) start++
227
- const trimmed = n.slice(start)
228
- const padded =
229
- trimmed[0]! & 0x80 ? concat(new Uint8Array([0x00]), trimmed) : trimmed
230
- return encodeString(padded)
231
- }
232
-
233
- const innerSig = concat(encodeMpint(r), encodeMpint(s))
234
- return concat(encodeString(sigAlgo), encodeString(innerSig))
235
- }
236
-
237
- /**
238
- * Wrap an Ed25519 WebCrypto signature into SSH wire encoding:
239
- * string(algo) + string(sig_bytes)
240
- */
241
- function wrapEd25519Sig(sig: Uint8Array): Uint8Array {
242
- return concat(encodeString("ssh-ed25519"), encodeString(sig))
243
- }
244
-
245
69
  /**
246
70
  * Base64-encode bytes and wrap in SSH signature armor at 76 chars per line.
247
71
  */
@@ -310,7 +134,7 @@ export class KRLBuilder {
310
134
  const keyIds = [...this._keyIds].sort()
311
135
 
312
136
  const content = concat(
313
- encodeString(caKeyWireBytes(this._caKey)),
137
+ encodeString(this._caKey.publicKey.blob),
314
138
  encodeUint32(serials.length),
315
139
  ...serials.map(encodeUint64),
316
140
  encodeUint32(keyIds.length),
@@ -326,14 +150,15 @@ export class KRLBuilder {
326
150
  *
327
151
  * @param krlVersion Optional KRL version number (default: 1).
328
152
  * @param comment Optional comment string embedded in the header.
153
+ * @param generatedAt Optional time the KRL was generated (default: now).
329
154
  */
330
- generate(krlVersion: bigint = 1n, comment = ""): ArrayBuffer {
155
+ generate(krlVersion: bigint = 1n, comment = "", generatedAt?: Date): ArrayBuffer {
331
156
  if (this._krl !== null) {
332
157
  return this._krl
333
158
  }
334
159
 
335
160
  // ── Header ──────────────────────────────────────────────────────────────
336
- const generatedDate = BigInt(Math.floor(Date.now() / 1000))
161
+ const generatedDate = BigInt(Math.floor((generatedAt?.getTime() ?? Date.now()) / 1000))
337
162
 
338
163
  const header = concat(
339
164
  encodeUint64(KRL_MAGIC),
@@ -375,7 +200,7 @@ export class KRLBuilder {
375
200
  // <one or more cert sub-sections>
376
201
  //
377
202
  const certSectionData = concat(
378
- encodeString(caKeyWireBytes(this._caKey)), // ca_key
203
+ encodeString(this._caKey.publicKey.blob), // ca_key
379
204
  encodeString(""), // reserved
380
205
  ...certSubSections
381
206
  )
@@ -409,7 +234,7 @@ export class KRLBuilder {
409
234
  } else {
410
235
  krl = this._krl
411
236
  }
412
- const { cryptoKey, sigAlgo, webCryptoAlgo } = await importPrivateKey(this._caKey)
237
+ const signer = await createSigner(this._caKey)
413
238
 
414
239
  // 1. Hash the KRL bytes with SHA-512.
415
240
  const krlHash = new Uint8Array(
@@ -431,18 +256,10 @@ export class KRLBuilder {
431
256
  encodeString(krlHash),
432
257
  )
433
258
 
434
- // 3. Sign the blob.
435
- const rawSig = new Uint8Array(
436
- await crypto.subtle.sign(webCryptoAlgo, cryptoKey, signedData),
437
- )
438
-
439
- // 4. Encode the signature into SSH wire format.
440
- const sshSig =
441
- sigAlgo === "ssh-ed25519"
442
- ? wrapEd25519Sig(rawSig)
443
- : p1363ToSshEcdsaSig(rawSig, sigAlgo)
259
+ // 3. Sign the blob, which returns the signature in SSH wire format.
260
+ const sshSig = await signer.sign(signedData)
444
261
 
445
- // 5. Assemble the outer SSHSIG blob.
262
+ // 4. Assemble the outer SSHSIG blob.
446
263
  //
447
264
  // byte[6] "SSHSIG"
448
265
  // uint32 SIG_VERSION (1)
@@ -451,18 +268,17 @@ export class KRLBuilder {
451
268
  // string reserved (empty)
452
269
  // string hash_algorithm
453
270
  // string signature
454
- const publicKeyWire = caKeyWireBytes(this._caKey)
455
271
  const sigBlob = concat(
456
272
  SSHSIG_MAGIC,
457
273
  encodeUint32(SIG_VERSION),
458
- encodeString(publicKeyWire),
274
+ encodeString(signer.publicKey),
459
275
  encodeString(NAMESPACE),
460
276
  encodeString(""), // reserved
461
277
  encodeString(HASH_ALGORITHM),
462
278
  encodeString(sshSig),
463
279
  )
464
280
 
465
- // 6. Armor and return.
281
+ // 5. Armor and return.
466
282
  return armorSignature(sigBlob)
467
283
  }
468
284
  }
@@ -0,0 +1,62 @@
1
+ import type { CaCache } from "../types.js"
2
+
3
+ type Entry = {
4
+ value: string
5
+ /** When the entry expires in milliseconds since the Unix epoch, if it does */
6
+ expires: number | undefined
7
+ }
8
+
9
+ /**
10
+ * Create an in-memory cache for the `CACHE` binding, with the same shape as a
11
+ * Cloudflare Workers KV namespace (see {@link CaCache}).
12
+ *
13
+ * Entries are kept in memory by the process, so they are lost on restart and
14
+ * not shared between processes.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * const env = bindingsFromEnv({
19
+ * DB: fromNodeSqlite(new DatabaseSync("ssh-ca.sqlite")),
20
+ * PRIVATE_KEY: secretFromFile("/etc/ssh-ca/ca_key"),
21
+ * CACHE: cacheInMemory(),
22
+ * })
23
+ * ```
24
+ *
25
+ * @returns A cache for the `CACHE` binding, see {@link CaCache}
26
+ */
27
+ export const cacheInMemory = (): CaCache => {
28
+ const entries = new Map<string, Entry>()
29
+
30
+ const expired = (entry: Entry, now: number): boolean => entry.expires !== undefined && now >= entry.expires
31
+
32
+ return {
33
+ get: async (key: string): Promise<string | null> => {
34
+ const entry = entries.get(key)
35
+ if (entry === undefined) {
36
+ return null
37
+ }
38
+
39
+ if (expired(entry, Date.now())) {
40
+ entries.delete(key)
41
+ return null
42
+ }
43
+
44
+ return entry.value
45
+ },
46
+ put: async (key: string, value: string, options?: { expirationTtl?: number }): Promise<void> => {
47
+ const now = Date.now()
48
+
49
+ // remove entries that have expired, as they are not read again
50
+ for (const [k, entry] of entries) {
51
+ if (expired(entry, now)) {
52
+ entries.delete(k)
53
+ }
54
+ }
55
+
56
+ entries.set(key, {
57
+ value,
58
+ expires: options?.expirationTtl === undefined ? undefined : now + options.expirationTtl * 1000,
59
+ })
60
+ },
61
+ }
62
+ }
package/src/node/env.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { seconds } from "itty-time"
2
2
  import z from "zod"
3
- import type { CaDatabase, CaSecret, SshCaBindings } from "../types.js"
3
+ import type { CaCache, CaDatabase, CaRateLimiter, CaSecret, SshCaBindings } from "../types.js"
4
4
 
5
5
  const required = z.string().trim().min(1, { message: "must be set" })
6
6
 
@@ -32,7 +32,8 @@ export const EnvSchema = z.object({
32
32
  SSH_HOST_CERTIFICATE_ALLOWED_ROLES: z.string().optional(),
33
33
  ISSUER_DN: required,
34
34
  JWT_JWKS_URL: z.url({ protocol: /^https?$/, message: "must be an http or https URL" }),
35
- JWT_AUD: z.string().optional(),
35
+ JWT_AUD: required,
36
+ JWT_ACCESS_TOKEN_AUD: z.string().optional(),
36
37
  JWT_ISSUER: required,
37
38
  JWT_ALGORITHMS: required,
38
39
  JWT_SSH_CERTIFICATE_PRINCIPALS_CLAIM: required,
@@ -72,13 +73,13 @@ export class InvalidConfigurationError extends Error {
72
73
  * })
73
74
  * ```
74
75
  *
75
- * @param bindings The database and private key, see {@link CaDatabase} and {@link CaSecret}
76
+ * @param bindings The database, private key, optional rate limiter and optional cache, see {@link CaDatabase}, {@link CaSecret}, {@link CaRateLimiter} and {@link CaCache}
76
77
  * @param source The environment variables to read, `process.env` by default
77
78
  * @returns The bindings to pass to `createSshCa`
78
79
  * @throws {@link InvalidConfigurationError} if a variable is missing or invalid
79
80
  */
80
81
  export const bindingsFromEnv = (
81
- bindings: { DB: CaDatabase, PRIVATE_KEY: CaSecret },
82
+ bindings: { DB: CaDatabase, PRIVATE_KEY: CaSecret, RATE_LIMITER?: CaRateLimiter, CACHE?: CaCache },
82
83
  source: Record<string, string | undefined> = process.env,
83
84
  ): SshCaBindings => {
84
85
  const result = EnvSchema.safeParse(source)
@@ -86,5 +87,5 @@ export const bindingsFromEnv = (
86
87
  throw new InvalidConfigurationError(`invalid configuration:\n${z.prettifyError(result.error)}`)
87
88
  }
88
89
 
89
- return { ...result.data, DB: bindings.DB, PRIVATE_KEY: bindings.PRIVATE_KEY }
90
+ return { ...result.data, DB: bindings.DB, PRIVATE_KEY: bindings.PRIVATE_KEY, RATE_LIMITER: bindings.RATE_LIMITER, CACHE: bindings.CACHE }
90
91
  }
package/src/node/index.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  *
17
17
  * @packageDocumentation
18
18
  */
19
+ export { cacheInMemory } from "./cache.js"
19
20
  export { bindingsFromEnv, EnvSchema, InvalidConfigurationError } from "./env.js"
20
21
  export type { CaEnv } from "./env.js"
21
22
  export { secretFromFile, secretFromString } from "./secret.js"
package/src/proof.ts CHANGED
@@ -1,16 +1,34 @@
1
1
  import { ms } from "itty-time"
2
- import sshpk from "sshpk"
3
- import type { Fingerprint, Key } from "sshpk"
2
+ import { KeyParseError, PublicKey } from "./ssh/keys.js"
4
3
  import { verify } from "./sshsig/index.js"
5
4
  import { parse } from "./sshsig/sig_parser.js"
6
5
  import { Sig } from "./sshsig/sig.js"
7
6
  import { group, Logger, LogLevel } from "@andrewheberle/ts-slog"
8
7
 
9
- // sshpk is CommonJS and Node's ESM loader cannot detect most of its named exports, so values are taken from the default import
10
- const { FingerprintFormatError, parseFingerprint, parseKey } = sshpk
8
+ // a SHA256 fingerprint as shown by ssh-keygen -l, which is unpadded base64
9
+ const FingerprintFormat = /^SHA256:[A-Za-z0-9+/]{43}$/
11
10
 
12
11
  export const Namespace = "proof-of-possession@com.github.serverless-ssh-ca.andrewheberle"
13
12
 
13
+ /**
14
+ * The namespace used for proofs of possession that are bound to a request,
15
+ * kept separate from {@link Namespace} so a proof made for one form can never
16
+ * be accepted as the other.
17
+ */
18
+ export const RequestNamespace = "request-proof-of-possession@com.github.serverless-ssh-ca.andrewheberle"
19
+
20
+ /**
21
+ * Returns the request a proof of possession is bound to, which is appended to
22
+ * the signed data as `${timestamp}.${fingerprint}.${binding}`.
23
+ *
24
+ * @param method The HTTP method of the request, for example "DELETE"
25
+ * @param path The path of the request with any parameters filled in, for example "/api/v4/user/certificates/123"
26
+ * @returns The request binding, for example "DELETE /api/v4/user/certificates/123"
27
+ */
28
+ export const requestBinding = (method: string, path: string): string => {
29
+ return `${method.toUpperCase()} ${path}`
30
+ }
31
+
14
32
  export class PossessionParseError extends Error {
15
33
  constructor(message: string, cause?: unknown) {
16
34
  super(message)
@@ -38,9 +56,10 @@ export class PossessionParseError extends Error {
38
56
  */
39
57
  export class ProofOfPossession {
40
58
  readonly timestamp: number
41
- readonly fingerprint: Fingerprint
59
+ /** The SHA256 fingerprint of the key, for example "SHA256:79mq0wtpQMTvS4+Of8VzLN0qmYWNUyTXYmqKwhEgSLs" */
60
+ readonly fingerprint: string
42
61
  readonly signature: Sig
43
- readonly signaturePubkey: Key
62
+ readonly signaturePubkey: PublicKey
44
63
  readonly logger: Logger
45
64
  private readonly data: string
46
65
 
@@ -69,10 +88,9 @@ constructor(proof: string, options?: { from?: number, logger?: Logger, skew?: nu
69
88
  throw new PossessionParseError("timestamp was from the future")
70
89
  }
71
90
  try {
72
- // parse fingerprint
73
- const fingerprint = parseFingerprint(fingerprintString!)
74
- if (fingerprint === undefined) {
75
- throw new PossessionParseError("fingerprint did not parse")
91
+ // check fingerprint
92
+ if (!FingerprintFormat.test(fingerprintString!)) {
93
+ throw new PossessionParseError("proof of possession fingerprint was an invalid format")
76
94
  }
77
95
 
78
96
  // convert siganture from base64
@@ -86,26 +104,26 @@ constructor(proof: string, options?: { from?: number, logger?: Logger, skew?: nu
86
104
  throw new PossessionParseError("proof of possession signature could not be parsed", err)
87
105
  }
88
106
 
89
- this.fingerprint = fingerprint
107
+ this.fingerprint = fingerprintString!
90
108
  this.data = `${timestamp}.${fingerprintString}`
91
- this.signaturePubkey = parseKey(this.signature.publickey.toString())
109
+ this.signaturePubkey = PublicKey.parse(this.signature.publickey.toString())
92
110
 
93
111
  // ensure the provided fingerprint matches the signature public key
94
- if (!this.fingerprint.matches(this.signaturePubkey)) {
112
+ if (this.fingerprint !== this.signaturePubkey.fingerprint()) {
95
113
  throw new PossessionParseError("proof of possession fingerprint does not match signature public key")
96
114
  }
97
115
 
98
116
  this.logger.debug("constructor completed",
99
117
  ...group("this",
100
118
  "timestamp", this.timestamp,
101
- "fingerprint", this.fingerprint.toString("base64"),
102
- "signaturePubkey.fingerprint()", this.signaturePubkey.fingerprint().toString("base64"),
119
+ "fingerprint", this.fingerprint,
120
+ "signaturePubkey.fingerprint()", this.signaturePubkey.fingerprint(),
103
121
  ),
104
122
  )
105
123
  } catch (err) {
106
124
  switch (true) {
107
- case (err instanceof FingerprintFormatError):
108
- throw new PossessionParseError("proof of possession fingerprint was an invalid format", err)
125
+ case (err instanceof KeyParseError):
126
+ throw new PossessionParseError(`proof of possession signature key could not be used: ${err.message}`, err)
109
127
  default:
110
128
  throw err
111
129
  }
@@ -120,26 +138,42 @@ constructor(proof: string, options?: { from?: number, logger?: Logger, skew?: nu
120
138
  return await verify(this.signature, this.data, { namespace: namespace || Namespace })
121
139
  }
122
140
 
141
+ /**
142
+ * Verifies the proof was made for a specific request, so it cannot be
143
+ * replayed against another endpoint or with other path parameters (such
144
+ * as a different certificate serial).
145
+ *
146
+ * The signed data is `${timestamp}.${fingerprint}.${METHOD} ${path}` using
147
+ * the {@link RequestNamespace} namespace.
148
+ *
149
+ * @param method The HTTP method of the request
150
+ * @param path The path of the request with any parameters filled in
151
+ * @returns true if signature is valid for the request, false otherwise
152
+ */
153
+ async verifyRequest(method: string, path: string): Promise<boolean> {
154
+ return await verify(this.signature, `${this.data}.${requestBinding(method, path)}`, { namespace: RequestNamespace })
155
+ }
156
+
123
157
  /**
124
158
  *
125
159
  * @param keys array of keys to verify finterprint against
126
160
  * @returns true if the proof of possession fingerprint matches any of the provided keys, false otherwise
127
161
  */
128
- matches(key: Key): boolean
129
- matches(...keys: Key[]): boolean
162
+ matches(key: PublicKey): boolean
163
+ matches(...keys: PublicKey[]): boolean
130
164
 
131
- matches(...keys: Key[]): boolean {
165
+ matches(...keys: PublicKey[]): boolean {
132
166
  this.logger.debug("matches() called", "in", "ProofOfPossession", "keys", keys.length)
133
167
  try {
134
168
  for (const key of keys) {
135
169
  // confirm proof of possession fingerprint matches keys
136
- this.logger.debug("this.fingerprint.matches()", "in", "ProofOfPossession", "fingerprint", this.fingerprint.toString("base64"), "key", key.toString("ssh"))
137
- if (!this.fingerprint.matches(key))
170
+ this.logger.debug("comparing fingerprint", "in", "ProofOfPossession", "fingerprint", this.fingerprint, "key", key.toString())
171
+ if (this.fingerprint !== key.fingerprint())
138
172
  return false
139
173
 
140
174
  // also confirm key used to sign proof of possession matches
141
- this.logger.debug("this.signaturePubkey.fingerprint().matches()", "in", "ProofOfPossession", "signaturePubkey", this.signaturePubkey.fingerprint().toString("base64"), "key", key.toString("ssh"))
142
- if (!this.signaturePubkey.fingerprint().matches(key))
175
+ this.logger.debug("comparing signature public key", "in", "ProofOfPossession", "signaturePubkey", this.signaturePubkey.fingerprint(), "key", key.toString())
176
+ if (!this.signaturePubkey.equals(key))
143
177
  return false
144
178
  }
145
179
  this.logger.debug("matches() completed", "in", "ProofOfPossession", "keys", keys.length)
@@ -163,10 +197,10 @@ export class PossessionMatchError extends Error {
163
197
  }
164
198
 
165
199
  export class RenewalProofOfPossession extends ProofOfPossession {
166
- override matches(key: Key): never
167
- override matches(...keys: Key[]): boolean
200
+ override matches(key: PublicKey): never
201
+ override matches(...keys: PublicKey[]): boolean
168
202
 
169
- override matches(...keys: Key[]): boolean {
203
+ override matches(...keys: PublicKey[]): boolean {
170
204
  this.logger.debug("matches() called", "in", "RenewalProofOfPossession", "keys", keys.length)
171
205
  if (keys.length === 1) {
172
206
  throw new PossessionMatchError("must verify both public key and certificate")
@@ -174,11 +208,11 @@ export class RenewalProofOfPossession extends ProofOfPossession {
174
208
 
175
209
  try {
176
210
  for (const key of keys) {
177
- this.logger.debug("this.fingerprint.matches()", "in", "RenewalProofOfPossession", "fingerprint", this.fingerprint.toString("base64"), "key", key.toString("ssh"))
178
- if (!this.fingerprint.matches(key))
211
+ this.logger.debug("comparing fingerprint", "in", "RenewalProofOfPossession", "fingerprint", this.fingerprint, "key", key.toString())
212
+ if (this.fingerprint !== key.fingerprint())
179
213
  return false
180
- this.logger.debug("this.signaturePubkey.fingerprint().matches()", "in", "RenewalProofOfPossession", "signaturePubkey", this.signaturePubkey.fingerprint().toString("base64"), "key", key.toString("ssh"))
181
- if (!this.signaturePubkey.fingerprint().matches(key))
214
+ this.logger.debug("comparing signature public key", "in", "RenewalProofOfPossession", "signaturePubkey", this.signaturePubkey.fingerprint(), "key", key.toString())
215
+ if (!this.signaturePubkey.equals(key))
182
216
  return false
183
217
  }
184
218
  this.logger.debug("matches() completed", "in", "RenewalProofOfPossession", "keys", keys.length)