@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
package/src/krl.ts ADDED
@@ -0,0 +1,414 @@
1
+ import type { PrivateKey } from "sshpk"
2
+
3
+ // ── Constants ────────────────────────────────────────────────────────────────
4
+
5
+ const KRL_MAGIC = 0x5353484b524c0a00n // "SSHKRL\n\0"
6
+ const KRL_FORMAT_VERSION = 1
7
+
8
+ const KRL_SECTION_CERTIFICATES = 1
9
+ const KRL_SECTION_CERT_SERIAL_LIST = 0x20
10
+ const KRL_SECTION_CERT_KEY_ID = 0x23
11
+
12
+ // ── Low-level encoding helpers ───────────────────────────────────────────────
13
+
14
+ /** Encode a uint32 big-endian. */
15
+ function encodeUint32(value: number): Uint8Array {
16
+ const buf = new Uint8Array(4)
17
+ new DataView(buf.buffer).setUint32(0, value, false)
18
+ return buf
19
+ }
20
+
21
+ /** Encode a uint64 big-endian from a bigint. */
22
+ function encodeUint64(value: bigint): Uint8Array {
23
+ const buf = new Uint8Array(8)
24
+ new DataView(buf.buffer).setBigUint64(0, value, false)
25
+ return buf
26
+ }
27
+
28
+ /**
29
+ * Encode an SSH "string" (RFC 4251 §5): uint32 length followed by raw bytes.
30
+ * Accepts either a Uint8Array (raw bytes) or a JS string (encoded as UTF-8).
31
+ */
32
+ function encodeString(value: Uint8Array | string): Uint8Array {
33
+ const bytes =
34
+ typeof value === "string" ? new TextEncoder().encode(value) : value
35
+ const out = new Uint8Array(4 + bytes.length)
36
+ new DataView(out.buffer).setUint32(0, bytes.length, false)
37
+ out.set(bytes, 4)
38
+ return out
39
+ }
40
+
41
+ /** Concatenate an arbitrary number of Uint8Arrays into one. */
42
+ function concat(...parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
43
+ const total = parts.reduce((n, p) => n + p.length, 0)
44
+ const out = new Uint8Array(total)
45
+ let offset = 0
46
+ for (const part of parts) {
47
+ out.set(part, offset)
48
+ offset += part.length
49
+ }
50
+ return out
51
+ }
52
+
53
+ /**
54
+ * Wrap bytes as a KRL section: byte section_type + string section_data.
55
+ * The spec defines each section as: byte type | string data
56
+ */
57
+ function encodeSection(sectionType: number, data: Uint8Array): Uint8Array {
58
+ return concat(new Uint8Array([sectionType]), encodeString(data))
59
+ }
60
+
61
+ // ── CA public-key extraction ─────────────────────────────────────────────────
62
+
63
+ /**
64
+ * Extract the SSH wire-format public key bytes from an sshpk PrivateKey.
65
+ *
66
+ * sshpk's "ssh" format produces a string like:
67
+ * ssh-ed25519 AAAA...base64... [comment]
68
+ *
69
+ * The base64 blob *is* the SSH wire encoding of the public key, so we just
70
+ * decode it. This works for both Ed25519 and ECDSA keys.
71
+ */
72
+ function caKeyWireBytes(caKey: PrivateKey): Uint8Array {
73
+ const sshString = caKey.toPublic().toString("ssh")
74
+ // Format: "<type> <base64> [<comment>]" — we want the second token.
75
+ const base64 = sshString.trim().split(/\s+/)[1]
76
+ if (!base64) {
77
+ throw new Error("Unexpected sshpk 'ssh' format output: " + sshString)
78
+ }
79
+ const binary = atob(base64)
80
+ const bytes = new Uint8Array(binary.length)
81
+ for (let i = 0; i < binary.length; i++) {
82
+ bytes[i] = binary.charCodeAt(i)
83
+ }
84
+ return bytes
85
+ }
86
+
87
+ // ── Constants ────────────────────────────────────────────────────────────────
88
+
89
+ const SSHSIG_MAGIC = new TextEncoder().encode("SSHSIG")
90
+ const SIG_VERSION = 1
91
+ const NAMESPACE = "krl@com.github.serverless-ssh-ca.andrewheberle"
92
+ const HASH_ALGORITHM = "sha512"
93
+
94
+ // ── Helpers ──────────────────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * PEM string → raw DER bytes (strips header/footer and decodes base64).
98
+ * Works for both PKCS8 ("-----BEGIN PRIVATE KEY-----") and any other PEM type.
99
+ */
100
+ function pemToDer(pem: string): Uint8Array<ArrayBuffer> {
101
+ const lines = pem
102
+ .trim()
103
+ .split("\n")
104
+ .filter((l) => !l.startsWith("-----"))
105
+ const binary = atob(lines.join(""))
106
+ const bytes = new Uint8Array(binary.length)
107
+ for (let i = 0; i < binary.length; i++) {
108
+ bytes[i] = binary.charCodeAt(i)
109
+ }
110
+ return bytes
111
+ }
112
+
113
+ /**
114
+ * Import an sshpk PrivateKey into a WebCrypto CryptoKey.
115
+ * Supports Ed25519 and ECDSA (P-256 / P-384 / P-521).
116
+ */
117
+ async function importPrivateKey(caKey: PrivateKey): Promise<{
118
+ cryptoKey: CryptoKey
119
+ sigAlgo: string // SSH signature algorithm name
120
+ webCryptoAlgo: SubtleCryptoSignAlgorithm
121
+ }> {
122
+ const pkcs8Pem = caKey.toString("pkcs8")
123
+ const der = pemToDer(pkcs8Pem)
124
+ const type = caKey.type // "ed25519" | "ecdsa" | etc.
125
+ const curve = (caKey as unknown as { curve?: string }).curve
126
+
127
+ if (type === "ed25519") {
128
+ const cryptoKey = await crypto.subtle.importKey(
129
+ "pkcs8",
130
+ der,
131
+ { name: "Ed25519" },
132
+ false,
133
+ ["sign"],
134
+ )
135
+ return {
136
+ cryptoKey,
137
+ sigAlgo: "ssh-ed25519",
138
+ webCryptoAlgo: { name: "Ed25519" },
139
+ }
140
+ } else if (type === "ecdsa") {
141
+ let namedCurve: string
142
+ let sigAlgo: string
143
+ let hash: string
144
+
145
+ if (curve === "nistp256") {
146
+ namedCurve = "P-256"
147
+ sigAlgo = "ecdsa-sha2-nistp256"
148
+ hash = "SHA-256"
149
+ } else if (curve === "nistp384") {
150
+ namedCurve = "P-384"
151
+ sigAlgo = "ecdsa-sha2-nistp384"
152
+ hash = "SHA-384"
153
+ } else if (curve === "nistp521") {
154
+ namedCurve = "P-521"
155
+ sigAlgo = "ecdsa-sha2-nistp521"
156
+ hash = "SHA-512"
157
+ } else {
158
+ throw new Error(`Unsupported ECDSA curve: ${curve}`)
159
+ }
160
+
161
+ const cryptoKey = await crypto.subtle.importKey(
162
+ "pkcs8",
163
+ der,
164
+ { name: "ECDSA", namedCurve },
165
+ false,
166
+ ["sign"],
167
+ )
168
+ return {
169
+ cryptoKey,
170
+ sigAlgo,
171
+ webCryptoAlgo: { name: "ECDSA", hash: { name: hash } },
172
+ }
173
+ } else {
174
+ throw new Error(`Unsupported key type: ${type}`)
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Convert a WebCrypto ECDSA signature (IEEE P1363: raw r||s) to the SSH wire
180
+ * encoding: string(algo) + string(mpint(r) + mpint(s)).
181
+ *
182
+ * SSH mpints are big-endian, with a leading 0x00 byte if the high bit is set.
183
+ */
184
+ function p1363ToSshEcdsaSig(
185
+ p1363: Uint8Array,
186
+ sigAlgo: string,
187
+ ): Uint8Array {
188
+ const half = p1363.length / 2
189
+ const r = p1363.slice(0, half)
190
+ const s = p1363.slice(half)
191
+
192
+ function encodeMpint(n: Uint8Array): Uint8Array {
193
+ // Strip leading zero bytes, then re-pad if high bit set.
194
+ let start = 0
195
+ while (start < n.length - 1 && n[start] === 0) start++
196
+ const trimmed = n.slice(start)
197
+ const padded =
198
+ trimmed[0]! & 0x80 ? concat(new Uint8Array([0x00]), trimmed) : trimmed
199
+ return encodeString(padded)
200
+ }
201
+
202
+ const innerSig = concat(encodeMpint(r), encodeMpint(s))
203
+ return concat(encodeString(sigAlgo), encodeString(innerSig))
204
+ }
205
+
206
+ /**
207
+ * Wrap an Ed25519 WebCrypto signature into SSH wire encoding:
208
+ * string(algo) + string(sig_bytes)
209
+ */
210
+ function wrapEd25519Sig(sig: Uint8Array): Uint8Array {
211
+ return concat(encodeString("ssh-ed25519"), encodeString(sig))
212
+ }
213
+
214
+ /**
215
+ * Base64-encode bytes and wrap in SSH signature armor at 76 chars per line.
216
+ */
217
+ function armorSignature(bytes: Uint8Array): string {
218
+ let b64 = btoa(String.fromCharCode(...bytes))
219
+ const lines: string[] = []
220
+ for (let i = 0; i < b64.length; i += 76) {
221
+ lines.push(b64.slice(i, i + 76))
222
+ }
223
+ return [
224
+ "-----BEGIN SSH SIGNATURE-----",
225
+ ...lines,
226
+ "-----END SSH SIGNATURE-----",
227
+ ].join("\n")
228
+ }
229
+
230
+ // ── KRLBuilder ───────────────────────────────────────────────────────────────
231
+
232
+ export class KRLBuilder {
233
+ private readonly _caKey: PrivateKey
234
+ private readonly _serials: bigint[] = []
235
+ private readonly _keyIds: string[] = []
236
+ private _krl: ArrayBuffer | null = null
237
+
238
+ constructor(caKey: PrivateKey) {
239
+ this._caKey = caKey
240
+ }
241
+
242
+ /**
243
+ * Add certificate serial numbers to be revoked.
244
+ * May be called multiple times all serials are merged into one section.
245
+ *
246
+ * This must not be called after generate() has been called
247
+ */
248
+ addSerials(serials: bigint[]): this {
249
+ if (this._krl !== null) {
250
+ throw new Error("addSerials cannot be called after KRL has been generated")
251
+ }
252
+ this._serials.push(...serials)
253
+ return this
254
+ }
255
+
256
+ /**
257
+ * Add certificate key ID strings to be revoked.
258
+ * May be called multiple times all key IDs are merged into one section.
259
+ *
260
+ * This must not be called after generate() has been called
261
+ */
262
+ addKeyIds(keyIds: string[]): this {
263
+ if (this._krl !== null) {
264
+ throw new Error("addKeyIds cannot be called after KRL has been generated")
265
+ }
266
+ this._keyIds.push(...keyIds)
267
+ return this
268
+ }
269
+
270
+ /**
271
+ * Generate the binary KRL
272
+ *
273
+ * @param krlVersion Optional KRL version number (default: 1).
274
+ * @param comment Optional comment string embedded in the header.
275
+ */
276
+ generate(krlVersion: bigint = 1n, comment = ""): ArrayBuffer {
277
+ if (this._krl !== null) {
278
+ return this._krl
279
+ }
280
+
281
+ // ── Header ──────────────────────────────────────────────────────────────
282
+ const generatedDate = BigInt(Math.floor(Date.now() / 1000))
283
+
284
+ const header = concat(
285
+ encodeUint64(KRL_MAGIC),
286
+ encodeUint32(KRL_FORMAT_VERSION),
287
+ encodeUint64(krlVersion),
288
+ encodeUint64(generatedDate),
289
+ encodeUint64(0n), // flags — none defined
290
+ encodeString(""), // reserved
291
+ encodeString(comment)
292
+ )
293
+
294
+ // ── Certificate sub-sections ─────────────────────────────────────────────
295
+
296
+ const certSubSections: Uint8Array[] = []
297
+
298
+ // KRL_SECTION_CERT_SERIAL_LIST
299
+ if (this._serials.length > 0) {
300
+ const serialBytes = concat(...this._serials.map(encodeUint64))
301
+ certSubSections.push(
302
+ encodeSection(KRL_SECTION_CERT_SERIAL_LIST, serialBytes)
303
+ )
304
+ }
305
+
306
+ // KRL_SECTION_CERT_KEY_ID
307
+ if (this._keyIds.length > 0) {
308
+ const keyIdBytes = concat(
309
+ ...this._keyIds.map((id) => encodeString(id))
310
+ )
311
+ certSubSections.push(
312
+ encodeSection(KRL_SECTION_CERT_KEY_ID, keyIdBytes)
313
+ )
314
+ }
315
+
316
+ // ── KRL_SECTION_CERTIFICATES wrapper ────────────────────────────────────
317
+ //
318
+ // Format (inside the outer section's string data):
319
+ // string ca_key
320
+ // string reserved
321
+ // <one or more cert sub-sections>
322
+ //
323
+ const certSectionData = concat(
324
+ encodeString(caKeyWireBytes(this._caKey)), // ca_key
325
+ encodeString(""), // reserved
326
+ ...certSubSections
327
+ )
328
+
329
+ const certificatesSection = encodeSection(
330
+ KRL_SECTION_CERTIFICATES,
331
+ certSectionData
332
+ )
333
+
334
+ // ── Assemble final KRL ───────────────────────────────────────────────────
335
+ const krl = concat(header, certificatesSection)
336
+
337
+ this._krl = krl.buffer as ArrayBuffer
338
+
339
+ return this._krl
340
+ }
341
+
342
+ /**
343
+ * Return the SSHSIG signature of the KRL.
344
+ *
345
+ * This must be called after generate()
346
+ *
347
+ * The returned string can be verified with ssh-keygen:
348
+ * echo -n "$KRL_BYTES" | ssh-keygen -Y verify -f allowed_signers \
349
+ * -I <identity> -n file -s <sig_file>
350
+ */
351
+ async signature(): Promise<string> {
352
+ let krl: ArrayBuffer
353
+ if (this._krl === null) {
354
+ krl = this.generate()
355
+ } else {
356
+ krl = this._krl
357
+ }
358
+ const { cryptoKey, sigAlgo, webCryptoAlgo } = await importPrivateKey(this._caKey)
359
+
360
+ // 1. Hash the KRL bytes with SHA-512.
361
+ const krlHash = new Uint8Array(
362
+ await crypto.subtle.digest("SHA-512", krl),
363
+ )
364
+
365
+ // 2. Build the signed data blob.
366
+ //
367
+ // byte[6] "SSHSIG"
368
+ // string namespace
369
+ // string reserved (empty)
370
+ // string hash_algorithm
371
+ // string H(message)
372
+ const signedData = concat(
373
+ SSHSIG_MAGIC,
374
+ encodeString(NAMESPACE),
375
+ encodeString(""), // reserved
376
+ encodeString(HASH_ALGORITHM),
377
+ encodeString(krlHash),
378
+ )
379
+
380
+ // 3. Sign the blob.
381
+ const rawSig = new Uint8Array(
382
+ await crypto.subtle.sign(webCryptoAlgo, cryptoKey, signedData),
383
+ )
384
+
385
+ // 4. Encode the signature into SSH wire format.
386
+ const sshSig =
387
+ sigAlgo === "ssh-ed25519"
388
+ ? wrapEd25519Sig(rawSig)
389
+ : p1363ToSshEcdsaSig(rawSig, sigAlgo)
390
+
391
+ // 5. Assemble the outer SSHSIG blob.
392
+ //
393
+ // byte[6] "SSHSIG"
394
+ // uint32 SIG_VERSION (1)
395
+ // string publickey
396
+ // string namespace
397
+ // string reserved (empty)
398
+ // string hash_algorithm
399
+ // string signature
400
+ const publicKeyWire = caKeyWireBytes(this._caKey)
401
+ const sigBlob = concat(
402
+ SSHSIG_MAGIC,
403
+ encodeUint32(SIG_VERSION),
404
+ encodeString(publicKeyWire),
405
+ encodeString(NAMESPACE),
406
+ encodeString(""), // reserved
407
+ encodeString(HASH_ALGORITHM),
408
+ encodeString(sshSig),
409
+ )
410
+
411
+ // 6. Armor and return.
412
+ return armorSignature(sigBlob)
413
+ }
414
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { SshCaBindings } from "./types"
2
+ import { Logger, LogLevel } from "@andrewheberle/ts-slog"
3
+
4
+ export const logger = (env: SshCaBindings) => (
5
+ new Logger({ minLevel: env.LOG_LEVEL as string === "debug" ? LogLevel.Debug : LogLevel.Info })
6
+ )
package/src/proof.ts ADDED
@@ -0,0 +1,187 @@
1
+ import { ms } from "itty-time"
2
+ import { Fingerprint, FingerprintFormatError, Key, parseFingerprint, parseKey } from "sshpk"
3
+ import { verify } from "./sshsig"
4
+ import { parse } from "./sshsig/sig_parser"
5
+ import { Sig } from "./sshsig/sig"
6
+ import { group, Logger, LogLevel } from "@andrewheberle/ts-slog"
7
+
8
+ export const Namespace = "proof-of-possession@com.github.serverless-ssh-ca.andrewheberle"
9
+
10
+ export class PossessionParseError extends Error {
11
+ constructor(message: string, cause?: unknown) {
12
+ super(message)
13
+ this.name = "PossessionParseError"
14
+ this.cause = cause
15
+
16
+ // This is necessary for proper stack trace in TypeScript
17
+ Object.setPrototypeOf(this, PossessionParseError.prototype)
18
+ }
19
+ }
20
+
21
+ /**
22
+ * ProofOfPossession is used to check/enforce proof of possession for a SSH
23
+ * private key.
24
+ *
25
+ * This is used to verify that the requester of a certificate actually
26
+ * possesses the private key corresponding to the public key in the certificate
27
+ * request.
28
+ *
29
+ * The proof of possession is a signed message containing a timestamp and the
30
+ * fingerprint of the public key.
31
+ *
32
+ * The signature is made with the private key corresponding to the public key
33
+ * in the certificate request and is a BASE64 encoded SSHSIG signature.
34
+ */
35
+ export class ProofOfPossession {
36
+ readonly timestamp: number
37
+ readonly fingerprint: Fingerprint
38
+ readonly signature: Sig
39
+ readonly signaturePubkey: Key
40
+ readonly logger: Logger
41
+ private readonly data: string
42
+
43
+ constructor(proof: string, options?: { from?: number, logger?: Logger, skew?: number }) {
44
+ this.logger = options?.logger !== undefined ? options.logger : new Logger({ minLevel: LogLevel.None })
45
+ const parts = proof.split(".")
46
+ if (parts.length !== 3) {
47
+ throw new PossessionParseError("invalid proof of possession format")
48
+ }
49
+
50
+ const [timestampStr, fingerprintString, signatureBase64] = parts
51
+
52
+ // verify timestamp
53
+ const timestamp: number = parseInt(timestampStr!, 10)
54
+ if (isNaN(timestamp)) {
55
+ throw new PossessionParseError("timestamp was not a number")
56
+ }
57
+
58
+ const now = options?.from !== undefined ? options?.from : Date.now()
59
+ const age = now - timestamp
60
+ const skew = options?.skew !== undefined ? options.skew : ms("90 seconds")
61
+ if (age > skew) {
62
+ throw new PossessionParseError("timestamp too old")
63
+ }
64
+ if (age + skew < 0) {
65
+ throw new PossessionParseError("timestamp was from the future")
66
+ }
67
+ try {
68
+ // parse fingerprint
69
+ const fingerprint = parseFingerprint(fingerprintString!)
70
+ if (fingerprint === undefined) {
71
+ throw new PossessionParseError("fingerprint did not parse")
72
+ }
73
+
74
+ // convert siganture from base64
75
+ const signature = Buffer.from(signatureBase64!, "base64").toString()
76
+
77
+ // set values
78
+ this.timestamp = timestamp
79
+ try {
80
+ this.signature = parse(signature)
81
+ } catch (err) {
82
+ throw new PossessionParseError("proof of possession signature could not be parsed", err)
83
+ }
84
+
85
+ this.fingerprint = fingerprint
86
+ this.data = `${timestamp}.${fingerprintString}`
87
+ this.signaturePubkey = parseKey(this.signature.publickey.toString())
88
+
89
+ // ensure the provided fingerprint matches the signature public key
90
+ if (!this.fingerprint.matches(this.signaturePubkey)) {
91
+ throw new PossessionParseError("proof of possession fingerprint does not match signature public key")
92
+ }
93
+
94
+ this.logger.debug("constructor completed",
95
+ ...group("this",
96
+ "timestamp", this.timestamp,
97
+ "fingerprint", this.fingerprint.toString("base64"),
98
+ "signaturePubkey.fingerprint()", this.signaturePubkey.fingerprint().toString("base64"),
99
+ ),
100
+ )
101
+ } catch (err) {
102
+ switch (true) {
103
+ case (err instanceof FingerprintFormatError):
104
+ throw new PossessionParseError("proof of possession fingerprint was an invalid format", err)
105
+ default:
106
+ throw err
107
+ }
108
+ }
109
+ }
110
+
111
+ /**
112
+ *
113
+ * @returns true if signature is valid, false otherwise
114
+ */
115
+ async verify(namespace?: string): Promise<boolean> {
116
+ return await verify(this.signature, this.data, { namespace: namespace || Namespace })
117
+ }
118
+
119
+ /**
120
+ *
121
+ * @param keys array of keys to verify finterprint against
122
+ * @returns true if the proof of possession fingerprint matches any of the provided keys, false otherwise
123
+ */
124
+ matches(key: Key): boolean
125
+ matches(...keys: Key[]): boolean
126
+
127
+ matches(...keys: Key[]): boolean {
128
+ this.logger.debug("matches() called", "in", "ProofOfPossession", "keys", keys.length)
129
+ try {
130
+ for (const key of keys) {
131
+ // confirm proof of possession fingerprint matches keys
132
+ this.logger.debug("this.fingerprint.matches()", "in", "ProofOfPossession", "fingerprint", this.fingerprint.toString("base64"), "key", key.toString("ssh"))
133
+ if (!this.fingerprint.matches(key))
134
+ return false
135
+
136
+ // also confirm key used to sign proof of possession matches
137
+ this.logger.debug("this.signaturePubkey.fingerprint().matches()", "in", "ProofOfPossession", "signaturePubkey", this.signaturePubkey.fingerprint().toString("base64"), "key", key.toString("ssh"))
138
+ if (!this.signaturePubkey.fingerprint().matches(key))
139
+ return false
140
+ }
141
+ this.logger.debug("matches() completed", "in", "ProofOfPossession", "keys", keys.length)
142
+ return true
143
+ } catch (err) {
144
+ this.logger.error("matches() error", "in", "ProofOfPossession", "error", err)
145
+ throw err
146
+ }
147
+ }
148
+ }
149
+
150
+ export class PossessionMatchError extends Error {
151
+ constructor(message: string, cause?: unknown) {
152
+ super(message)
153
+ this.name = "PossessionMatchError"
154
+ this.cause = cause
155
+
156
+ // This is necessary for proper stack trace in TypeScript
157
+ Object.setPrototypeOf(this, PossessionMatchError.prototype)
158
+ }
159
+ }
160
+
161
+ export class RenewalProofOfPossession extends ProofOfPossession {
162
+ override matches(key: Key): never
163
+ override matches(...keys: Key[]): boolean
164
+
165
+ override matches(...keys: Key[]): boolean {
166
+ this.logger.debug("matches() called", "in", "RenewalProofOfPossession", "keys", keys.length)
167
+ if (keys.length === 1) {
168
+ throw new PossessionMatchError("must verify both public key and certificate")
169
+ }
170
+
171
+ try {
172
+ for (const key of keys) {
173
+ this.logger.debug("this.fingerprint.matches()", "in", "RenewalProofOfPossession", "fingerprint", this.fingerprint.toString("base64"), "key", key.toString("ssh"))
174
+ if (!this.fingerprint.matches(key))
175
+ return false
176
+ this.logger.debug("this.signaturePubkey.fingerprint().matches()", "in", "RenewalProofOfPossession", "signaturePubkey", this.signaturePubkey.fingerprint().toString("base64"), "key", key.toString("ssh"))
177
+ if (!this.signaturePubkey.fingerprint().matches(key))
178
+ return false
179
+ }
180
+ this.logger.debug("matches() completed", "in", "RenewalProofOfPossession", "keys", keys.length)
181
+ return true
182
+ } catch (err) {
183
+ this.logger.error("error in matches()", "in", "RenewalProofOfPossession", "error", err)
184
+ throw err
185
+ }
186
+ }
187
+ }
package/src/router.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { fromHono } from "chanfana"
2
+ import { Hono, type Context } from "hono"
3
+ import { createApi as apiv3 } from "./api/v3"
4
+ import { HTTPException } from "hono/http-exception"
5
+ import { logger } from "./logger"
6
+ import type { SshCaBindings } from "./types"
7
+
8
+ export type CFArgs = [SshCaBindings, ExecutionContext]
9
+ export type AppContext = Context<{ Bindings: SshCaBindings }>
10
+
11
+ export const createApp = (env: SshCaBindings) => {
12
+ const hono = new Hono()
13
+
14
+ // add error handling
15
+ hono.onError((err, c) => {
16
+ if (err instanceof HTTPException) {
17
+ return err.getResponse()
18
+ }
19
+
20
+ logger(env).error("unexpected error from router", "error", err)
21
+ return c.json({ success: false, errors: [{ code: 7000, message: "Internal Server Error" }] }, 500)
22
+ })
23
+
24
+ const app = fromHono(hono)
25
+
26
+ app.registry.registerComponent("securitySchemes", "oidcAuth", {
27
+ type: 'http',
28
+ scheme: 'bearer',
29
+ bearerFormat: 'JWT',
30
+ })
31
+
32
+ app.route("/api/v3", apiv3(env))
33
+
34
+ return app
35
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Removes armoring from text SSH signatures and decodes it to raw bytes.
3
+ *
4
+ * @param {string} text SSH signature in armored format.
5
+ * @returns {Uint8Array} Raw bytes of the signature.
6
+ */
7
+ export function dearmor(text: string) {
8
+ const lines = text.trim().split("\n");
9
+ const first = lines.shift();
10
+ if (first !== "-----BEGIN SSH SIGNATURE-----") {
11
+ throw new Error(
12
+ "Bad header line, expected -----BEGIN SSH SIGNATURE----- got: " + first,
13
+ );
14
+ }
15
+ const last = lines.pop();
16
+ if (last !== "-----END SSH SIGNATURE-----") {
17
+ throw new Error(
18
+ "Bad trailer line, expected -----END SSH SIGNATURE----- got: " + last,
19
+ );
20
+ }
21
+ return Uint8Array.from(
22
+ atob(lines.join("")).split("").map((x) => x.charCodeAt(0)),
23
+ );
24
+ }