@andrewheberle/serverless-ssh-ca 0.17.5

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 (81) hide show
  1. package/dist/api/v3/index.d.ts +2 -0
  2. package/dist/api/v3/index.js +278 -0
  3. package/dist/api/v3/schema.d.ts +218 -0
  4. package/dist/api/v3/schema.js +207 -0
  5. package/dist/certificate.d.ts +30 -0
  6. package/dist/certificate.js +154 -0
  7. package/dist/db.d.ts +36 -0
  8. package/dist/db.js +111 -0
  9. package/dist/index.d.ts +7 -0
  10. package/dist/index.js +14 -0
  11. package/dist/krl.d.ts +39 -0
  12. package/dist/krl.js +317 -0
  13. package/dist/logger.d.ts +3 -0
  14. package/dist/logger.js +2 -0
  15. package/dist/proof.d.ts +52 -0
  16. package/dist/proof.js +153 -0
  17. package/dist/router.d.ts +7 -0
  18. package/dist/router.js +24 -0
  19. package/dist/src/api/v3/index.d.ts +1 -0
  20. package/dist/src/api/v3/index.js +263 -0
  21. package/dist/src/api/v3/schema.d.ts +217 -0
  22. package/dist/src/api/v3/schema.js +216 -0
  23. package/dist/src/certificate.d.ts +30 -0
  24. package/dist/src/certificate.js +154 -0
  25. package/dist/src/db.d.ts +36 -0
  26. package/dist/src/db.js +111 -0
  27. package/dist/src/index.d.ts +6 -0
  28. package/dist/src/index.js +8 -0
  29. package/dist/src/krl.d.ts +39 -0
  30. package/dist/src/krl.js +317 -0
  31. package/dist/src/logger.d.ts +2 -0
  32. package/dist/src/logger.js +3 -0
  33. package/dist/src/proof.d.ts +51 -0
  34. package/dist/src/proof.js +154 -0
  35. package/dist/src/router.d.ts +14 -0
  36. package/dist/src/router.js +22 -0
  37. package/dist/src/sshsig/armor.d.ts +7 -0
  38. package/dist/src/sshsig/armor.js +18 -0
  39. package/dist/src/sshsig/formats.d.ts +52 -0
  40. package/dist/src/sshsig/formats.js +174 -0
  41. package/dist/src/sshsig/index.d.ts +15 -0
  42. package/dist/src/sshsig/index.js +31 -0
  43. package/dist/src/sshsig/reader.d.ts +20 -0
  44. package/dist/src/sshsig/reader.js +54 -0
  45. package/dist/src/sshsig/sig.d.ts +16 -0
  46. package/dist/src/sshsig/sig.js +1 -0
  47. package/dist/src/sshsig/sig_parser.d.ts +9 -0
  48. package/dist/src/sshsig/sig_parser.js +83 -0
  49. package/dist/src/sshsig/verifier.d.ts +2 -0
  50. package/dist/src/sshsig/verifier.js +37 -0
  51. package/dist/src/sshsig/writer.d.ts +17 -0
  52. package/dist/src/sshsig/writer.js +51 -0
  53. package/dist/src/types.d.ts +42 -0
  54. package/dist/src/types.js +20 -0
  55. package/dist/src/utils.d.ts +76 -0
  56. package/dist/src/utils.js +370 -0
  57. package/dist/src/verify.d.ts +6 -0
  58. package/dist/src/verify.js +17 -0
  59. package/dist/sshsig/armor.d.ts +7 -0
  60. package/dist/sshsig/armor.js +18 -0
  61. package/dist/sshsig/formats.d.ts +52 -0
  62. package/dist/sshsig/formats.js +174 -0
  63. package/dist/sshsig/index.d.ts +15 -0
  64. package/dist/sshsig/index.js +34 -0
  65. package/dist/sshsig/reader.d.ts +20 -0
  66. package/dist/sshsig/reader.js +54 -0
  67. package/dist/sshsig/sig.d.ts +16 -0
  68. package/dist/sshsig/sig.js +1 -0
  69. package/dist/sshsig/sig_parser.d.ts +9 -0
  70. package/dist/sshsig/sig_parser.js +83 -0
  71. package/dist/sshsig/verifier.d.ts +2 -0
  72. package/dist/sshsig/verifier.js +37 -0
  73. package/dist/sshsig/writer.d.ts +17 -0
  74. package/dist/sshsig/writer.js +51 -0
  75. package/dist/types.d.ts +42 -0
  76. package/dist/types.js +1 -0
  77. package/dist/utils.d.ts +59 -0
  78. package/dist/utils.js +323 -0
  79. package/dist/verify.d.ts +7 -0
  80. package/dist/verify.js +16 -0
  81. package/package.json +58 -0
package/dist/utils.js ADDED
@@ -0,0 +1,323 @@
1
+ import { JWKInvalid, JWKSInvalid, JWSInvalid, JWTClaimValidationFailed, JWTInvalid } from "jose/errors";
2
+ import { KeyParseError, CertificateParseError, parseCertificate, parseKey, parsePrivateKey } from "sshpk";
3
+ import z from "zod";
4
+ import { verifyJWT } from "./verify";
5
+ import { RenewalProofOfPossession, ProofOfPossession, PossessionParseError } from "./proof";
6
+ import { isRevoked } from "./db";
7
+ import { logger } from "./logger";
8
+ import { ms } from "itty-time";
9
+ export const fatalIssue = (ctx, message, val) => {
10
+ ctx.issues.push({
11
+ code: "custom",
12
+ message: message,
13
+ input: val
14
+ });
15
+ return z.NEVER;
16
+ };
17
+ export const transformProofOfPossession = (env, val, ctx) => {
18
+ const l = logger(env);
19
+ try {
20
+ const proof = new ProofOfPossession(val, { logger: l, skew: ms(env.CERTIFICATE_REQUEST_TIME_SKEW_MAX) });
21
+ return proof;
22
+ }
23
+ catch (err) {
24
+ l.error("proof of possession parsing error", "error", err);
25
+ switch (true) {
26
+ case (err instanceof PossessionParseError):
27
+ ctx.issues.push({
28
+ code: "custom",
29
+ message: err.message,
30
+ input: val
31
+ });
32
+ break;
33
+ default:
34
+ ctx.issues.push({
35
+ code: "custom",
36
+ message: "proof of possession transform unhandled error",
37
+ input: val
38
+ });
39
+ }
40
+ return z.NEVER;
41
+ }
42
+ };
43
+ export const transformAuthorizationHeader = async (env, val, ctx) => {
44
+ const jwt = val.replace("Bearer ", "");
45
+ const l = logger(env);
46
+ if (jwt === "") {
47
+ return fatalIssue(ctx, "request did not contain a JWT", val);
48
+ }
49
+ try {
50
+ // verify jwt
51
+ const { payload } = await verifyJWT(env, jwt);
52
+ if (payload.email === undefined) {
53
+ return fatalIssue(ctx, "JWT was verified but was missing required email claim", val);
54
+ }
55
+ return {
56
+ email: payload.email,
57
+ sub: payload.sub,
58
+ };
59
+ }
60
+ catch (err) {
61
+ switch (true) {
62
+ case (err instanceof JWSInvalid):
63
+ return fatalIssue(ctx, "the access token was invalid", val);
64
+ case (err instanceof JWTInvalid):
65
+ return fatalIssue(ctx, "the access token failed verification", val);
66
+ case (err instanceof JWKInvalid):
67
+ return fatalIssue(ctx, "the access token JWK was invalid", val);
68
+ case (err instanceof JWKSInvalid):
69
+ return fatalIssue(ctx, "the access token JWKS was invalid", val);
70
+ case (err instanceof JWTClaimValidationFailed):
71
+ return fatalIssue(ctx, "claim validtion of the JWT failed", val);
72
+ default:
73
+ l.error("unhandled access token validation error", "in", "transformAuthorizationHeader", "error", err);
74
+ return fatalIssue(ctx, "unhandled access token validation error", val);
75
+ }
76
+ }
77
+ };
78
+ export const transformPublicKey = (val, ctx) => {
79
+ try {
80
+ const key = typeof val === "string" ? parseKey(Buffer.from(val, "base64")) : parseKey(val);
81
+ return key;
82
+ }
83
+ catch (err) {
84
+ switch (true) {
85
+ case (err instanceof TypeError):
86
+ ctx.issues.push({
87
+ code: "custom",
88
+ message: err.message,
89
+ input: val
90
+ });
91
+ break;
92
+ case (err instanceof KeyParseError):
93
+ ctx.issues.push({
94
+ code: "custom",
95
+ message: err.message,
96
+ input: val
97
+ });
98
+ break;
99
+ default:
100
+ ctx.issues.push({
101
+ code: "custom",
102
+ message: "unhandled error parsing public_key",
103
+ input: val
104
+ });
105
+ }
106
+ return z.NEVER;
107
+ }
108
+ };
109
+ export const identityPrincipals = (env, payload, claim) => {
110
+ if (claim === undefined) {
111
+ claim = env.JWT_SSH_CERTIFICATE_PRINCIPALS_CLAIM;
112
+ }
113
+ const l = logger(env);
114
+ if (claim === "__proto__" || claim === "prototype" || claim === "constructor") {
115
+ l.warn("invalid principals claim configured", "claim", claim);
116
+ return [];
117
+ }
118
+ const p = payload[claim];
119
+ if (p === undefined) {
120
+ l.warn("claim was missing despite being set in CA config", "claim", claim);
121
+ return [];
122
+ }
123
+ if (p === "") {
124
+ l.warn("claim was present but was an empty string", "claim", claim);
125
+ return [];
126
+ }
127
+ // make sure its always a string[] and replace any spaces with underscores
128
+ const principals = (typeof p === "string" ? [p] : p)
129
+ .map((value) => value.replaceAll(" ", "_"));
130
+ l.info("identity token included principals", "principals", principals);
131
+ return principals;
132
+ };
133
+ export const parseIdentity = async (env, jwt, claim) => {
134
+ if (jwt === undefined) {
135
+ throw new Error("missing identity token");
136
+ }
137
+ const aud = env.JWT_AUD === undefined || env.JWT_AUD === "" ? undefined : split(env.JWT_AUD);
138
+ const { payload } = await verifyJWT(env, jwt, { aud: aud });
139
+ const principals = identityPrincipals(env, payload, claim);
140
+ return {
141
+ sub: payload.sub,
142
+ principals: principals,
143
+ };
144
+ };
145
+ export const transformIdentityToken = async (env, val, ctx) => {
146
+ try {
147
+ const identity = await parseIdentity(env, val, env.JWT_SSH_CERTIFICATE_PRINCIPALS_CLAIM);
148
+ return identity;
149
+ }
150
+ catch (err) {
151
+ ctx.issues.push({
152
+ code: "custom",
153
+ message: "problem parsing identity token",
154
+ input: val
155
+ });
156
+ return z.NEVER;
157
+ }
158
+ };
159
+ /**
160
+ *
161
+ * @param val A SSH certificate either a base64 encoded "string" or a "Buffer\<ArrayBufferLike\>"
162
+ * @param ctx The Zod context for any errors
163
+ * @returns
164
+ */
165
+ export const transformCertificate = (val, ctx) => {
166
+ try {
167
+ const cert = typeof val === "string" ? parseCertificate(Buffer.from(val, "base64"), "openssh") : parseCertificate(val, "openssh");
168
+ return cert;
169
+ }
170
+ catch (err) {
171
+ switch (true) {
172
+ case (err instanceof TypeError):
173
+ ctx.issues.push({
174
+ code: "custom",
175
+ message: err.message,
176
+ input: val
177
+ });
178
+ break;
179
+ case (err instanceof CertificateParseError):
180
+ ctx.issues.push({
181
+ code: "custom",
182
+ message: err.message,
183
+ input: val
184
+ });
185
+ break;
186
+ default:
187
+ ctx.issues.push({
188
+ code: "custom",
189
+ message: "unhandled error parsing certificate",
190
+ input: val
191
+ });
192
+ }
193
+ return z.NEVER;
194
+ }
195
+ };
196
+ export const refineCertificateRequest = async (env, val, ctx) => {
197
+ const l = logger(env);
198
+ try {
199
+ // check proof of possession fingerprint matches public key
200
+ if (!val.proof.matches(val.public_key)) {
201
+ return fatalIssue(ctx, "proof of possession fingerprint did not match public_key", val);
202
+ }
203
+ // verify proof of possession signature
204
+ const verified = await val.proof.verify();
205
+ if (!verified) {
206
+ return fatalIssue(ctx, "proof of possession signature validation failed", val);
207
+ }
208
+ return z.NEVER;
209
+ }
210
+ catch (err) {
211
+ l.error("proof of possession verification unhandled error", "in", "refineCertificateRequest", "error", err);
212
+ return fatalIssue(ctx, "proof of possession verification unhandled error", val);
213
+ }
214
+ };
215
+ export const transformRenewalProofOfPossession = (env, val, ctx) => {
216
+ const l = logger(env);
217
+ try {
218
+ return new RenewalProofOfPossession(val, { logger: l, skew: ms(env.CERTIFICATE_REQUEST_TIME_SKEW_MAX) });
219
+ }
220
+ catch (err) {
221
+ if (err instanceof PossessionParseError) {
222
+ return fatalIssue(ctx, err.message, val);
223
+ }
224
+ l.error("proof of possession verification unhandled error", "in", "transformRenewalProofOfPossession", "error", err);
225
+ return fatalIssue(ctx, "proof of possession transform unhandled error", val);
226
+ }
227
+ };
228
+ export const refineHostCertificateRequest = async (env, val, ctx) => {
229
+ const l = logger(env).with("in", "refineHostCertificateRequest");
230
+ l.debug("started");
231
+ try {
232
+ // check proof of possession fingerprint matches public key
233
+ l.debug("checking val.proof.matches()", "public_key", val.public_key.toString("ssh"));
234
+ if (!val.proof.matches(val.public_key)) {
235
+ l.warn("proof of possession fingerprint did not match public_key");
236
+ return fatalIssue(ctx, "proof of possession fingerprint did not match public_key", val);
237
+ }
238
+ // verify proof of possession signature
239
+ l.debug("await val.proof.verify()");
240
+ const verified = await val.proof.verify();
241
+ if (!verified) {
242
+ l.warn("proof of possession signature validation failed");
243
+ return fatalIssue(ctx, "proof of possession signature validation failed", val);
244
+ }
245
+ l.debug("done");
246
+ return z.NEVER;
247
+ }
248
+ catch (err) {
249
+ l.error("proof of possession verification unhandled error", "error", err);
250
+ return fatalIssue(ctx, "proof of possession verification unhandled error", val);
251
+ }
252
+ };
253
+ export const refineHostCertificateRenewal = async (env, val, ctx) => {
254
+ const l = logger(env);
255
+ try {
256
+ // ensure certificate is signed by CA
257
+ const issuer = await getPublic(env);
258
+ if (!val.certificate.isSignedByKey(issuer)) {
259
+ return fatalIssue(ctx, "the provided certificate was not signed by this CA", val);
260
+ }
261
+ // check certificate is not expired
262
+ if (val.certificate.isExpired()) {
263
+ return fatalIssue(ctx, "the provided certificate is expired", val);
264
+ }
265
+ // ensure certificate presented for renewal is not revoked
266
+ const serial = val.certificate.serial.readBigUInt64BE(0);
267
+ if (await isRevoked(env, serial)) {
268
+ return fatalIssue(ctx, "the provided certificate is revoked", val);
269
+ }
270
+ // check proof of possession fingerprint matches public key and certificate subject key
271
+ if (!val.proof.matches(val.public_key, val.certificate.subjectKey)) {
272
+ return fatalIssue(ctx, "proof of possession fingerprints did not match public_key and/or certificate", val);
273
+ }
274
+ // verify proof of possession signature
275
+ const verified = await val.proof.verify();
276
+ if (!verified) {
277
+ return fatalIssue(ctx, "proof of possession signature validation failed", val);
278
+ }
279
+ return z.NEVER;
280
+ }
281
+ catch (err) {
282
+ l.error("proof of possession verification unhandled error", "in", "refineHostCertificateRenewal", "error", err);
283
+ return fatalIssue(ctx, "proof of possession verification unhandled error", val);
284
+ }
285
+ };
286
+ export const refineRevokeCertificate = async (env, val, ctx) => {
287
+ const l = logger(env);
288
+ try {
289
+ // check proof of possession fingerprint matches public key
290
+ if (!val.proof.matches(val.public_key)) {
291
+ return fatalIssue(ctx, "proof of possession fingerprint did not match public_key", val);
292
+ }
293
+ // verify proof of possession signature
294
+ const verified = await val.proof.verify();
295
+ if (!verified) {
296
+ return fatalIssue(ctx, "proof of possession signature validation failed", val);
297
+ }
298
+ return z.NEVER;
299
+ }
300
+ catch (err) {
301
+ l.error("proof of possession verification unhandled error", "in", "refineRevokeCertificate", "error", err);
302
+ return fatalIssue(ctx, "proof of possession verification unhandled error", val);
303
+ }
304
+ };
305
+ export const split = (v) => {
306
+ if (v === "" || v === undefined) {
307
+ return [];
308
+ }
309
+ return v.split(",");
310
+ };
311
+ export const getPublic = async (env, key) => {
312
+ if (key === undefined) {
313
+ // grab private key from secret store
314
+ const secret = await env.PRIVATE_KEY.get();
315
+ // parse it
316
+ key = parsePrivateKey(secret);
317
+ }
318
+ // convert to a public key and add comment
319
+ const pub = key.toPublic();
320
+ pub.comment = env.ISSUER_DN;
321
+ // return in ssh format trimmed of any whitespace
322
+ return pub;
323
+ };
@@ -0,0 +1,7 @@
1
+ import { CertificateRequestJWTPayload } from "./types";
2
+ import type { SshCaBindings } from "./types";
3
+ type VerifyOptions = {
4
+ aud?: string | string[];
5
+ };
6
+ export declare const verifyJWT: (env: SshCaBindings, jwt: string, opts?: VerifyOptions) => Promise<import("jose").JWTVerifyResult<CertificateRequestJWTPayload> & import("jose").ResolvedKey>;
7
+ export {};
package/dist/verify.js ADDED
@@ -0,0 +1,16 @@
1
+ import { createRemoteJWKSet, jwtVerify } from "jose";
2
+ import { split } from "./utils";
3
+ export const verifyJWT = async (env, jwt, opts = {}) => {
4
+ const JWKS = createRemoteJWKSet(new URL(env.JWT_JWKS_URL));
5
+ if (opts.aud !== undefined) {
6
+ return await jwtVerify(jwt, JWKS, {
7
+ issuer: env.JWT_ISSUER,
8
+ algorithms: split(env.JWT_ALGORITHMS),
9
+ audience: opts.aud
10
+ });
11
+ }
12
+ return await jwtVerify(jwt, JWKS, {
13
+ issuer: env.JWT_ISSUER,
14
+ algorithms: split(env.JWT_ALGORITHMS),
15
+ });
16
+ };
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@andrewheberle/serverless-ssh-ca",
3
+ "version": "0.17.5",
4
+ "description": "A serverless SSH CA built on Cloudflare Workers",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "test": "vitest run",
20
+ "test:watch": "vitest",
21
+ "prepublishOnly": "npm run build",
22
+ "release": "release-it"
23
+ },
24
+ "dependencies": {
25
+ "@andrewheberle/ts-slog": "^1.3.0",
26
+ "@cloudflare/actors": "^0.0.1-beta.6",
27
+ "chanfana": "^3.0.0",
28
+ "hono": "^4.10.6",
29
+ "itty-time": "^2.0.2",
30
+ "jose": "^6.0.12",
31
+ "sshpk": "^1.18.0",
32
+ "zod": "^4.0.0"
33
+ },
34
+ "devDependencies": {
35
+ "@cloudflare/workers-types": "^4.20260528.1",
36
+ "@types/sshpk": "^1.17.4",
37
+ "release-it": "^20.0.1",
38
+ "tsx": "^4.21.0",
39
+ "typescript": "^6.0.0"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/andrewheberle/serverless-ssh-ca.git",
44
+ "directory": "packages/ssh-ca"
45
+ },
46
+ "homepage": "https://github.com/andrewheberle/serverless-ssh-ca#readme",
47
+ "bugs": {
48
+ "url": "https://github.com/andrewheberle/serverless-ssh-ca/issues"
49
+ },
50
+ "author": "Andrew Heberle",
51
+ "license": "Apache-2.0",
52
+ "release-it": {
53
+ "git": false,
54
+ "npm": {
55
+ "skipChecks": true
56
+ }
57
+ }
58
+ }