@andrewheberle/serverless-ssh-ca 0.17.12 → 0.17.15

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 (114) hide show
  1. package/README.md +6 -17
  2. package/dist/api/v3/index.d.ts +1 -0
  3. package/dist/api/v3/index.d.ts.map +1 -0
  4. package/dist/api/v3/index.js +1 -0
  5. package/dist/api/v3/index.js.map +1 -0
  6. package/dist/api/v3/schema.d.ts +9 -0
  7. package/dist/api/v3/schema.d.ts.map +1 -0
  8. package/dist/api/v3/schema.js +4 -4
  9. package/dist/api/v3/schema.js.map +1 -0
  10. package/dist/certificate.d.ts +1 -0
  11. package/dist/certificate.d.ts.map +1 -0
  12. package/dist/certificate.js +1 -0
  13. package/dist/certificate.js.map +1 -0
  14. package/dist/{db.d.ts → db/index.d.ts} +6 -6
  15. package/dist/db/index.d.ts.map +1 -0
  16. package/dist/db/index.js +151 -0
  17. package/dist/db/index.js.map +1 -0
  18. package/dist/db/migrations/0001_initial_schema.d.ts +3 -0
  19. package/dist/db/migrations/0001_initial_schema.d.ts.map +1 -0
  20. package/dist/db/migrations/0001_initial_schema.js +24 -0
  21. package/dist/db/migrations/0001_initial_schema.js.map +1 -0
  22. package/dist/db/migrations/index.d.ts +3 -0
  23. package/dist/db/migrations/index.d.ts.map +1 -0
  24. package/dist/db/migrations/index.js +5 -0
  25. package/dist/db/migrations/index.js.map +1 -0
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +1 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/krl.d.ts +1 -0
  31. package/dist/krl.d.ts.map +1 -0
  32. package/dist/krl.js +1 -0
  33. package/dist/krl.js.map +1 -0
  34. package/dist/logger.d.ts +1 -0
  35. package/dist/logger.d.ts.map +1 -0
  36. package/dist/logger.js +1 -0
  37. package/dist/logger.js.map +1 -0
  38. package/dist/proof.d.ts +2 -0
  39. package/dist/proof.d.ts.map +1 -0
  40. package/dist/proof.js +2 -1
  41. package/dist/proof.js.map +1 -0
  42. package/dist/router.d.ts +1 -0
  43. package/dist/router.d.ts.map +1 -0
  44. package/dist/router.js +1 -0
  45. package/dist/router.js.map +1 -0
  46. package/dist/sshsig/armor.d.ts +1 -0
  47. package/dist/sshsig/armor.d.ts.map +1 -0
  48. package/dist/sshsig/armor.js +1 -0
  49. package/dist/sshsig/armor.js.map +1 -0
  50. package/dist/sshsig/formats.d.ts +1 -0
  51. package/dist/sshsig/formats.d.ts.map +1 -0
  52. package/dist/sshsig/formats.js +1 -0
  53. package/dist/sshsig/formats.js.map +1 -0
  54. package/dist/sshsig/index.d.ts +1 -0
  55. package/dist/sshsig/index.d.ts.map +1 -0
  56. package/dist/sshsig/index.js +1 -0
  57. package/dist/sshsig/index.js.map +1 -0
  58. package/dist/sshsig/reader.d.ts +1 -0
  59. package/dist/sshsig/reader.d.ts.map +1 -0
  60. package/dist/sshsig/reader.js +1 -0
  61. package/dist/sshsig/reader.js.map +1 -0
  62. package/dist/sshsig/sig.d.ts +1 -0
  63. package/dist/sshsig/sig.d.ts.map +1 -0
  64. package/dist/sshsig/sig.js +1 -0
  65. package/dist/sshsig/sig.js.map +1 -0
  66. package/dist/sshsig/sig_parser.d.ts +1 -0
  67. package/dist/sshsig/sig_parser.d.ts.map +1 -0
  68. package/dist/sshsig/sig_parser.js +1 -0
  69. package/dist/sshsig/sig_parser.js.map +1 -0
  70. package/dist/sshsig/verifier.d.ts +1 -0
  71. package/dist/sshsig/verifier.d.ts.map +1 -0
  72. package/dist/sshsig/verifier.js +1 -0
  73. package/dist/sshsig/verifier.js.map +1 -0
  74. package/dist/sshsig/writer.d.ts +1 -0
  75. package/dist/sshsig/writer.d.ts.map +1 -0
  76. package/dist/sshsig/writer.js +1 -0
  77. package/dist/sshsig/writer.js.map +1 -0
  78. package/dist/types.d.ts +1 -0
  79. package/dist/types.d.ts.map +1 -0
  80. package/dist/types.js +1 -0
  81. package/dist/types.js.map +1 -0
  82. package/dist/utils.d.ts +1 -0
  83. package/dist/utils.d.ts.map +1 -0
  84. package/dist/utils.js +4 -0
  85. package/dist/utils.js.map +1 -0
  86. package/dist/verify.d.ts +1 -0
  87. package/dist/verify.d.ts.map +1 -0
  88. package/dist/verify.js +1 -0
  89. package/dist/verify.js.map +1 -0
  90. package/package.json +16 -8
  91. package/src/api/v3/index.ts +419 -0
  92. package/src/api/v3/schema.ts +265 -0
  93. package/src/certificate.ts +214 -0
  94. package/src/db/index.ts +194 -0
  95. package/src/db/migrations/0001_initial_schema.ts +25 -0
  96. package/src/db/migrations/index.ts +6 -0
  97. package/src/index.ts +18 -0
  98. package/src/krl.ts +414 -0
  99. package/src/logger.ts +6 -0
  100. package/src/proof.ts +187 -0
  101. package/src/router.ts +35 -0
  102. package/src/sshsig/armor.ts +24 -0
  103. package/src/sshsig/formats.ts +226 -0
  104. package/src/sshsig/index.ts +47 -0
  105. package/src/sshsig/reader.ts +59 -0
  106. package/src/sshsig/sig.ts +17 -0
  107. package/src/sshsig/sig_parser.ts +90 -0
  108. package/src/sshsig/verifier.ts +85 -0
  109. package/src/sshsig/writer.ts +56 -0
  110. package/src/types.ts +49 -0
  111. package/src/utils.ts +414 -0
  112. package/src/verify.ts +25 -0
  113. package/LICENSE +0 -201
  114. package/dist/db.js +0 -111
@@ -0,0 +1,214 @@
1
+ import { seconds } from "itty-time"
2
+ import { Certificate, createCertificate, Identity, identityForHost, identityForUser, identityFromDN, Key, parsePrivateKey, PrivateKey } from "sshpk"
3
+ import { SSHExtension } from "./types"
4
+ import { split } from "./utils"
5
+ import type { SshCaBindings } from "./types"
6
+
7
+ // const sshCertificateExtensions = split(env.SSH_CERTIFICATE_EXTENSIONS)
8
+
9
+ export type CreateCertificateOptions = {
10
+ lifetime?: number
11
+ principals?: string[]
12
+ extensions?: string[]
13
+ }
14
+
15
+ /* const DefaultCreateCertificateOptions: CreateCertificateOptions = {
16
+ lifetime: seconds(env.SSH_CERTIFICATE_LIFETIME),
17
+ principals: [],
18
+ extensions: sshCertificateExtensions
19
+ } */
20
+
21
+ export class CertificateError extends Error {
22
+ constructor(message: string) {
23
+ super(message)
24
+ this.name = "CertificateError"
25
+
26
+ // This is necessary for proper stack trace in TypeScript
27
+ Object.setPrototypeOf(this, CertificateError.prototype)
28
+ }
29
+ }
30
+
31
+ export type GenerateCertificateOptions = {
32
+ /**
33
+ * @internal
34
+ * Override the certificate serial number.
35
+ * Should only be set in tests.
36
+ */
37
+ serial?: bigint
38
+ } & CreateCertificateOptions
39
+
40
+ export const generateCertificate = (env: SshCaBindings, email: string, key: PrivateKey, public_key: Key, options?: GenerateCertificateOptions): Certificate => {
41
+ let { lifetime, principals, extensions, serial } = options ?? {}
42
+
43
+ // add identities
44
+ const identity = env.SSH_CERTIFICATE_INCLUDE_SELF as string === "true"
45
+ ? [identityForUser(email.split("@")[0]!)]
46
+ : []
47
+ if (principals !== undefined) {
48
+ for (const p of principals) {
49
+ identity.push(identityForUser(p))
50
+ }
51
+ }
52
+ if (env.SSH_CERTIFICATE_PRINCIPALS as string !== "") {
53
+ for (const p of split(env.SSH_CERTIFICATE_PRINCIPALS)) {
54
+ identity.push(identityForUser(p))
55
+ }
56
+ }
57
+
58
+ // lifetime is the smaller of what was provided in the options or the default
59
+ lifetime = lifetime !== undefined
60
+ ? Math.min(lifetime, seconds(env.SSH_CERTIFICATE_LIFETIME))
61
+ : seconds(env.SSH_CERTIFICATE_LIFETIME)
62
+
63
+ // generate value for serial of certificate
64
+ const s = generateSerial(serial)
65
+
66
+ // set issuer of certificate based on ISSUER_DN
67
+ const issuer = identityFromDN(env.ISSUER_DN)
68
+
69
+ // create certificate
70
+ const certificate = createCertificate(identity, public_key, issuer, key, { lifetime: lifetime, serial: s })
71
+
72
+ // ensure openssh info is included in certificate (should not occur)
73
+ if (certificate.signatures.openssh === undefined) {
74
+ throw new CertificateError("missing openssh information in certificate")
75
+ }
76
+
77
+ const sshCertificateExtensions = split(env.SSH_CERTIFICATE_EXTENSIONS)
78
+
79
+ // add usage extensions
80
+ const sshextensions: SSHExtension[] = []
81
+ if (extensions !== undefined) {
82
+ // if extensions are provided in request, ensure they do not include extra extensions beyond the defaults
83
+ for (const ext of extensions) {
84
+ if (!sshCertificateExtensions.includes(ext)) {
85
+ throw new CertificateError(`${ext} is not allowed`)
86
+ }
87
+
88
+ // add to list of allowed extensions
89
+ sshextensions.push({
90
+ critical: false,
91
+ name: ext,
92
+ data: Buffer.alloc(0)
93
+ })
94
+ }
95
+ } else {
96
+ // use defaults if not provided
97
+ sshCertificateExtensions.forEach((ext: string) => {
98
+ sshextensions.push({
99
+ critical: false,
100
+ name: ext,
101
+ data: Buffer.alloc(0)
102
+ })
103
+ })
104
+
105
+ }
106
+
107
+ // add info to certificate
108
+ certificate.signatures = {
109
+ openssh: {
110
+ nonce: certificate.signatures.openssh.nonce,
111
+ keyId: email,
112
+ signature: certificate.signatures.openssh.signature,
113
+ exts: sshextensions
114
+ }
115
+ }
116
+
117
+ // re-sign after changes
118
+ certificate.signWith(key)
119
+
120
+ return certificate
121
+ }
122
+
123
+ export async function createSignedCertificate(env: SshCaBindings, email: string, public_key: Key, options?: CreateCertificateOptions): Promise<Certificate> {
124
+ // grab private key from secret store
125
+ const secret = await env.PRIVATE_KEY.get()
126
+
127
+ // parse key
128
+ const key = parsePrivateKey(secret)
129
+
130
+ if (options === undefined) {
131
+ options = {
132
+ lifetime: seconds(env.SSH_CERTIFICATE_LIFETIME),
133
+ principals: [],
134
+ extensions: split(env.SSH_CERTIFICATE_EXTENSIONS)
135
+ }
136
+ }
137
+
138
+ return generateCertificate(env, email, key, public_key, options)
139
+ }
140
+
141
+ export type CreateHostCertificateOptions = {
142
+ lifetime?: number
143
+ principals?: string[]
144
+ subjects?: Identity[]
145
+ }
146
+
147
+ /* const DefaultCreateHostertificateOptions: CreateHostCertificateOptions = {
148
+ lifetime: seconds(env.SSH_HOST_CERTIFICATE_LIFETIME),
149
+ principals: [],
150
+ } */
151
+
152
+ export class BadIssuerError extends Error {
153
+ constructor(message: string) {
154
+ super(message)
155
+ this.name = "BadIssuerError"
156
+
157
+ Object.setPrototypeOf(this, BadIssuerError.prototype)
158
+ }
159
+ }
160
+
161
+ export async function createSignedHostCertificate(env: SshCaBindings, public_key: Key, options?: CreateHostCertificateOptions): Promise<Certificate> {
162
+ if (options === undefined) {
163
+ options = {
164
+ lifetime: seconds(env.SSH_HOST_CERTIFICATE_LIFETIME),
165
+ principals: []
166
+ }
167
+ }
168
+ // generate list of identities for host key
169
+ const identity: Identity[] = []
170
+ if (options.principals !== undefined) {
171
+ for (const p of options.principals) {
172
+ identity.push(identityForHost(p))
173
+ }
174
+ }
175
+
176
+ // add any already provided subjects (for renewals only)
177
+ if (options.subjects !== undefined) {
178
+ identity.push(...options.subjects)
179
+ }
180
+
181
+ // grab private key from secret store
182
+ const secret = await env.PRIVATE_KEY.get()
183
+
184
+ // parse private key
185
+ const key = parsePrivateKey(secret)
186
+
187
+ // lifetime is the smaller of what was provided in the options or the default
188
+ const lifetime = options.lifetime !== undefined
189
+ ? Math.min(options.lifetime, seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
190
+ : seconds(env.SSH_HOST_CERTIFICATE_LIFETIME)
191
+
192
+ // generate value for serial of certificate
193
+ const serial = generateSerial()
194
+
195
+ // set issuer of certificate based on ISSUER_DN
196
+ const issuer = identityFromDN(env.ISSUER_DN)
197
+
198
+ // create certificate
199
+ const certificate = createCertificate(identity, public_key, issuer, key, { lifetime: lifetime, serial: serial })
200
+
201
+ return certificate
202
+ }
203
+
204
+ export const generateSerial = (serial?: bigint): Buffer<ArrayBuffer> => {
205
+ if (serial !== undefined) {
206
+ const s = Buffer.alloc(8)
207
+ s.writeBigUInt64BE(serial)
208
+
209
+ return s
210
+ }
211
+
212
+ const randomBytes = crypto.getRandomValues(new Uint8Array(8))
213
+ return Buffer.from(randomBytes)
214
+ }
@@ -0,0 +1,194 @@
1
+ import { Certificate, Format, Identity } from "sshpk"
2
+ import { logger } from "../logger"
3
+ import type { SshCaBindings } from "../types"
4
+ import { D1QB } from "workers-qb"
5
+ import { migrations } from "./migrations"
6
+
7
+ export enum CertificateType {
8
+ User,
9
+ Host
10
+ }
11
+
12
+ export type CertificateRecord = {
13
+ id: number
14
+ serial: string
15
+ key_id: string
16
+ principals: string
17
+ extensions: string | null
18
+ valid_after: Date
19
+ valid_before: Date
20
+ revoked_at: Date | null
21
+ certificate_type: CertificateType
22
+ public_key: string | null
23
+ }
24
+
25
+ export type DatabaseSchema = {
26
+ certificates: CertificateRecord
27
+ }
28
+
29
+ const connect = async (env: SshCaBindings): Promise<D1QB<DatabaseSchema>> => {
30
+ const l = logger(env)
31
+
32
+ try {
33
+ l.info("connecting to database")
34
+
35
+ const qb = new D1QB<DatabaseSchema>(env.DB)
36
+ const migrationBuilder = qb.migrations({ migrations })
37
+ const appliedMigrations = await migrationBuilder.apply()
38
+
39
+ l.info("applied database migrations", "migrations", appliedMigrations.length, "migration_names", appliedMigrations.map(m => m.name))
40
+
41
+ return qb
42
+ } catch (err) {
43
+ l.error("error connecting to database", "error", err)
44
+ throw err
45
+ }
46
+ }
47
+
48
+ export const dbCleanup = async (env: SshCaBindings) => {
49
+ const l = logger(env).with("retention", env.DB_CERTIFICATE_RETENTION)
50
+
51
+ if (env.DB_CERTIFICATE_RETENTION === "infinite") {
52
+ l.info("skipping database cleanup")
53
+ }
54
+
55
+ l.info("starting database cleanup")
56
+ try {
57
+ const qb = await connect(env)
58
+ const res = await qb.delete({
59
+ tableName: "certificates",
60
+ where: {
61
+ conditions: "unixepoch('subsec') > unixepoch(valid_before,'subsec',?)",
62
+ params: env.DB_CERTIFICATE_RETENTION
63
+ }
64
+ }).execute()
65
+ l.info("completed database cleanup", "changes", res.meta?.changes)
66
+ } catch (err) {
67
+ l.error("error during database cleanup", "retention", env.DB_CERTIFICATE_RETENTION)
68
+ }
69
+ }
70
+
71
+ export const recordCertificate = async (env: SshCaBindings, certificate: Certificate, keyid: string, certificateType: CertificateType = CertificateType.User) => {
72
+ const serial = certificate.serial.readBigUInt64BE(0)
73
+ const subjects = certificate.subjects.map((v: Identity): string => {
74
+ return v.toString()
75
+ }).join(",")
76
+ const extensions = certificate.getExtensions().map((v: Format.OpenSshSignatureExt | Format.x509SignatureExt): string => {
77
+ // @ts-ignore: the name property does exist
78
+ return v.name as string
79
+ }).join(",")
80
+ const qb = await connect(env)
81
+ const res = await qb.insert({
82
+ tableName: "certificates",
83
+ data: {
84
+ serial: `${serial}`,
85
+ key_id: keyid,
86
+ principals: subjects,
87
+ extensions: extensions,
88
+ valid_after: certificate.validFrom.toISOString(),
89
+ valid_before: certificate.validUntil.toISOString(),
90
+ certificate_type: certificateType,
91
+ public_key: certificate.subjectKey.toString("ssh")
92
+ },
93
+ }).execute()
94
+
95
+ if (!res.success) {
96
+ throw new Error("error during query")
97
+ }
98
+ }
99
+
100
+ export const isRevoked = async (env: SshCaBindings, serial: bigint): Promise<boolean> => {
101
+ const qb = await connect(env)
102
+ const res = await qb.fetchOne({
103
+ tableName: "certificates",
104
+ fields: ["serial"],
105
+ where: {
106
+ conditions: "serial = ? AND revoked_at NOTNULL",
107
+ params: `${serial}`
108
+ },
109
+ }).execute()
110
+
111
+ return res.results !== undefined
112
+ }
113
+
114
+ export enum RevocationStatus {
115
+ NotFound,
116
+ Revoked,
117
+ Revokable,
118
+ Unrevokable
119
+ }
120
+
121
+ export const revocationStatus = async (env: SshCaBindings, serial: bigint, publickey: string, certificateType: CertificateType): Promise<RevocationStatus> => {
122
+ const qb = await connect(env)
123
+ const res = await qb.fetchOne({
124
+ tableName: "certificates",
125
+ fields: ["public_key", "revoked_at"],
126
+ where: {
127
+ conditions: "serial = ? AND public_key = ? AND certificate_type = ?",
128
+ params: [`${serial}`, publickey, certificateType]
129
+ }
130
+ }).execute()
131
+
132
+ if (res.results === undefined) {
133
+ return RevocationStatus.NotFound
134
+ }
135
+
136
+ if (res.results.revoked_at !== null) {
137
+ return RevocationStatus.Revoked
138
+ }
139
+
140
+ if (publickey === res.results.public_key) {
141
+ return RevocationStatus.Revokable
142
+ }
143
+
144
+ return RevocationStatus.Unrevokable
145
+ }
146
+
147
+ export const getRevocationList = async (env: SshCaBindings, certificateType: CertificateType): Promise<string[]> => {
148
+ const qb = await connect(env)
149
+ const res = await qb.fetchAll({
150
+ tableName: "certificates",
151
+ fields: ["serial"],
152
+ where: {
153
+ conditions: "revoked_at NOTNULL AND certificate_type = ? AND unixepoch('subsec') < unixepoch(valid_before,'subsec','24 hours')",
154
+ params: certificateType
155
+ }
156
+ }).execute()
157
+
158
+ if (!res.success) {
159
+ throw new Error("error during query")
160
+ }
161
+
162
+ if (res.results === undefined) {
163
+ return []
164
+ }
165
+
166
+ return res.results.map(item => item.serial)
167
+ }
168
+
169
+ type RevokedCertificate = Pick<CertificateRecord, "revoked_at">
170
+
171
+ export const revokeCertificate = async (env: SshCaBindings, serial: bigint): Promise<RevokedCertificate[]> => {
172
+ const qb = await connect(env)
173
+ const res = await qb.update<RevokedCertificate>({
174
+ tableName: "certificates",
175
+ data: {
176
+ revoked_at: (new Date).toISOString()
177
+ },
178
+ where: {
179
+ conditions: "serial = ?",
180
+ params: `${serial}`
181
+ },
182
+ returning: ["revoked_at"]
183
+ }).execute()
184
+
185
+ if (!res.success) {
186
+ throw new Error("error during query")
187
+ }
188
+
189
+ if (res.results === undefined) {
190
+ return []
191
+ }
192
+
193
+ return res.results
194
+ }
@@ -0,0 +1,25 @@
1
+ import { type Migration } from "workers-qb";
2
+
3
+ export const migration: Migration = {
4
+ name: "0001_initial_schema",
5
+ sql: `
6
+ CREATE TABLE IF NOT EXISTS certificates (
7
+ id INTEGER PRIMARY KEY,
8
+ serial TEXT UNIQUE NOT NULL,
9
+ key_id TEXT NOT NULL,
10
+ principals TEXT NOT NULL,
11
+ extensions TEXT,
12
+ valid_after TIMESTAMP NOT NULL,
13
+ valid_before TIMESTAMP NOT NULL,
14
+ revoked_at TIMESTAMP,
15
+ certificate_type INTEGER NOT NULL DEFAULT 0,
16
+ public_key TEXT
17
+ );
18
+
19
+ CREATE INDEX IF NOT EXISTS idx_certificates_serial ON certificates(serial);
20
+ CREATE INDEX IF NOT EXISTS idx_certificates_valid_after ON certificates(valid_after);
21
+ CREATE INDEX IF NOT EXISTS idx_certificates_valid_before ON certificates(valid_before);
22
+ CREATE INDEX IF NOT EXISTS idx_certificates_revoked_at ON certificates(revoked_at);
23
+ CREATE INDEX IF NOT EXISTS idx_certificates_certificate_type ON certificates(certificate_type);
24
+ `
25
+ }
@@ -0,0 +1,6 @@
1
+ import { type Migration } from "workers-qb"
2
+ import { migration as initialSchema0001 } from "./0001_initial_schema"
3
+
4
+ export const migrations: Migration[] = [
5
+ initialSchema0001
6
+ ]
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { dbCleanup } from "./db"
2
+ import { createApp } from "./router"
3
+ import type { SshCaBindings } from "./types"
4
+ export type { SshCaBindings } from "./types"
5
+
6
+ let app: ReturnType<typeof createApp> | undefined
7
+
8
+ export default {
9
+ fetch(req: Request, env: SshCaBindings, ctx: ExecutionContext) {
10
+ if (app === undefined) {
11
+ app = createApp(env)
12
+ }
13
+ return app.fetch(req, env, ctx)
14
+ },
15
+ async scheduled(controller: ScheduledController, env: SshCaBindings, ctx: ExecutionContext) {
16
+ ctx.waitUntil(dbCleanup(env))
17
+ },
18
+ } satisfies ExportedHandler<SshCaBindings>