@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/src/utils.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { SshCaBindings } from "./types.js"
2
2
  import { JWKInvalid, JWKSInvalid, JWSInvalid, JWSSignatureVerificationFailed, JWTClaimValidationFailed, JWTExpired, JWTInvalid } from "jose/errors"
3
- import sshpk from "sshpk"
4
- import type { Certificate, Key, PrivateKey } from "sshpk"
3
+ import { CertificateParseError, SshCertificate } from "./ssh/certificate.js"
4
+ import { KeyParseError, parsePrivateKey, PublicKey, UnsupportedKeyError, type PrivateKey } from "./ssh/keys.js"
5
5
  import z from "zod"
6
6
  import { verifyJWT } from "./verify.js"
7
7
  import { CertificateRequestJWTPayload } from "./types.js"
@@ -11,8 +11,7 @@ import { logger } from "./logger.js"
11
11
  import { ms } from "itty-time"
12
12
  import { InternalServerErrorException } from "chanfana"
13
13
 
14
- // sshpk is CommonJS and Node's ESM loader cannot detect most of its named exports, so values are taken from the default import
15
- const { KeyParseError, CertificateParseError, parseCertificate, parseKey, parsePrivateKey } = sshpk
14
+ export { UnsupportedKeyError }
16
15
 
17
16
  export const fatalIssue = (ctx: z.RefinementCtx, message: string, val: unknown) => {
18
17
  ctx.issues.push({
@@ -104,11 +103,20 @@ export const transformAuthorizationHeader = async (env: SshCaBindings, val: stri
104
103
  }
105
104
  }
106
105
 
107
- export const transformPublicKey = (val: string | Buffer<ArrayBufferLike>, ctx: z.RefinementCtx): Key | never => {
108
- try {
109
- const key = typeof val === "string" ? parseKey(Buffer.from(val, "base64")) : parseKey(val)
106
+ // decodes a value sent as base64, which may already have been decoded by the schema
107
+ const fromBase64 = (val: string | Buffer<ArrayBufferLike>): string => {
108
+ return (typeof val === "string" ? Buffer.from(val, "base64") : Buffer.from(val)).toString("utf8")
109
+ }
110
110
 
111
- return key
111
+ /**
112
+ * Parses a public key in OpenSSH format, `${type} ${base64}[ ${comment}]`
113
+ *
114
+ * @param val The public key, either base64 encoded or as bytes
115
+ * @param ctx The Zod context for any errors
116
+ */
117
+ export const transformPublicKey = (val: string | Buffer<ArrayBufferLike>, ctx: z.RefinementCtx): PublicKey | never => {
118
+ try {
119
+ return PublicKey.parse(fromBase64(val))
112
120
  } catch (err) {
113
121
  switch (true) {
114
122
  case (err instanceof TypeError):
@@ -234,11 +242,9 @@ export const transformIdentityToken = async (env: SshCaBindings, val: string, ct
234
242
  * @param ctx The Zod context for any errors
235
243
  * @returns
236
244
  */
237
- export const transformCertificate = (val: string | Buffer<ArrayBufferLike>, ctx: z.RefinementCtx): Certificate | never => {
245
+ export const transformCertificate = (val: string | Buffer<ArrayBufferLike>, ctx: z.RefinementCtx): SshCertificate | never => {
238
246
  try {
239
- const cert = typeof val === "string" ? parseCertificate(Buffer.from(val, "base64"), "openssh") : parseCertificate(val, "openssh")
240
-
241
- return cert
247
+ return SshCertificate.parse(fromBase64(val))
242
248
  } catch (err) {
243
249
  switch (true) {
244
250
  case (err instanceof TypeError):
@@ -269,7 +275,7 @@ export const transformCertificate = (val: string | Buffer<ArrayBufferLike>, ctx:
269
275
 
270
276
  type ParsedCertificateRequest = {
271
277
  proof: ProofOfPossession
272
- public_key: Key
278
+ public_key: PublicKey
273
279
  lifetime: number
274
280
  identity: IdentityToken
275
281
  extensions: string[]
@@ -311,7 +317,7 @@ export const transformRenewalProofOfPossession = (env: SshCaBindings, val: strin
311
317
  }
312
318
 
313
319
  type HostCertificateRequest = {
314
- public_key: Key
320
+ public_key: PublicKey
315
321
  lifetime: number
316
322
  }
317
323
 
@@ -327,7 +333,7 @@ export const refineHostCertificateRequest = async (env: SshCaBindings, val: Pars
327
333
 
328
334
  try {
329
335
  // check proof of possession fingerprint matches public key
330
- l.debug("checking val.proof.matches()", "public_key", val.public_key.toString("ssh"))
336
+ l.debug("checking val.proof.matches()", "public_key", val.public_key.toString())
331
337
  if (!val.proof.matches(val.public_key)) {
332
338
  l.warn("proof of possession fingerprint did not match public_key")
333
339
  return fatalIssue(ctx, "proof of possession fingerprint did not match public_key", val)
@@ -351,38 +357,86 @@ export const refineHostCertificateRequest = async (env: SshCaBindings, val: Pars
351
357
  }
352
358
 
353
359
  type ParsedHostCertificateRenewal = {
354
- certificate: Certificate
360
+ certificate: SshCertificate
355
361
  proof: RenewalProofOfPossession
356
362
  } & HostCertificateRequest
357
363
 
358
- export const refineHostCertificateRenewal = async (env: SshCaBindings, isRevoked: typeof IsRevokedFn, val: ParsedHostCertificateRenewal, ctx: z.RefinementCtx): Promise<never> => {
364
+ /**
365
+ * Checks a certificate presented for renewal was issued by this CA, is a host
366
+ * certificate, is within its validity period and has not been revoked.
367
+ *
368
+ * @param env The CA bindings
369
+ * @param isRevoked Returns whether a serial has been revoked
370
+ * @param certificate The certificate presented for renewal
371
+ * @returns The reason the certificate cannot be renewed, or undefined if it can be
372
+ * @throws {@link UnsupportedKeyError} when the CA key is not supported
373
+ */
374
+ export const checkRenewableCertificate = async (env: SshCaBindings, isRevoked: typeof IsRevokedFn, certificate: SshCertificate): Promise<string | undefined> => {
375
+ // ensure certificate is signed by CA
376
+ const issuer = await getPublic(env)
377
+ if (!await certificate.isSignedBy(issuer)) {
378
+ return "the provided certificate was not signed by this CA"
379
+ }
380
+
381
+ // only host certificates can be renewed, as renewal does not need the
382
+ // OIDC authentication a user certificate is issued with
383
+ if (certificate.kind !== "host") {
384
+ return "the provided certificate is not a host certificate"
385
+ }
386
+
387
+ // check certificate is within its validity period, allowing for clock
388
+ // skew at the start as a certificate issued moments ago may have a
389
+ // validity start up to a second in the future
390
+ const now = Date.now()
391
+ if (now >= certificate.validBefore.getTime()) {
392
+ return "the provided certificate is expired"
393
+ }
394
+ if (certificate.validAfter.getTime() > now + ms(env.CERTIFICATE_REQUEST_TIME_SKEW_MAX)) {
395
+ return "the provided certificate is not yet valid"
396
+ }
397
+
398
+ // ensure certificate presented for renewal is not revoked
399
+ if (await isRevoked(env, certificate.serial)) {
400
+ return "the provided certificate is revoked"
401
+ }
402
+
403
+ return undefined
404
+ }
405
+
406
+ /**
407
+ * Refines a host certificate renewal so only a certificate that can be
408
+ * renewed (see {@link checkRenewableCertificate}) is accepted.
409
+ */
410
+ export const refineRenewableCertificate = async (env: SshCaBindings, isRevoked: typeof IsRevokedFn, val: { certificate: SshCertificate }, ctx: z.RefinementCtx): Promise<never> => {
359
411
  const l = logger(env)
360
412
  try {
361
- // ensure certificate is signed by CA
362
- const issuer = await getPublic(env)
363
- if (!val.certificate.isSignedByKey(issuer)) {
364
- return fatalIssue(ctx, "the provided certificate was not signed by this CA", val)
413
+ const reason = await checkRenewableCertificate(env, isRevoked, val.certificate)
414
+ if (reason !== undefined) {
415
+ return fatalIssue(ctx, reason, val)
365
416
  }
366
417
 
367
- // check certificate is within its validity period, allowing for clock
368
- // skew at the start as a certificate issued moments ago may have a
369
- // validity start up to a second in the future
370
- const now = Date.now()
371
- if (now >= val.certificate.validUntil.getTime()) {
372
- return fatalIssue(ctx, "the provided certificate is expired", val)
373
- }
374
- if (val.certificate.validFrom.getTime() > now + ms(env.CERTIFICATE_REQUEST_TIME_SKEW_MAX)) {
375
- return fatalIssue(ctx, "the provided certificate is not yet valid", val)
418
+ return z.NEVER
419
+ } catch (err) {
420
+ // a misconfigured CA key is a server error, not a validation failure
421
+ if (err instanceof UnsupportedKeyError) {
422
+ l.error(err.message, "in", "refineRenewableCertificate")
423
+ throw new InternalServerErrorException(err.message)
376
424
  }
425
+ l.error("certificate renewal check unhandled error", "in", "refineRenewableCertificate", "error", err)
426
+ return fatalIssue(ctx, "certificate renewal check unhandled error", val)
427
+ }
428
+ }
377
429
 
378
- // ensure certificate presented for renewal is not revoked
379
- const serial = val.certificate.serial.readBigUInt64BE(0)
380
- if (await isRevoked(env, serial)) {
381
- return fatalIssue(ctx, "the provided certificate is revoked", val)
430
+ export const refineHostCertificateRenewal = async (env: SshCaBindings, isRevoked: typeof IsRevokedFn, val: ParsedHostCertificateRenewal, ctx: z.RefinementCtx): Promise<never> => {
431
+ const l = logger(env)
432
+ try {
433
+ const reason = await checkRenewableCertificate(env, isRevoked, val.certificate)
434
+ if (reason !== undefined) {
435
+ return fatalIssue(ctx, reason, val)
382
436
  }
383
437
 
384
438
  // check proof of possession fingerprint matches public key and certificate subject key
385
- if (!val.proof.matches(val.public_key, val.certificate.subjectKey)) {
439
+ if (!val.proof.matches(val.public_key, val.certificate.publicKey)) {
386
440
  return fatalIssue(ctx, "proof of possession fingerprints did not match public_key and/or certificate", val)
387
441
  }
388
442
 
@@ -406,7 +460,7 @@ export const refineHostCertificateRenewal = async (env: SshCaBindings, isRevoked
406
460
 
407
461
  type ParsedRevokeCertificate = {
408
462
  serial: bigint
409
- public_key: Key
463
+ public_key: PublicKey
410
464
  proof: ProofOfPossession
411
465
  }
412
466
 
@@ -441,53 +495,29 @@ export const split = (v?: string | string[]): string[] => {
441
495
  : v
442
496
  }
443
497
 
444
- /**
445
- * Thrown by {@link getPrivateKey} when the CA private key is of a type that
446
- * cannot be used.
447
- */
448
- export class UnsupportedKeyError extends Error {
449
- constructor(message: string) {
450
- super(message)
451
- this.name = "UnsupportedKeyError"
452
-
453
- Object.setPrototypeOf(this, UnsupportedKeyError.prototype)
454
- }
455
- }
456
-
457
498
  /**
458
499
  * Load and parse the CA private key from the `PRIVATE_KEY` secret.
459
500
  *
460
- * Only Ed25519 and ECDSA keys are supported. RSA keys are rejected because
461
- * sshpk signs RSA certificates with SHA-1 (`ssh-rsa`), which OpenSSH 8.2 and
462
- * later refuse by default, and because RSA keys in OpenSSH format are
463
- * converted with invalid CRT parameters that the Workers runtime rejects.
501
+ * Only Ed25519 and ECDSA keys are supported, as these are the key types that
502
+ * certificates and KRLs are signed with (see `createSigner` in `ssh/signer.ts`).
464
503
  *
465
- * @throws KeyParseError when the secret cannot be parsed.
504
+ * @throws {@link KeyParseError} when the secret cannot be parsed.
466
505
  * @throws {@link UnsupportedKeyError} when the key is not Ed25519 or ECDSA.
467
506
  */
468
507
  export const getPrivateKey = async (env: SshCaBindings): Promise<PrivateKey> => {
469
508
  // grab private key from secret store
470
509
  const secret = await env.PRIVATE_KEY.get()
471
510
 
472
- // parse it
473
- const key = parsePrivateKey(secret)
474
-
475
- if (key.type !== "ed25519" && key.type !== "ecdsa") {
476
- throw new UnsupportedKeyError(`CA key type ${key.type} is not supported, the CA key must be Ed25519 or ECDSA`)
477
- }
478
-
479
- return key
511
+ return parsePrivateKey(secret)
480
512
  }
481
513
 
482
- export const getPublic = async (env: SshCaBindings, key?: PrivateKey): Promise<Key> => {
514
+ /**
515
+ * Returns the public key of the CA, with `ISSUER_DN` as its comment
516
+ */
517
+ export const getPublic = async (env: SshCaBindings, key?: PrivateKey): Promise<PublicKey> => {
483
518
  if (key === undefined) {
484
519
  key = await getPrivateKey(env)
485
520
  }
486
521
 
487
- // convert to a public key and add comment
488
- const pub = key.toPublic()
489
- pub.comment = env.ISSUER_DN
490
-
491
- // return in ssh format trimmed of any whitespace
492
- return pub
522
+ return key.publicKey.withComment(env.ISSUER_DN)
493
523
  }