@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.
- package/README.md +55 -1
- package/dist/api/operations.d.ts +49 -0
- package/dist/api/operations.d.ts.map +1 -0
- package/dist/api/operations.js +175 -0
- package/dist/api/operations.js.map +1 -0
- package/dist/api/schema.d.ts +71 -0
- package/dist/api/schema.d.ts.map +1 -0
- package/dist/api/schema.js +107 -0
- package/dist/api/schema.js.map +1 -0
- package/dist/api/v3/index.d.ts.map +1 -1
- package/dist/api/v3/index.js +13 -202
- package/dist/api/v3/index.js.map +1 -1
- package/dist/api/v3/schema.d.ts +23 -68
- package/dist/api/v3/schema.d.ts.map +1 -1
- package/dist/api/v3/schema.js +10 -100
- package/dist/api/v3/schema.js.map +1 -1
- package/dist/api/v4/index.d.ts +8 -0
- package/dist/api/v4/index.d.ts.map +1 -0
- package/dist/api/v4/index.js +155 -0
- package/dist/api/v4/index.js.map +1 -0
- package/dist/api/v4/schema.d.ts +268 -0
- package/dist/api/v4/schema.d.ts.map +1 -0
- package/dist/api/v4/schema.js +182 -0
- package/dist/api/v4/schema.js.map +1 -0
- package/dist/certificate.d.ts +9 -6
- package/dist/certificate.d.ts.map +1 -1
- package/dist/certificate.js +55 -99
- package/dist/certificate.js.map +1 -1
- package/dist/db/index.d.ts +17 -5
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +39 -23
- package/dist/db/index.js.map +1 -1
- package/dist/krl.d.ts +1 -1
- package/dist/krl.d.ts.map +1 -1
- package/dist/krl.js +9 -163
- package/dist/krl.js.map +1 -1
- package/dist/proof.d.ts +36 -7
- package/dist/proof.d.ts.map +1 -1
- package/dist/proof.js +53 -21
- package/dist/proof.js.map +1 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +3 -1
- package/dist/router.js.map +1 -1
- package/dist/ssh/certificate.d.ts +93 -0
- package/dist/ssh/certificate.d.ts.map +1 -0
- package/dist/ssh/certificate.js +178 -0
- package/dist/ssh/certificate.js.map +1 -0
- package/dist/ssh/encoding.d.ts +41 -0
- package/dist/ssh/encoding.d.ts.map +1 -0
- package/dist/ssh/encoding.js +70 -0
- package/dist/ssh/encoding.js.map +1 -0
- package/dist/ssh/keys.d.ts +80 -0
- package/dist/ssh/keys.d.ts.map +1 -0
- package/dist/ssh/keys.js +249 -0
- package/dist/ssh/keys.js.map +1 -0
- package/dist/ssh/signer.d.ts +35 -0
- package/dist/ssh/signer.d.ts.map +1 -0
- package/dist/ssh/signer.js +99 -0
- package/dist/ssh/signer.js.map +1 -0
- package/dist/types.d.ts +12 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +40 -21
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +87 -54
- package/dist/utils.js.map +1 -1
- package/openapi.json +1542 -1
- package/package.json +4 -3
- package/src/api/operations.ts +211 -0
- package/src/api/schema.ts +137 -0
- package/src/api/v3/index.ts +20 -248
- package/src/api/v3/schema.ts +26 -125
- package/src/api/v4/index.ts +265 -0
- package/src/api/v4/schema.ts +235 -0
- package/src/certificate.ts +62 -123
- package/src/db/index.ts +41 -26
- package/src/krl.ts +10 -195
- package/src/proof.ts +65 -31
- package/src/router.ts +3 -1
- package/src/ssh/certificate.ts +236 -0
- package/src/ssh/encoding.ts +81 -0
- package/src/ssh/keys.ts +294 -0
- package/src/ssh/signer.ts +129 -0
- package/src/types.ts +12 -18
- 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
|
|
4
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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):
|
|
245
|
+
export const transformCertificate = (val: string | Buffer<ArrayBufferLike>, ctx: z.RefinementCtx): SshCertificate | never => {
|
|
238
246
|
try {
|
|
239
|
-
|
|
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:
|
|
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:
|
|
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(
|
|
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:
|
|
360
|
+
certificate: SshCertificate
|
|
355
361
|
proof: RenewalProofOfPossession
|
|
356
362
|
} & HostCertificateRequest
|
|
357
363
|
|
|
358
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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.
|
|
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:
|
|
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
|
|
461
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|