@andrewheberle/serverless-ssh-ca 0.17.18 → 0.17.19
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.
- package/README.md +17 -0
- package/dist/api/v3/index.d.ts.map +1 -1
- package/dist/api/v3/index.js +24 -5
- package/dist/api/v3/index.js.map +1 -1
- package/dist/api/v3/schema.d.ts +3 -3
- package/dist/api/v3/schema.d.ts.map +1 -1
- package/dist/api/v3/schema.js +7 -2
- package/dist/api/v3/schema.js.map +1 -1
- package/dist/certificate.d.ts.map +1 -1
- package/dist/certificate.js +4 -8
- package/dist/certificate.js.map +1 -1
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +3 -2
- package/dist/db/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/krl.d.ts.map +1 -1
- package/dist/krl.js.map +1 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/proof.d.ts.map +1 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/sshsig/formats.d.ts.map +1 -1
- package/dist/sshsig/reader.d.ts.map +1 -1
- package/dist/sshsig/reader.js.map +1 -1
- package/dist/sshsig/sig_parser.d.ts +30 -0
- package/dist/sshsig/sig_parser.d.ts.map +1 -1
- package/dist/sshsig/sig_parser.js +66 -18
- package/dist/sshsig/sig_parser.js.map +1 -1
- package/dist/sshsig/writer.d.ts.map +1 -1
- package/dist/utils.d.ts +26 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +79 -12
- package/dist/utils.js.map +1 -1
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js.map +1 -1
- package/package.json +3 -3
- package/src/api/v3/index.ts +24 -5
- package/src/api/v3/schema.ts +7 -2
- package/src/certificate.ts +3 -10
- package/src/db/index.ts +3 -2
- package/src/sshsig/sig_parser.ts +79 -20
- package/src/utils.ts +90 -13
package/src/sshsig/sig_parser.ts
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
332
|
-
|
|
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
|
-
|
|
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
|