@andrewheberle/serverless-ssh-ca 0.17.18 → 0.18.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 (42) hide show
  1. package/README.md +17 -0
  2. package/dist/api/v3/index.d.ts.map +1 -1
  3. package/dist/api/v3/index.js +24 -5
  4. package/dist/api/v3/index.js.map +1 -1
  5. package/dist/api/v3/schema.d.ts +3 -3
  6. package/dist/api/v3/schema.d.ts.map +1 -1
  7. package/dist/api/v3/schema.js +7 -2
  8. package/dist/api/v3/schema.js.map +1 -1
  9. package/dist/certificate.d.ts.map +1 -1
  10. package/dist/certificate.js +4 -8
  11. package/dist/certificate.js.map +1 -1
  12. package/dist/db/index.d.ts.map +1 -1
  13. package/dist/db/index.js +3 -2
  14. package/dist/db/index.js.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/krl.d.ts.map +1 -1
  18. package/dist/krl.js.map +1 -1
  19. package/dist/logger.d.ts.map +1 -1
  20. package/dist/proof.d.ts.map +1 -1
  21. package/dist/router.d.ts.map +1 -1
  22. package/dist/sshsig/formats.d.ts.map +1 -1
  23. package/dist/sshsig/reader.d.ts.map +1 -1
  24. package/dist/sshsig/reader.js.map +1 -1
  25. package/dist/sshsig/sig_parser.d.ts +30 -0
  26. package/dist/sshsig/sig_parser.d.ts.map +1 -1
  27. package/dist/sshsig/sig_parser.js +66 -18
  28. package/dist/sshsig/sig_parser.js.map +1 -1
  29. package/dist/sshsig/writer.d.ts.map +1 -1
  30. package/dist/utils.d.ts +26 -0
  31. package/dist/utils.d.ts.map +1 -1
  32. package/dist/utils.js +79 -12
  33. package/dist/utils.js.map +1 -1
  34. package/dist/verify.d.ts.map +1 -1
  35. package/dist/verify.js.map +1 -1
  36. package/package.json +3 -3
  37. package/src/api/v3/index.ts +24 -5
  38. package/src/api/v3/schema.ts +7 -2
  39. package/src/certificate.ts +3 -10
  40. package/src/db/index.ts +3 -2
  41. package/src/sshsig/sig_parser.ts +79 -20
  42. package/src/utils.ts +90 -13
@@ -3,6 +3,75 @@ import { Reader } from "./reader";
3
3
  import { parsePubkey } from "./formats";
4
4
  import { dearmor } from "./armor";
5
5
 
6
+ /**
7
+ * ECDSA signature algorithms whose signatures need converting from SSH
8
+ * mpint encoding to the raw r || s format used by WebCrypto.
9
+ */
10
+ export type EcdsaSigAlgo =
11
+ | "ecdsa-sha2-nistp256"
12
+ | "ecdsa-sha2-nistp384"
13
+ | "ecdsa-sha2-nistp521"
14
+ | "sk-ecdsa-sha2-nistp256@openssh.com";
15
+
16
+ /**
17
+ * Narrow a signature algorithm name to an {@link EcdsaSigAlgo}.
18
+ *
19
+ * @param sig_algo Signature algorithm name from the SSH signature.
20
+ */
21
+ export function isEcdsaSigAlgo(sig_algo: string): sig_algo is EcdsaSigAlgo {
22
+ return sig_algo === "ecdsa-sha2-nistp256" ||
23
+ sig_algo === "ecdsa-sha2-nistp384" ||
24
+ sig_algo === "ecdsa-sha2-nistp521" ||
25
+ sig_algo === "sk-ecdsa-sha2-nistp256@openssh.com";
26
+ }
27
+
28
+ /**
29
+ * Size in bytes of each of the r and s signature components for the curve
30
+ * used by an {@link EcdsaSigAlgo}.
31
+ *
32
+ * @param sig_algo ECDSA signature algorithm name.
33
+ */
34
+ export function ecdsaComponentSize(sig_algo: EcdsaSigAlgo): number {
35
+ switch (sig_algo) {
36
+ case "ecdsa-sha2-nistp256":
37
+ case "sk-ecdsa-sha2-nistp256@openssh.com":
38
+ return 32;
39
+ case "ecdsa-sha2-nistp384":
40
+ return 48;
41
+ case "ecdsa-sha2-nistp521":
42
+ return 66;
43
+ default: {
44
+ const exhaustive: never = sig_algo;
45
+ throw new Error(`unsupported ECDSA signature algorithm: ${exhaustive}`);
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Convert an SSH mpint encoded ECDSA signature component (r or s) to a
52
+ * fixed-width big-endian value of `size` bytes.
53
+ *
54
+ * Leading zero bytes are removed and the value is then left-padded with
55
+ * zeros to `size`. This throws an Error when the value is zero or does not
56
+ * fit in `size` bytes.
57
+ *
58
+ * @param mpint Component bytes as encoded in the SSH signature.
59
+ * @param size Required width in bytes, see {@link ecdsaComponentSize}.
60
+ */
61
+ export function toFixedWidth(mpint: Uint8Array, size: number): Uint8Array {
62
+ let start = 0;
63
+ while (start < mpint.length && mpint[start] === 0x00) {
64
+ start++;
65
+ }
66
+ const value = mpint.subarray(start);
67
+ if (value.length === 0 || value.length > size) {
68
+ throw new Error("invalid ECDSA signature component");
69
+ }
70
+ const out = new Uint8Array(size);
71
+ out.set(value, size - value.length);
72
+ return out;
73
+ }
74
+
6
75
  /**
7
76
  * Parse bytes into an SSH signature object.
8
77
  *
@@ -39,26 +108,16 @@ export function parse(signature: DataView | string): Sig {
39
108
  const sig_algo = raw_signature.readString().toString();
40
109
  const sig_bytes = raw_signature.readString();
41
110
  let bytes;
42
- if (
43
- sig_algo === "ecdsa-sha2-nistp256" || sig_algo === "ecdsa-sha2-nistp384" ||
44
- sig_algo === "ecdsa-sha2-nistp512" ||
45
- sig_algo === "sk-ecdsa-sha2-nistp256@openssh.com"
46
- ) {
47
- let r = new Uint8Array(sig_bytes.readString().bytes());
48
- if (r.length === 0) {
49
- throw new Error("invalid ECDSA signature component")
50
- }
51
- if (r[0] === 0x00 && r.length % 2 == 1) {
52
- r = r.slice(1);
53
- }
54
- let s = new Uint8Array(sig_bytes.readString().bytes());
55
- if (s.length === 0) {
56
- throw new Error("invalid ECDSA signature component")
57
- }
58
- if (s[0] === 0x00 && s.length % 2 == 1) {
59
- s = s.slice(1);
60
- }
61
- bytes = new Uint8Array([...r, ...s]).buffer as ArrayBuffer;
111
+ if (isEcdsaSigAlgo(sig_algo)) {
112
+ // SSH encodes r and s as minimal-length mpints, but WebCrypto expects
113
+ // the fixed-width concatenation r || s
114
+ const size = ecdsaComponentSize(sig_algo);
115
+ const r = toFixedWidth(new Uint8Array(sig_bytes.readString().bytes()), size);
116
+ const s = toFixedWidth(new Uint8Array(sig_bytes.readString().bytes()), size);
117
+ const raw = new Uint8Array(size * 2);
118
+ raw.set(r, 0);
119
+ raw.set(s, size);
120
+ bytes = raw.buffer;
62
121
  } else {
63
122
  bytes = sig_bytes.bytes();
64
123
  }
package/src/utils.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { SshCaBindings } from "./types"
2
- import { JWKInvalid, JWKSInvalid, JWSInvalid, JWTClaimValidationFailed, JWTInvalid } from "jose/errors"
2
+ import { JWKInvalid, JWKSInvalid, JWSInvalid, JWSSignatureVerificationFailed, JWTClaimValidationFailed, JWTExpired, JWTInvalid } from "jose/errors"
3
3
  import { Certificate, Key, KeyParseError, CertificateParseError, parseCertificate, parseKey, parsePrivateKey, PrivateKey } from "sshpk"
4
4
  import z from "zod"
5
5
  import { verifyJWT } from "./verify"
@@ -8,6 +8,7 @@ import { RenewalProofOfPossession, ProofOfPossession, PossessionParseError } fro
8
8
  import type { isRevoked as IsRevokedFn } from "./db"
9
9
  import { logger } from "./logger"
10
10
  import { ms } from "itty-time"
11
+ import { InternalServerErrorException } from "chanfana"
11
12
 
12
13
  export const fatalIssue = (ctx: z.RefinementCtx, message: string, val: unknown) => {
13
14
  ctx.issues.push({
@@ -87,7 +88,11 @@ export const transformAuthorizationHeader = async (env: SshCaBindings, val: stri
87
88
  case (err instanceof JWKSInvalid):
88
89
  return fatalIssue(ctx, "the access token JWKS was invalid", val)
89
90
  case (err instanceof JWTClaimValidationFailed):
90
- return fatalIssue(ctx, "claim validtion of the JWT failed", val)
91
+ return fatalIssue(ctx, "claim validation of the JWT failed", val)
92
+ case (err instanceof JWTExpired):
93
+ return fatalIssue(ctx, "the access token has expired", val)
94
+ case (err instanceof JWSSignatureVerificationFailed):
95
+ return fatalIssue(ctx, "the access token signature verification failed", val)
91
96
  default:
92
97
  l.error("unhandled access token validation error", "in", "transformAuthorizationHeader", "error", err)
93
98
  return fatalIssue(ctx, "unhandled access token validation error", val)
@@ -128,6 +133,21 @@ export const transformPublicKey = (val: string | Buffer<ArrayBufferLike>, ctx: z
128
133
  }
129
134
  }
130
135
 
136
+ /**
137
+ * Thrown by {@link identityPrincipals} when the principals claim of an
138
+ * identity token is not a string or an array of strings.
139
+ */
140
+ export class PrincipalsClaimError extends Error {
141
+ constructor(message: string) {
142
+ super(message)
143
+ this.name = "PrincipalsClaimError"
144
+
145
+ Object.setPrototypeOf(this, PrincipalsClaimError.prototype)
146
+ }
147
+ }
148
+
149
+ const PrincipalsClaimSchema = z.union([z.string(), z.array(z.string())]).nullish()
150
+
131
151
  export const identityPrincipals = (env: SshCaBindings, payload: CertificateRequestJWTPayload, claim?: string): string[] => {
132
152
  if (claim === undefined) {
133
153
  claim = env.JWT_SSH_CERTIFICATE_PRINCIPALS_CLAIM
@@ -135,13 +155,25 @@ export const identityPrincipals = (env: SshCaBindings, payload: CertificateReque
135
155
 
136
156
  const l = logger(env)
137
157
 
138
- const p = payload[claim]
158
+ // the token is verified but the claim type is set by the IdP
159
+ const parsed = PrincipalsClaimSchema.safeParse(payload[claim])
160
+ if (!parsed.success) {
161
+ l.warn("claim was not a string or an array of strings", "claim", claim)
162
+ throw new PrincipalsClaimError(`the ${claim} claim must be a string or an array of strings`)
163
+ }
164
+
165
+ const p = parsed.data
139
166
 
140
167
  if (p === undefined) {
141
168
  l.warn("claim was missing despite being set in CA config", "claim", claim)
142
169
  return []
143
170
  }
144
171
 
172
+ if (p === null) {
173
+ l.warn("claim was present but was null", "claim", claim)
174
+ return []
175
+ }
176
+
145
177
  if (p === "") {
146
178
  l.warn("claim was present but was an empty string", "claim", claim)
147
179
  return []
@@ -151,7 +183,7 @@ export const identityPrincipals = (env: SshCaBindings, payload: CertificateReque
151
183
  const principals = (typeof p === "string" ? [p] : p)
152
184
  .map((value) => value.replaceAll(" ", "_"))
153
185
 
154
- l.info("identity token included principals", "principals", principals)
186
+ l.debug("identity token included principals", "principals", principals)
155
187
 
156
188
  return principals
157
189
  }
@@ -182,10 +214,10 @@ export const transformIdentityToken = async (env: SshCaBindings, val: string, ct
182
214
  const identity = await parseIdentity(env, val, env.JWT_SSH_CERTIFICATE_PRINCIPALS_CLAIM)
183
215
 
184
216
  return identity
185
- } catch {
217
+ } catch (err) {
186
218
  ctx.issues.push({
187
219
  code: "custom",
188
- message: "problem parsing identity token",
220
+ message: err instanceof PrincipalsClaimError ? err.message : "problem parsing identity token",
189
221
  input: val
190
222
  })
191
223
  return z.NEVER
@@ -328,10 +360,16 @@ export const refineHostCertificateRenewal = async (env: SshCaBindings, isRevoked
328
360
  return fatalIssue(ctx, "the provided certificate was not signed by this CA", val)
329
361
  }
330
362
 
331
- // check certificate is not expired
332
- if (val.certificate.isExpired()) {
363
+ // check certificate is within its validity period, allowing for clock
364
+ // skew at the start as a certificate issued moments ago may have a
365
+ // validity start up to a second in the future
366
+ const now = Date.now()
367
+ if (now >= val.certificate.validUntil.getTime()) {
333
368
  return fatalIssue(ctx, "the provided certificate is expired", val)
334
369
  }
370
+ if (val.certificate.validFrom.getTime() > now + ms(env.CERTIFICATE_REQUEST_TIME_SKEW_MAX)) {
371
+ return fatalIssue(ctx, "the provided certificate is not yet valid", val)
372
+ }
335
373
 
336
374
  // ensure certificate presented for renewal is not revoked
337
375
  const serial = val.certificate.serial.readBigUInt64BE(0)
@@ -352,6 +390,11 @@ export const refineHostCertificateRenewal = async (env: SshCaBindings, isRevoked
352
390
 
353
391
  return z.NEVER
354
392
  } catch (err) {
393
+ // a misconfigured CA key is a server error, not a validation failure
394
+ if (err instanceof UnsupportedKeyError) {
395
+ l.error(err.message, "in", "refineHostCertificateRenewal")
396
+ throw new InternalServerErrorException(err.message)
397
+ }
355
398
  l.error("proof of possession verification unhandled error", "in", "refineHostCertificateRenewal", "error", err)
356
399
  return fatalIssue(ctx, "proof of possession verification unhandled error", val)
357
400
  }
@@ -394,13 +437,47 @@ export const split = (v?: string | string[]): string[] => {
394
437
  : v
395
438
  }
396
439
 
440
+ /**
441
+ * Thrown by {@link getPrivateKey} when the CA private key is of a type that
442
+ * cannot be used.
443
+ */
444
+ export class UnsupportedKeyError extends Error {
445
+ constructor(message: string) {
446
+ super(message)
447
+ this.name = "UnsupportedKeyError"
448
+
449
+ Object.setPrototypeOf(this, UnsupportedKeyError.prototype)
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Load and parse the CA private key from the `PRIVATE_KEY` secret.
455
+ *
456
+ * Only Ed25519 and ECDSA keys are supported. RSA keys are rejected because
457
+ * sshpk signs RSA certificates with SHA-1 (`ssh-rsa`), which OpenSSH 8.2 and
458
+ * later refuse by default, and because RSA keys in OpenSSH format are
459
+ * converted with invalid CRT parameters that the Workers runtime rejects.
460
+ *
461
+ * @throws KeyParseError when the secret cannot be parsed.
462
+ * @throws {@link UnsupportedKeyError} when the key is not Ed25519 or ECDSA.
463
+ */
464
+ export const getPrivateKey = async (env: SshCaBindings): Promise<PrivateKey> => {
465
+ // grab private key from secret store
466
+ const secret = await env.PRIVATE_KEY.get()
467
+
468
+ // parse it
469
+ const key = parsePrivateKey(secret)
470
+
471
+ if (key.type !== "ed25519" && key.type !== "ecdsa") {
472
+ throw new UnsupportedKeyError(`CA key type ${key.type} is not supported, the CA key must be Ed25519 or ECDSA`)
473
+ }
474
+
475
+ return key
476
+ }
477
+
397
478
  export const getPublic = async (env: SshCaBindings, key?: PrivateKey): Promise<Key> => {
398
479
  if (key === undefined) {
399
- // grab private key from secret store
400
- const secret = await env.PRIVATE_KEY.get()
401
-
402
- // parse it
403
- key = parsePrivateKey(secret)
480
+ key = await getPrivateKey(env)
404
481
  }
405
482
 
406
483
  // convert to a public key and add comment