@andrewheberle/serverless-ssh-ca 0.22.0 → 0.24.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 +174 -1
- package/dist/api/operations.d.ts +50 -0
- package/dist/api/operations.d.ts.map +1 -0
- package/dist/api/operations.js +264 -0
- package/dist/api/operations.js.map +1 -0
- package/dist/api/schema.d.ts +72 -0
- package/dist/api/schema.d.ts.map +1 -0
- package/dist/api/schema.js +114 -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 +18 -104
- 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 +186 -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 +68 -9
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +165 -49
- package/dist/db/index.js.map +1 -1
- package/dist/db/migrations/0003_krl_revision.d.ts +3 -0
- package/dist/db/migrations/0003_krl_revision.d.ts.map +1 -0
- package/dist/db/migrations/0003_krl_revision.js +14 -0
- package/dist/db/migrations/0003_krl_revision.js.map +1 -0
- package/dist/db/migrations/index.d.ts.map +1 -1
- package/dist/db/migrations/index.js +2 -0
- package/dist/db/migrations/index.js.map +1 -1
- package/dist/db/types.d.ts +2 -0
- package/dist/db/types.d.ts.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/krl.d.ts +3 -2
- package/dist/krl.d.ts.map +1 -1
- package/dist/krl.js +12 -165
- package/dist/krl.js.map +1 -1
- package/dist/node/cache.d.ts +21 -0
- package/dist/node/cache.d.ts.map +1 -0
- package/dist/node/cache.js +49 -0
- package/dist/node/cache.js.map +1 -0
- package/dist/node/env.d.ts +6 -3
- package/dist/node/env.d.ts.map +1 -1
- package/dist/node/env.js +4 -3
- package/dist/node/env.js.map +1 -1
- package/dist/node/index.d.ts +1 -0
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -0
- package/dist/node/index.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 +25 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +41 -2
- 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 +92 -0
- package/dist/ssh/keys.d.ts.map +1 -0
- package/dist/ssh/keys.js +277 -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 +64 -19
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +107 -21
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +251 -65
- package/dist/utils.js.map +1 -1
- package/dist/verify.d.ts +17 -5
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +47 -9
- package/dist/verify.js.map +1 -1
- package/openapi.json +1942 -1
- package/package.json +4 -3
- package/src/api/operations.ts +314 -0
- package/src/api/schema.ts +146 -0
- package/src/api/v3/index.ts +21 -248
- package/src/api/v3/schema.ts +36 -128
- package/src/api/v4/index.ts +266 -0
- package/src/api/v4/schema.ts +241 -0
- package/src/certificate.ts +62 -123
- package/src/db/index.ts +196 -56
- package/src/db/migrations/0003_krl_revision.ts +15 -0
- package/src/db/migrations/index.ts +2 -0
- package/src/db/types.ts +2 -0
- package/src/index.ts +6 -4
- package/src/krl.ts +13 -197
- package/src/node/cache.ts +62 -0
- package/src/node/env.ts +6 -5
- package/src/node/index.ts +1 -0
- package/src/proof.ts +65 -31
- package/src/router.ts +67 -3
- package/src/ssh/certificate.ts +236 -0
- package/src/ssh/encoding.ts +81 -0
- package/src/ssh/keys.ts +327 -0
- package/src/ssh/signer.ts +129 -0
- package/src/types.ts +60 -20
- package/src/utils.ts +285 -81
- package/src/verify.ts +54 -14
package/src/index.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ExecutionContext } from "hono"
|
|
2
2
|
import { dbCleanup } from "./db/index.js"
|
|
3
|
-
import { createApp } from "./router.js"
|
|
3
|
+
import { createApp, type SshCaOptions } from "./router.js"
|
|
4
4
|
import type { SshCaBindings } from "./types.js"
|
|
5
|
-
export type { CaDatabase, CaDatabaseQueryResult, CaDatabaseResult, CaPreparedStatement, CaSecret, SshCaBindings } from "./types.js"
|
|
5
|
+
export type { CaCache, CaDatabase, CaDatabaseQueryResult, CaDatabaseResult, CaPreparedStatement, CaRateLimiter, CaSecret, SshCaBindings } from "./types.js"
|
|
6
|
+
export { cloudflareClientIp, type RateLimitKey, type SshCaOptions } from "./router.js"
|
|
6
7
|
|
|
7
8
|
// the handlers the CA exposes, compatible with a Cloudflare Workers ExportedHandler
|
|
8
9
|
type SshCaHandler = {
|
|
@@ -35,10 +36,11 @@ export interface SshCa {
|
|
|
35
36
|
* the runtime on each request.
|
|
36
37
|
*
|
|
37
38
|
* @param env The bindings (configuration, database and private key) for the CA, see {@link SshCaBindings}
|
|
39
|
+
* @param options Options for the CA, such as the key requests are rate limited by, see {@link SshCaOptions}
|
|
38
40
|
* @returns The CA's request handler and database cleanup task, see {@link SshCa}
|
|
39
41
|
*/
|
|
40
|
-
export const createSshCa = (env: SshCaBindings): SshCa => {
|
|
41
|
-
const app = createApp(env)
|
|
42
|
+
export const createSshCa = (env: SshCaBindings, options: SshCaOptions = {}): SshCa => {
|
|
43
|
+
const app = createApp(env, options)
|
|
42
44
|
|
|
43
45
|
return {
|
|
44
46
|
fetch: async (req: Request): Promise<Response> => await app.fetch(req, env),
|
package/src/krl.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
1
|
+
import type { PrivateKey } from "./ssh/keys.js"
|
|
2
|
+
import { createSigner } from "./ssh/signer.js"
|
|
3
3
|
|
|
4
4
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
5
5
|
|
|
@@ -59,32 +59,6 @@ function encodeSection(sectionType: number, data: Uint8Array): Uint8Array {
|
|
|
59
59
|
return concat(new Uint8Array([sectionType]), encodeString(data))
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
// ── CA public-key extraction ─────────────────────────────────────────────────
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Extract the SSH wire-format public key bytes from an sshpk PrivateKey.
|
|
66
|
-
*
|
|
67
|
-
* sshpk's "ssh" format produces a string like:
|
|
68
|
-
* ssh-ed25519 AAAA...base64... [comment]
|
|
69
|
-
*
|
|
70
|
-
* The base64 blob *is* the SSH wire encoding of the public key, so we just
|
|
71
|
-
* decode it. This works for both Ed25519 and ECDSA keys.
|
|
72
|
-
*/
|
|
73
|
-
function caKeyWireBytes(caKey: PrivateKey): Uint8Array {
|
|
74
|
-
const sshString = caKey.toPublic().toString("ssh")
|
|
75
|
-
// Format: "<type> <base64> [<comment>]" — we want the second token.
|
|
76
|
-
const base64 = sshString.trim().split(/\s+/)[1]
|
|
77
|
-
if (!base64) {
|
|
78
|
-
throw new Error("Unexpected sshpk 'ssh' format output: " + sshString)
|
|
79
|
-
}
|
|
80
|
-
const binary = atob(base64)
|
|
81
|
-
const bytes = new Uint8Array(binary.length)
|
|
82
|
-
for (let i = 0; i < binary.length; i++) {
|
|
83
|
-
bytes[i] = binary.charCodeAt(i)
|
|
84
|
-
}
|
|
85
|
-
return bytes
|
|
86
|
-
}
|
|
87
|
-
|
|
88
62
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
89
63
|
|
|
90
64
|
const SSHSIG_MAGIC = new TextEncoder().encode("SSHSIG")
|
|
@@ -92,156 +66,6 @@ const SIG_VERSION = 1
|
|
|
92
66
|
const NAMESPACE = "krl@com.github.serverless-ssh-ca.andrewheberle"
|
|
93
67
|
const HASH_ALGORITHM = "sha512"
|
|
94
68
|
|
|
95
|
-
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Get the raw bytes of a named part of an sshpk key.
|
|
99
|
-
*/
|
|
100
|
-
function keyPart(key: PrivateKey, name: AlgorithmPart): Uint8Array {
|
|
101
|
-
const part = key.parts.find((p) => p.name === name)
|
|
102
|
-
if (part === undefined) {
|
|
103
|
-
throw new Error(`Missing ${name} part in ${key.type} key`)
|
|
104
|
-
}
|
|
105
|
-
return part.data
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Base64url-encode bytes without padding, as used for JWK values.
|
|
110
|
-
*/
|
|
111
|
-
function base64url(bytes: Uint8Array): string {
|
|
112
|
-
let binary = ""
|
|
113
|
-
for (const byte of bytes) {
|
|
114
|
-
binary += String.fromCharCode(byte)
|
|
115
|
-
}
|
|
116
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Import an sshpk PrivateKey into a WebCrypto CryptoKey.
|
|
121
|
-
* Supports Ed25519 and ECDSA (P-256 / P-384 / P-521).
|
|
122
|
-
*/
|
|
123
|
-
async function importPrivateKey(caKey: PrivateKey): Promise<{
|
|
124
|
-
cryptoKey: CryptoKey
|
|
125
|
-
sigAlgo: string // SSH signature algorithm name
|
|
126
|
-
webCryptoAlgo: Parameters<SubtleCrypto["sign"]>[0]
|
|
127
|
-
}> {
|
|
128
|
-
const type = caKey.type // "ed25519" | "ecdsa" | etc.
|
|
129
|
-
const curve = (caKey as unknown as { curve?: string }).curve
|
|
130
|
-
|
|
131
|
-
if (type === "ed25519") {
|
|
132
|
-
// import from the raw key parts as a JWK rather than via sshpk's PKCS#8
|
|
133
|
-
// output, which drops a leading zero byte from the seed (about 1 key in
|
|
134
|
-
// 512) and is then rejected by WebCrypto
|
|
135
|
-
const seed = keyPart(caKey, "k")
|
|
136
|
-
const publicKey = keyPart(caKey, "A")
|
|
137
|
-
if (seed.length !== 32 || publicKey.length !== 32) {
|
|
138
|
-
throw new Error(`Unexpected Ed25519 key part lengths: k=${seed.length} A=${publicKey.length}`)
|
|
139
|
-
}
|
|
140
|
-
const cryptoKey = await crypto.subtle.importKey(
|
|
141
|
-
"jwk",
|
|
142
|
-
{ kty: "OKP", crv: "Ed25519", d: base64url(seed), x: base64url(publicKey) },
|
|
143
|
-
{ name: "Ed25519" },
|
|
144
|
-
false,
|
|
145
|
-
["sign"],
|
|
146
|
-
)
|
|
147
|
-
return {
|
|
148
|
-
cryptoKey,
|
|
149
|
-
sigAlgo: "ssh-ed25519",
|
|
150
|
-
webCryptoAlgo: { name: "Ed25519" },
|
|
151
|
-
}
|
|
152
|
-
} else if (type === "ecdsa") {
|
|
153
|
-
let namedCurve: string
|
|
154
|
-
let sigAlgo: string
|
|
155
|
-
let hash: string
|
|
156
|
-
let size: number // bytes in each coordinate and the private key
|
|
157
|
-
|
|
158
|
-
if (curve === "nistp256") {
|
|
159
|
-
namedCurve = "P-256"
|
|
160
|
-
sigAlgo = "ecdsa-sha2-nistp256"
|
|
161
|
-
hash = "SHA-256"
|
|
162
|
-
size = 32
|
|
163
|
-
} else if (curve === "nistp384") {
|
|
164
|
-
namedCurve = "P-384"
|
|
165
|
-
sigAlgo = "ecdsa-sha2-nistp384"
|
|
166
|
-
hash = "SHA-384"
|
|
167
|
-
size = 48
|
|
168
|
-
} else if (curve === "nistp521") {
|
|
169
|
-
namedCurve = "P-521"
|
|
170
|
-
sigAlgo = "ecdsa-sha2-nistp521"
|
|
171
|
-
hash = "SHA-512"
|
|
172
|
-
size = 66
|
|
173
|
-
} else {
|
|
174
|
-
throw new Error(`Unsupported ECDSA curve: ${curve}`)
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// import from the raw key parts as a JWK rather than via sshpk's PKCS#8
|
|
178
|
-
// output, which drops leading zero bytes from the private key so it is
|
|
179
|
-
// shorter than RFC 5915 requires (about half of P-521 keys)
|
|
180
|
-
const point = keyPart(caKey, "Q")
|
|
181
|
-
if (point.length !== 1 + size * 2 || point[0] !== 0x04) {
|
|
182
|
-
throw new Error(`Unexpected ECDSA public key encoding for ${curve}`)
|
|
183
|
-
}
|
|
184
|
-
// d is an SSH mpint, so may be short or have a leading sign byte
|
|
185
|
-
const d = toFixedWidth(keyPart(caKey, "d"), size)
|
|
186
|
-
const cryptoKey = await crypto.subtle.importKey(
|
|
187
|
-
"jwk",
|
|
188
|
-
{
|
|
189
|
-
kty: "EC",
|
|
190
|
-
crv: namedCurve,
|
|
191
|
-
d: base64url(d),
|
|
192
|
-
x: base64url(point.subarray(1, 1 + size)),
|
|
193
|
-
y: base64url(point.subarray(1 + size)),
|
|
194
|
-
},
|
|
195
|
-
{ name: "ECDSA", namedCurve },
|
|
196
|
-
false,
|
|
197
|
-
["sign"],
|
|
198
|
-
)
|
|
199
|
-
return {
|
|
200
|
-
cryptoKey,
|
|
201
|
-
sigAlgo,
|
|
202
|
-
webCryptoAlgo: { name: "ECDSA", hash: { name: hash } },
|
|
203
|
-
}
|
|
204
|
-
} else {
|
|
205
|
-
throw new Error(`Unsupported key type: ${type}`)
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Convert a WebCrypto ECDSA signature (IEEE P1363: raw r||s) to the SSH wire
|
|
211
|
-
* encoding: string(algo) + string(mpint(r) + mpint(s)).
|
|
212
|
-
*
|
|
213
|
-
* SSH mpints are big-endian, with a leading 0x00 byte if the high bit is set.
|
|
214
|
-
*/
|
|
215
|
-
function p1363ToSshEcdsaSig(
|
|
216
|
-
p1363: Uint8Array,
|
|
217
|
-
sigAlgo: string,
|
|
218
|
-
): Uint8Array {
|
|
219
|
-
const half = p1363.length / 2
|
|
220
|
-
const r = p1363.slice(0, half)
|
|
221
|
-
const s = p1363.slice(half)
|
|
222
|
-
|
|
223
|
-
function encodeMpint(n: Uint8Array): Uint8Array {
|
|
224
|
-
// Strip leading zero bytes, then re-pad if high bit set.
|
|
225
|
-
let start = 0
|
|
226
|
-
while (start < n.length - 1 && n[start] === 0) start++
|
|
227
|
-
const trimmed = n.slice(start)
|
|
228
|
-
const padded =
|
|
229
|
-
trimmed[0]! & 0x80 ? concat(new Uint8Array([0x00]), trimmed) : trimmed
|
|
230
|
-
return encodeString(padded)
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const innerSig = concat(encodeMpint(r), encodeMpint(s))
|
|
234
|
-
return concat(encodeString(sigAlgo), encodeString(innerSig))
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Wrap an Ed25519 WebCrypto signature into SSH wire encoding:
|
|
239
|
-
* string(algo) + string(sig_bytes)
|
|
240
|
-
*/
|
|
241
|
-
function wrapEd25519Sig(sig: Uint8Array): Uint8Array {
|
|
242
|
-
return concat(encodeString("ssh-ed25519"), encodeString(sig))
|
|
243
|
-
}
|
|
244
|
-
|
|
245
69
|
/**
|
|
246
70
|
* Base64-encode bytes and wrap in SSH signature armor at 76 chars per line.
|
|
247
71
|
*/
|
|
@@ -310,7 +134,7 @@ export class KRLBuilder {
|
|
|
310
134
|
const keyIds = [...this._keyIds].sort()
|
|
311
135
|
|
|
312
136
|
const content = concat(
|
|
313
|
-
encodeString(
|
|
137
|
+
encodeString(this._caKey.publicKey.blob),
|
|
314
138
|
encodeUint32(serials.length),
|
|
315
139
|
...serials.map(encodeUint64),
|
|
316
140
|
encodeUint32(keyIds.length),
|
|
@@ -326,14 +150,15 @@ export class KRLBuilder {
|
|
|
326
150
|
*
|
|
327
151
|
* @param krlVersion Optional KRL version number (default: 1).
|
|
328
152
|
* @param comment Optional comment string embedded in the header.
|
|
153
|
+
* @param generatedAt Optional time the KRL was generated (default: now).
|
|
329
154
|
*/
|
|
330
|
-
generate(krlVersion: bigint = 1n, comment = ""): ArrayBuffer {
|
|
155
|
+
generate(krlVersion: bigint = 1n, comment = "", generatedAt?: Date): ArrayBuffer {
|
|
331
156
|
if (this._krl !== null) {
|
|
332
157
|
return this._krl
|
|
333
158
|
}
|
|
334
159
|
|
|
335
160
|
// ── Header ──────────────────────────────────────────────────────────────
|
|
336
|
-
const generatedDate = BigInt(Math.floor(Date.now() / 1000))
|
|
161
|
+
const generatedDate = BigInt(Math.floor((generatedAt?.getTime() ?? Date.now()) / 1000))
|
|
337
162
|
|
|
338
163
|
const header = concat(
|
|
339
164
|
encodeUint64(KRL_MAGIC),
|
|
@@ -375,7 +200,7 @@ export class KRLBuilder {
|
|
|
375
200
|
// <one or more cert sub-sections>
|
|
376
201
|
//
|
|
377
202
|
const certSectionData = concat(
|
|
378
|
-
encodeString(
|
|
203
|
+
encodeString(this._caKey.publicKey.blob), // ca_key
|
|
379
204
|
encodeString(""), // reserved
|
|
380
205
|
...certSubSections
|
|
381
206
|
)
|
|
@@ -409,7 +234,7 @@ export class KRLBuilder {
|
|
|
409
234
|
} else {
|
|
410
235
|
krl = this._krl
|
|
411
236
|
}
|
|
412
|
-
const
|
|
237
|
+
const signer = await createSigner(this._caKey)
|
|
413
238
|
|
|
414
239
|
// 1. Hash the KRL bytes with SHA-512.
|
|
415
240
|
const krlHash = new Uint8Array(
|
|
@@ -431,18 +256,10 @@ export class KRLBuilder {
|
|
|
431
256
|
encodeString(krlHash),
|
|
432
257
|
)
|
|
433
258
|
|
|
434
|
-
// 3. Sign the blob.
|
|
435
|
-
const
|
|
436
|
-
await crypto.subtle.sign(webCryptoAlgo, cryptoKey, signedData),
|
|
437
|
-
)
|
|
438
|
-
|
|
439
|
-
// 4. Encode the signature into SSH wire format.
|
|
440
|
-
const sshSig =
|
|
441
|
-
sigAlgo === "ssh-ed25519"
|
|
442
|
-
? wrapEd25519Sig(rawSig)
|
|
443
|
-
: p1363ToSshEcdsaSig(rawSig, sigAlgo)
|
|
259
|
+
// 3. Sign the blob, which returns the signature in SSH wire format.
|
|
260
|
+
const sshSig = await signer.sign(signedData)
|
|
444
261
|
|
|
445
|
-
//
|
|
262
|
+
// 4. Assemble the outer SSHSIG blob.
|
|
446
263
|
//
|
|
447
264
|
// byte[6] "SSHSIG"
|
|
448
265
|
// uint32 SIG_VERSION (1)
|
|
@@ -451,18 +268,17 @@ export class KRLBuilder {
|
|
|
451
268
|
// string reserved (empty)
|
|
452
269
|
// string hash_algorithm
|
|
453
270
|
// string signature
|
|
454
|
-
const publicKeyWire = caKeyWireBytes(this._caKey)
|
|
455
271
|
const sigBlob = concat(
|
|
456
272
|
SSHSIG_MAGIC,
|
|
457
273
|
encodeUint32(SIG_VERSION),
|
|
458
|
-
encodeString(
|
|
274
|
+
encodeString(signer.publicKey),
|
|
459
275
|
encodeString(NAMESPACE),
|
|
460
276
|
encodeString(""), // reserved
|
|
461
277
|
encodeString(HASH_ALGORITHM),
|
|
462
278
|
encodeString(sshSig),
|
|
463
279
|
)
|
|
464
280
|
|
|
465
|
-
//
|
|
281
|
+
// 5. Armor and return.
|
|
466
282
|
return armorSignature(sigBlob)
|
|
467
283
|
}
|
|
468
284
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { CaCache } from "../types.js"
|
|
2
|
+
|
|
3
|
+
type Entry = {
|
|
4
|
+
value: string
|
|
5
|
+
/** When the entry expires in milliseconds since the Unix epoch, if it does */
|
|
6
|
+
expires: number | undefined
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create an in-memory cache for the `CACHE` binding, with the same shape as a
|
|
11
|
+
* Cloudflare Workers KV namespace (see {@link CaCache}).
|
|
12
|
+
*
|
|
13
|
+
* Entries are kept in memory by the process, so they are lost on restart and
|
|
14
|
+
* not shared between processes.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* const env = bindingsFromEnv({
|
|
19
|
+
* DB: fromNodeSqlite(new DatabaseSync("ssh-ca.sqlite")),
|
|
20
|
+
* PRIVATE_KEY: secretFromFile("/etc/ssh-ca/ca_key"),
|
|
21
|
+
* CACHE: cacheInMemory(),
|
|
22
|
+
* })
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @returns A cache for the `CACHE` binding, see {@link CaCache}
|
|
26
|
+
*/
|
|
27
|
+
export const cacheInMemory = (): CaCache => {
|
|
28
|
+
const entries = new Map<string, Entry>()
|
|
29
|
+
|
|
30
|
+
const expired = (entry: Entry, now: number): boolean => entry.expires !== undefined && now >= entry.expires
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
get: async (key: string): Promise<string | null> => {
|
|
34
|
+
const entry = entries.get(key)
|
|
35
|
+
if (entry === undefined) {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (expired(entry, Date.now())) {
|
|
40
|
+
entries.delete(key)
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return entry.value
|
|
45
|
+
},
|
|
46
|
+
put: async (key: string, value: string, options?: { expirationTtl?: number }): Promise<void> => {
|
|
47
|
+
const now = Date.now()
|
|
48
|
+
|
|
49
|
+
// remove entries that have expired, as they are not read again
|
|
50
|
+
for (const [k, entry] of entries) {
|
|
51
|
+
if (expired(entry, now)) {
|
|
52
|
+
entries.delete(k)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
entries.set(key, {
|
|
57
|
+
value,
|
|
58
|
+
expires: options?.expirationTtl === undefined ? undefined : now + options.expirationTtl * 1000,
|
|
59
|
+
})
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/node/env.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { seconds } from "itty-time"
|
|
2
2
|
import z from "zod"
|
|
3
|
-
import type { CaDatabase, CaSecret, SshCaBindings } from "../types.js"
|
|
3
|
+
import type { CaCache, CaDatabase, CaRateLimiter, CaSecret, SshCaBindings } from "../types.js"
|
|
4
4
|
|
|
5
5
|
const required = z.string().trim().min(1, { message: "must be set" })
|
|
6
6
|
|
|
@@ -32,7 +32,8 @@ export const EnvSchema = z.object({
|
|
|
32
32
|
SSH_HOST_CERTIFICATE_ALLOWED_ROLES: z.string().optional(),
|
|
33
33
|
ISSUER_DN: required,
|
|
34
34
|
JWT_JWKS_URL: z.url({ protocol: /^https?$/, message: "must be an http or https URL" }),
|
|
35
|
-
JWT_AUD:
|
|
35
|
+
JWT_AUD: required,
|
|
36
|
+
JWT_ACCESS_TOKEN_AUD: z.string().optional(),
|
|
36
37
|
JWT_ISSUER: required,
|
|
37
38
|
JWT_ALGORITHMS: required,
|
|
38
39
|
JWT_SSH_CERTIFICATE_PRINCIPALS_CLAIM: required,
|
|
@@ -72,13 +73,13 @@ export class InvalidConfigurationError extends Error {
|
|
|
72
73
|
* })
|
|
73
74
|
* ```
|
|
74
75
|
*
|
|
75
|
-
* @param bindings The database
|
|
76
|
+
* @param bindings The database, private key, optional rate limiter and optional cache, see {@link CaDatabase}, {@link CaSecret}, {@link CaRateLimiter} and {@link CaCache}
|
|
76
77
|
* @param source The environment variables to read, `process.env` by default
|
|
77
78
|
* @returns The bindings to pass to `createSshCa`
|
|
78
79
|
* @throws {@link InvalidConfigurationError} if a variable is missing or invalid
|
|
79
80
|
*/
|
|
80
81
|
export const bindingsFromEnv = (
|
|
81
|
-
bindings: { DB: CaDatabase, PRIVATE_KEY: CaSecret },
|
|
82
|
+
bindings: { DB: CaDatabase, PRIVATE_KEY: CaSecret, RATE_LIMITER?: CaRateLimiter, CACHE?: CaCache },
|
|
82
83
|
source: Record<string, string | undefined> = process.env,
|
|
83
84
|
): SshCaBindings => {
|
|
84
85
|
const result = EnvSchema.safeParse(source)
|
|
@@ -86,5 +87,5 @@ export const bindingsFromEnv = (
|
|
|
86
87
|
throw new InvalidConfigurationError(`invalid configuration:\n${z.prettifyError(result.error)}`)
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
return { ...result.data, DB: bindings.DB, PRIVATE_KEY: bindings.PRIVATE_KEY }
|
|
90
|
+
return { ...result.data, DB: bindings.DB, PRIVATE_KEY: bindings.PRIVATE_KEY, RATE_LIMITER: bindings.RATE_LIMITER, CACHE: bindings.CACHE }
|
|
90
91
|
}
|
package/src/node/index.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* @packageDocumentation
|
|
18
18
|
*/
|
|
19
|
+
export { cacheInMemory } from "./cache.js"
|
|
19
20
|
export { bindingsFromEnv, EnvSchema, InvalidConfigurationError } from "./env.js"
|
|
20
21
|
export type { CaEnv } from "./env.js"
|
|
21
22
|
export { secretFromFile, secretFromString } from "./secret.js"
|
package/src/proof.ts
CHANGED
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
import { ms } from "itty-time"
|
|
2
|
-
import
|
|
3
|
-
import type { Fingerprint, Key } from "sshpk"
|
|
2
|
+
import { KeyParseError, PublicKey } from "./ssh/keys.js"
|
|
4
3
|
import { verify } from "./sshsig/index.js"
|
|
5
4
|
import { parse } from "./sshsig/sig_parser.js"
|
|
6
5
|
import { Sig } from "./sshsig/sig.js"
|
|
7
6
|
import { group, Logger, LogLevel } from "@andrewheberle/ts-slog"
|
|
8
7
|
|
|
9
|
-
//
|
|
10
|
-
const
|
|
8
|
+
// a SHA256 fingerprint as shown by ssh-keygen -l, which is unpadded base64
|
|
9
|
+
const FingerprintFormat = /^SHA256:[A-Za-z0-9+/]{43}$/
|
|
11
10
|
|
|
12
11
|
export const Namespace = "proof-of-possession@com.github.serverless-ssh-ca.andrewheberle"
|
|
13
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The namespace used for proofs of possession that are bound to a request,
|
|
15
|
+
* kept separate from {@link Namespace} so a proof made for one form can never
|
|
16
|
+
* be accepted as the other.
|
|
17
|
+
*/
|
|
18
|
+
export const RequestNamespace = "request-proof-of-possession@com.github.serverless-ssh-ca.andrewheberle"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns the request a proof of possession is bound to, which is appended to
|
|
22
|
+
* the signed data as `${timestamp}.${fingerprint}.${binding}`.
|
|
23
|
+
*
|
|
24
|
+
* @param method The HTTP method of the request, for example "DELETE"
|
|
25
|
+
* @param path The path of the request with any parameters filled in, for example "/api/v4/user/certificates/123"
|
|
26
|
+
* @returns The request binding, for example "DELETE /api/v4/user/certificates/123"
|
|
27
|
+
*/
|
|
28
|
+
export const requestBinding = (method: string, path: string): string => {
|
|
29
|
+
return `${method.toUpperCase()} ${path}`
|
|
30
|
+
}
|
|
31
|
+
|
|
14
32
|
export class PossessionParseError extends Error {
|
|
15
33
|
constructor(message: string, cause?: unknown) {
|
|
16
34
|
super(message)
|
|
@@ -38,9 +56,10 @@ export class PossessionParseError extends Error {
|
|
|
38
56
|
*/
|
|
39
57
|
export class ProofOfPossession {
|
|
40
58
|
readonly timestamp: number
|
|
41
|
-
|
|
59
|
+
/** The SHA256 fingerprint of the key, for example "SHA256:79mq0wtpQMTvS4+Of8VzLN0qmYWNUyTXYmqKwhEgSLs" */
|
|
60
|
+
readonly fingerprint: string
|
|
42
61
|
readonly signature: Sig
|
|
43
|
-
readonly signaturePubkey:
|
|
62
|
+
readonly signaturePubkey: PublicKey
|
|
44
63
|
readonly logger: Logger
|
|
45
64
|
private readonly data: string
|
|
46
65
|
|
|
@@ -69,10 +88,9 @@ constructor(proof: string, options?: { from?: number, logger?: Logger, skew?: nu
|
|
|
69
88
|
throw new PossessionParseError("timestamp was from the future")
|
|
70
89
|
}
|
|
71
90
|
try {
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
throw new PossessionParseError("fingerprint did not parse")
|
|
91
|
+
// check fingerprint
|
|
92
|
+
if (!FingerprintFormat.test(fingerprintString!)) {
|
|
93
|
+
throw new PossessionParseError("proof of possession fingerprint was an invalid format")
|
|
76
94
|
}
|
|
77
95
|
|
|
78
96
|
// convert siganture from base64
|
|
@@ -86,26 +104,26 @@ constructor(proof: string, options?: { from?: number, logger?: Logger, skew?: nu
|
|
|
86
104
|
throw new PossessionParseError("proof of possession signature could not be parsed", err)
|
|
87
105
|
}
|
|
88
106
|
|
|
89
|
-
this.fingerprint =
|
|
107
|
+
this.fingerprint = fingerprintString!
|
|
90
108
|
this.data = `${timestamp}.${fingerprintString}`
|
|
91
|
-
this.signaturePubkey =
|
|
109
|
+
this.signaturePubkey = PublicKey.parse(this.signature.publickey.toString())
|
|
92
110
|
|
|
93
111
|
// ensure the provided fingerprint matches the signature public key
|
|
94
|
-
if (
|
|
112
|
+
if (this.fingerprint !== this.signaturePubkey.fingerprint()) {
|
|
95
113
|
throw new PossessionParseError("proof of possession fingerprint does not match signature public key")
|
|
96
114
|
}
|
|
97
115
|
|
|
98
116
|
this.logger.debug("constructor completed",
|
|
99
117
|
...group("this",
|
|
100
118
|
"timestamp", this.timestamp,
|
|
101
|
-
"fingerprint", this.fingerprint
|
|
102
|
-
"signaturePubkey.fingerprint()", this.signaturePubkey.fingerprint()
|
|
119
|
+
"fingerprint", this.fingerprint,
|
|
120
|
+
"signaturePubkey.fingerprint()", this.signaturePubkey.fingerprint(),
|
|
103
121
|
),
|
|
104
122
|
)
|
|
105
123
|
} catch (err) {
|
|
106
124
|
switch (true) {
|
|
107
|
-
case (err instanceof
|
|
108
|
-
throw new PossessionParseError(
|
|
125
|
+
case (err instanceof KeyParseError):
|
|
126
|
+
throw new PossessionParseError(`proof of possession signature key could not be used: ${err.message}`, err)
|
|
109
127
|
default:
|
|
110
128
|
throw err
|
|
111
129
|
}
|
|
@@ -120,26 +138,42 @@ constructor(proof: string, options?: { from?: number, logger?: Logger, skew?: nu
|
|
|
120
138
|
return await verify(this.signature, this.data, { namespace: namespace || Namespace })
|
|
121
139
|
}
|
|
122
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Verifies the proof was made for a specific request, so it cannot be
|
|
143
|
+
* replayed against another endpoint or with other path parameters (such
|
|
144
|
+
* as a different certificate serial).
|
|
145
|
+
*
|
|
146
|
+
* The signed data is `${timestamp}.${fingerprint}.${METHOD} ${path}` using
|
|
147
|
+
* the {@link RequestNamespace} namespace.
|
|
148
|
+
*
|
|
149
|
+
* @param method The HTTP method of the request
|
|
150
|
+
* @param path The path of the request with any parameters filled in
|
|
151
|
+
* @returns true if signature is valid for the request, false otherwise
|
|
152
|
+
*/
|
|
153
|
+
async verifyRequest(method: string, path: string): Promise<boolean> {
|
|
154
|
+
return await verify(this.signature, `${this.data}.${requestBinding(method, path)}`, { namespace: RequestNamespace })
|
|
155
|
+
}
|
|
156
|
+
|
|
123
157
|
/**
|
|
124
158
|
*
|
|
125
159
|
* @param keys array of keys to verify finterprint against
|
|
126
160
|
* @returns true if the proof of possession fingerprint matches any of the provided keys, false otherwise
|
|
127
161
|
*/
|
|
128
|
-
matches(key:
|
|
129
|
-
matches(...keys:
|
|
162
|
+
matches(key: PublicKey): boolean
|
|
163
|
+
matches(...keys: PublicKey[]): boolean
|
|
130
164
|
|
|
131
|
-
matches(...keys:
|
|
165
|
+
matches(...keys: PublicKey[]): boolean {
|
|
132
166
|
this.logger.debug("matches() called", "in", "ProofOfPossession", "keys", keys.length)
|
|
133
167
|
try {
|
|
134
168
|
for (const key of keys) {
|
|
135
169
|
// confirm proof of possession fingerprint matches keys
|
|
136
|
-
this.logger.debug("
|
|
137
|
-
if (
|
|
170
|
+
this.logger.debug("comparing fingerprint", "in", "ProofOfPossession", "fingerprint", this.fingerprint, "key", key.toString())
|
|
171
|
+
if (this.fingerprint !== key.fingerprint())
|
|
138
172
|
return false
|
|
139
173
|
|
|
140
174
|
// also confirm key used to sign proof of possession matches
|
|
141
|
-
this.logger.debug("
|
|
142
|
-
if (!this.signaturePubkey.
|
|
175
|
+
this.logger.debug("comparing signature public key", "in", "ProofOfPossession", "signaturePubkey", this.signaturePubkey.fingerprint(), "key", key.toString())
|
|
176
|
+
if (!this.signaturePubkey.equals(key))
|
|
143
177
|
return false
|
|
144
178
|
}
|
|
145
179
|
this.logger.debug("matches() completed", "in", "ProofOfPossession", "keys", keys.length)
|
|
@@ -163,10 +197,10 @@ export class PossessionMatchError extends Error {
|
|
|
163
197
|
}
|
|
164
198
|
|
|
165
199
|
export class RenewalProofOfPossession extends ProofOfPossession {
|
|
166
|
-
override matches(key:
|
|
167
|
-
override matches(...keys:
|
|
200
|
+
override matches(key: PublicKey): never
|
|
201
|
+
override matches(...keys: PublicKey[]): boolean
|
|
168
202
|
|
|
169
|
-
override matches(...keys:
|
|
203
|
+
override matches(...keys: PublicKey[]): boolean {
|
|
170
204
|
this.logger.debug("matches() called", "in", "RenewalProofOfPossession", "keys", keys.length)
|
|
171
205
|
if (keys.length === 1) {
|
|
172
206
|
throw new PossessionMatchError("must verify both public key and certificate")
|
|
@@ -174,11 +208,11 @@ export class RenewalProofOfPossession extends ProofOfPossession {
|
|
|
174
208
|
|
|
175
209
|
try {
|
|
176
210
|
for (const key of keys) {
|
|
177
|
-
this.logger.debug("
|
|
178
|
-
if (
|
|
211
|
+
this.logger.debug("comparing fingerprint", "in", "RenewalProofOfPossession", "fingerprint", this.fingerprint, "key", key.toString())
|
|
212
|
+
if (this.fingerprint !== key.fingerprint())
|
|
179
213
|
return false
|
|
180
|
-
this.logger.debug("
|
|
181
|
-
if (!this.signaturePubkey.
|
|
214
|
+
this.logger.debug("comparing signature public key", "in", "RenewalProofOfPossession", "signaturePubkey", this.signaturePubkey.fingerprint(), "key", key.toString())
|
|
215
|
+
if (!this.signaturePubkey.equals(key))
|
|
182
216
|
return false
|
|
183
217
|
}
|
|
184
218
|
this.logger.debug("matches() completed", "in", "RenewalProofOfPossession", "keys", keys.length)
|