@andrewheberle/serverless-ssh-ca 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +55 -1
  2. package/dist/api/operations.d.ts +49 -0
  3. package/dist/api/operations.d.ts.map +1 -0
  4. package/dist/api/operations.js +175 -0
  5. package/dist/api/operations.js.map +1 -0
  6. package/dist/api/schema.d.ts +71 -0
  7. package/dist/api/schema.d.ts.map +1 -0
  8. package/dist/api/schema.js +107 -0
  9. package/dist/api/schema.js.map +1 -0
  10. package/dist/api/v3/index.d.ts.map +1 -1
  11. package/dist/api/v3/index.js +13 -202
  12. package/dist/api/v3/index.js.map +1 -1
  13. package/dist/api/v3/schema.d.ts +23 -68
  14. package/dist/api/v3/schema.d.ts.map +1 -1
  15. package/dist/api/v3/schema.js +10 -100
  16. package/dist/api/v3/schema.js.map +1 -1
  17. package/dist/api/v4/index.d.ts +8 -0
  18. package/dist/api/v4/index.d.ts.map +1 -0
  19. package/dist/api/v4/index.js +155 -0
  20. package/dist/api/v4/index.js.map +1 -0
  21. package/dist/api/v4/schema.d.ts +268 -0
  22. package/dist/api/v4/schema.d.ts.map +1 -0
  23. package/dist/api/v4/schema.js +182 -0
  24. package/dist/api/v4/schema.js.map +1 -0
  25. package/dist/certificate.d.ts +9 -6
  26. package/dist/certificate.d.ts.map +1 -1
  27. package/dist/certificate.js +55 -99
  28. package/dist/certificate.js.map +1 -1
  29. package/dist/db/index.d.ts +17 -5
  30. package/dist/db/index.d.ts.map +1 -1
  31. package/dist/db/index.js +39 -23
  32. package/dist/db/index.js.map +1 -1
  33. package/dist/krl.d.ts +1 -1
  34. package/dist/krl.d.ts.map +1 -1
  35. package/dist/krl.js +9 -163
  36. package/dist/krl.js.map +1 -1
  37. package/dist/proof.d.ts +36 -7
  38. package/dist/proof.d.ts.map +1 -1
  39. package/dist/proof.js +53 -21
  40. package/dist/proof.js.map +1 -1
  41. package/dist/router.d.ts.map +1 -1
  42. package/dist/router.js +3 -1
  43. package/dist/router.js.map +1 -1
  44. package/dist/ssh/certificate.d.ts +93 -0
  45. package/dist/ssh/certificate.d.ts.map +1 -0
  46. package/dist/ssh/certificate.js +178 -0
  47. package/dist/ssh/certificate.js.map +1 -0
  48. package/dist/ssh/encoding.d.ts +41 -0
  49. package/dist/ssh/encoding.d.ts.map +1 -0
  50. package/dist/ssh/encoding.js +70 -0
  51. package/dist/ssh/encoding.js.map +1 -0
  52. package/dist/ssh/keys.d.ts +80 -0
  53. package/dist/ssh/keys.d.ts.map +1 -0
  54. package/dist/ssh/keys.js +249 -0
  55. package/dist/ssh/keys.js.map +1 -0
  56. package/dist/ssh/signer.d.ts +35 -0
  57. package/dist/ssh/signer.d.ts.map +1 -0
  58. package/dist/ssh/signer.js +99 -0
  59. package/dist/ssh/signer.js.map +1 -0
  60. package/dist/types.d.ts +12 -17
  61. package/dist/types.d.ts.map +1 -1
  62. package/dist/utils.d.ts +40 -21
  63. package/dist/utils.d.ts.map +1 -1
  64. package/dist/utils.js +87 -54
  65. package/dist/utils.js.map +1 -1
  66. package/openapi.json +1542 -1
  67. package/package.json +4 -3
  68. package/src/api/operations.ts +211 -0
  69. package/src/api/schema.ts +137 -0
  70. package/src/api/v3/index.ts +20 -248
  71. package/src/api/v3/schema.ts +26 -125
  72. package/src/api/v4/index.ts +265 -0
  73. package/src/api/v4/schema.ts +235 -0
  74. package/src/certificate.ts +62 -123
  75. package/src/db/index.ts +41 -26
  76. package/src/krl.ts +10 -195
  77. package/src/proof.ts +65 -31
  78. package/src/router.ts +3 -1
  79. package/src/ssh/certificate.ts +236 -0
  80. package/src/ssh/encoding.ts +81 -0
  81. package/src/ssh/keys.ts +294 -0
  82. package/src/ssh/signer.ts +129 -0
  83. package/src/types.ts +12 -18
  84. package/src/utils.ts +99 -69
package/src/proof.ts CHANGED
@@ -1,16 +1,34 @@
1
1
  import { ms } from "itty-time"
2
- import sshpk from "sshpk"
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
- // sshpk is CommonJS and Node's ESM loader cannot detect most of its named exports, so values are taken from the default import
10
- const { FingerprintFormatError, parseFingerprint, parseKey } = sshpk
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
- readonly fingerprint: Fingerprint
59
+ /** The SHA256 fingerprint of the key, for example "SHA256:79mq0wtpQMTvS4+Of8VzLN0qmYWNUyTXYmqKwhEgSLs" */
60
+ readonly fingerprint: string
42
61
  readonly signature: Sig
43
- readonly signaturePubkey: Key
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
- // parse fingerprint
73
- const fingerprint = parseFingerprint(fingerprintString!)
74
- if (fingerprint === undefined) {
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 = fingerprint
107
+ this.fingerprint = fingerprintString!
90
108
  this.data = `${timestamp}.${fingerprintString}`
91
- this.signaturePubkey = parseKey(this.signature.publickey.toString())
109
+ this.signaturePubkey = PublicKey.parse(this.signature.publickey.toString())
92
110
 
93
111
  // ensure the provided fingerprint matches the signature public key
94
- if (!this.fingerprint.matches(this.signaturePubkey)) {
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.toString("base64"),
102
- "signaturePubkey.fingerprint()", this.signaturePubkey.fingerprint().toString("base64"),
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 FingerprintFormatError):
108
- throw new PossessionParseError("proof of possession fingerprint was an invalid format", err)
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: Key): boolean
129
- matches(...keys: Key[]): boolean
162
+ matches(key: PublicKey): boolean
163
+ matches(...keys: PublicKey[]): boolean
130
164
 
131
- matches(...keys: Key[]): boolean {
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("this.fingerprint.matches()", "in", "ProofOfPossession", "fingerprint", this.fingerprint.toString("base64"), "key", key.toString("ssh"))
137
- if (!this.fingerprint.matches(key))
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("this.signaturePubkey.fingerprint().matches()", "in", "ProofOfPossession", "signaturePubkey", this.signaturePubkey.fingerprint().toString("base64"), "key", key.toString("ssh"))
142
- if (!this.signaturePubkey.fingerprint().matches(key))
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: Key): never
167
- override matches(...keys: Key[]): boolean
200
+ override matches(key: PublicKey): never
201
+ override matches(...keys: PublicKey[]): boolean
168
202
 
169
- override matches(...keys: Key[]): boolean {
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("this.fingerprint.matches()", "in", "RenewalProofOfPossession", "fingerprint", this.fingerprint.toString("base64"), "key", key.toString("ssh"))
178
- if (!this.fingerprint.matches(key))
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("this.signaturePubkey.fingerprint().matches()", "in", "RenewalProofOfPossession", "signaturePubkey", this.signaturePubkey.fingerprint().toString("base64"), "key", key.toString("ssh"))
181
- if (!this.signaturePubkey.fingerprint().matches(key))
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)
package/src/router.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { fromHono } from "chanfana"
2
2
  import { Hono, type Context } from "hono"
3
3
  import { createApi as apiv3 } from "./api/v3/index.js"
4
+ import { BasePath as v4BasePath, createApi as apiv4 } from "./api/v4/index.js"
4
5
  import { HTTPException } from "hono/http-exception"
5
6
  import { logger } from "./logger.js"
6
7
  import type { SshCaBindings } from "./types.js"
@@ -24,7 +25,7 @@ export const createApp = (env: SshCaBindings) => {
24
25
  schema: {
25
26
  info: {
26
27
  title: "Serverless SSH CA API",
27
- version: "3.0.0"
28
+ version: "4.0.0"
28
29
  },
29
30
  }
30
31
  })
@@ -36,6 +37,7 @@ export const createApp = (env: SshCaBindings) => {
36
37
  })
37
38
 
38
39
  app.route("/api/v3", apiv3(env))
40
+ app.route(v4BasePath, apiv4(env))
39
41
 
40
42
  return app
41
43
  }
@@ -0,0 +1,236 @@
1
+ import * as P from "micro-packed"
2
+ import {
3
+ decodePublicKey,
4
+ PublicKeyFieldCounts,
5
+ SSHBytes,
6
+ SSHOptions,
7
+ SSHText,
8
+ SSHTextList,
9
+ } from "./encoding.js"
10
+ import { base64 } from "@scure/base"
11
+ import { KeyParseError, PublicKey } from "./keys.js"
12
+ import { verify, type Signer } from "./signer.js"
13
+
14
+ /**
15
+ * Encoding, decoding and signing of OpenSSH certificates as described in
16
+ * PROTOCOL.certkeys from OpenSSH
17
+ */
18
+
19
+ /** Whether a certificate is for a user or a host */
20
+ export type CertificateKind = "user" | "host"
21
+
22
+ const CertificateKinds: Readonly<Record<CertificateKind, number>> = {
23
+ user: 1,
24
+ host: 2,
25
+ }
26
+
27
+ /** The fields of a certificate that are set by the CA */
28
+ export type CertificateFields = {
29
+ /** Whether the certificate is for a user or a host */
30
+ kind: CertificateKind
31
+ /** The public key being certified */
32
+ publicKey: PublicKey
33
+ /** The serial number of the certificate */
34
+ serial: bigint
35
+ /** The key ID of the certificate, which is logged by sshd when it is used */
36
+ keyId: string
37
+ /** The users or hosts the certificate is valid for */
38
+ principals: string[]
39
+ /** The time from which the certificate is valid, to the second */
40
+ validAfter: Date
41
+ /** The time until which the certificate is valid, to the second */
42
+ validBefore: Date
43
+ /** The names of the extensions to include, which have no data */
44
+ extensions: string[]
45
+ }
46
+
47
+ // the fields of a certificate that follow its type, which are given the
48
+ // number of fields in the public key of the certificate
49
+ const fields = (keyFields: number) => ({
50
+ nonce: SSHBytes,
51
+ key: P.array(keyFields, SSHBytes),
52
+ serial: P.U64BE,
53
+ kind: P.U32BE,
54
+ keyId: SSHText,
55
+ principals: SSHTextList,
56
+ validAfter: P.U64BE,
57
+ validBefore: P.U64BE,
58
+ criticalOptions: SSHOptions,
59
+ extensions: SSHOptions,
60
+ reserved: SSHBytes,
61
+ signatureKey: SSHBytes,
62
+ })
63
+
64
+ const certificateType = (keyType: string): string => `${keyType}-cert-v01@openssh.com`
65
+
66
+ // a certificate without and with its signature, tagged by the certificate type
67
+ const ToBeSigned = P.tag(SSHText, Object.fromEntries(
68
+ Object.entries(PublicKeyFieldCounts).map(([keyType, count]) => [certificateType(keyType), P.struct(fields(count))]),
69
+ ))
70
+ const Certificate = P.tag(SSHText, Object.fromEntries(
71
+ Object.entries(PublicKeyFieldCounts).map(([keyType, count]) => [certificateType(keyType), P.struct({ ...fields(count), signature: SSHBytes })]),
72
+ ))
73
+
74
+ const toSeconds = (date: Date): bigint => BigInt(Math.floor(date.getTime() / 1000))
75
+
76
+ /**
77
+ * Thrown when a certificate cannot be parsed
78
+ */
79
+ export class CertificateParseError extends Error {
80
+ constructor(message: string, cause?: unknown) {
81
+ super(message)
82
+ this.name = "CertificateParseError"
83
+ this.cause = cause
84
+
85
+ Object.setPrototypeOf(this, CertificateParseError.prototype)
86
+ }
87
+ }
88
+
89
+ /**
90
+ * A signed OpenSSH certificate
91
+ */
92
+ export class SshCertificate {
93
+ /** The wire encoding of the certificate */
94
+ readonly bytes: Uint8Array
95
+ /** The certificate type, for example "ssh-ed25519-cert-v01@openssh.com" */
96
+ readonly type: string
97
+ readonly kind: CertificateKind
98
+ /** The public key being certified */
99
+ readonly publicKey: PublicKey
100
+ readonly serial: bigint
101
+ readonly keyId: string
102
+ readonly principals: string[]
103
+ readonly validAfter: Date
104
+ readonly validBefore: Date
105
+ /** The names of the extensions on the certificate */
106
+ readonly extensions: string[]
107
+ /** The wire encoding of the public key of the CA that signed the certificate */
108
+ readonly signatureKey: Uint8Array
109
+ /** The signature of the CA in the SSH wire format */
110
+ readonly signature: Uint8Array
111
+
112
+ /**
113
+ * Decodes a certificate from its wire encoding. This does not verify the
114
+ * signature on the certificate.
115
+ *
116
+ * @param bytes The wire encoding of the certificate
117
+ * @throws {@link CertificateParseError} when the certificate cannot be decoded or its type is not supported
118
+ */
119
+ constructor(bytes: Uint8Array) {
120
+ let decoded: P.UnwrapCoder<typeof Certificate>
121
+ try {
122
+ decoded = Certificate.decode(bytes)
123
+ } catch (err) {
124
+ throw new CertificateParseError("certificate could not be decoded", err)
125
+ }
126
+ const { TAG, data } = decoded
127
+ const kind = (Object.keys(CertificateKinds) as CertificateKind[]).find((k) => CertificateKinds[k] === data.kind)
128
+ if (kind === undefined) {
129
+ throw new CertificateParseError(`unknown certificate kind: ${data.kind}`)
130
+ }
131
+
132
+ const publicKeyType = TAG.replace(/-cert-v01@openssh\.com$/, "")
133
+ try {
134
+ this.publicKey = new PublicKey(P.struct({ type: SSHText, key: P.array(data.key.length, SSHBytes) }).encode({ type: publicKeyType, key: data.key }))
135
+ } catch (err) {
136
+ throw new CertificateParseError(err instanceof KeyParseError ? err.message : "certificate public key could not be decoded", err)
137
+ }
138
+
139
+ this.bytes = bytes
140
+ this.type = TAG
141
+ this.kind = kind
142
+ this.serial = data.serial
143
+ this.keyId = data.keyId
144
+ this.principals = data.principals
145
+ this.validAfter = new Date(Number(data.validAfter) * 1000)
146
+ this.validBefore = new Date(Number(data.validBefore) * 1000)
147
+ this.extensions = data.extensions.map((e) => e.name)
148
+ this.signatureKey = data.signatureKey
149
+ this.signature = data.signature
150
+ }
151
+
152
+ /**
153
+ * Parses a certificate in OpenSSH format, `${type} ${base64}[ ${comment}]`,
154
+ * as found in `-cert.pub` files. This does not verify the signature on the
155
+ * certificate.
156
+ *
157
+ * @param text The certificate
158
+ * @throws {@link CertificateParseError} when the certificate cannot be parsed or its type is not supported
159
+ */
160
+ static parse(text: string): SshCertificate {
161
+ const [type, data] = text.trim().split(/\s+/)
162
+ if (type === undefined || type === "" || data === undefined) {
163
+ throw new CertificateParseError("certificate was not in OpenSSH format")
164
+ }
165
+
166
+ let bytes: Uint8Array
167
+ try {
168
+ bytes = base64.decode(data)
169
+ } catch (err) {
170
+ throw new CertificateParseError("certificate was not valid base64", err)
171
+ }
172
+
173
+ const certificate = new SshCertificate(bytes)
174
+ if (certificate.type !== type) {
175
+ throw new CertificateParseError(`certificate type ${type} did not match encoded type ${certificate.type}`)
176
+ }
177
+
178
+ return certificate
179
+ }
180
+
181
+ /**
182
+ * Returns whether the certificate was signed by a CA key
183
+ *
184
+ * @param ca The public key of the CA
185
+ */
186
+ async isSignedBy(ca: PublicKey): Promise<boolean> {
187
+ if (!ca.equals(new PublicKey(this.signatureKey))) {
188
+ return false
189
+ }
190
+
191
+ // the signature is the last field, so everything before it is signed
192
+ const signed = this.bytes.subarray(0, this.bytes.length - 4 - this.signature.length)
193
+
194
+ return await verify(ca, this.signature, new Uint8Array(signed))
195
+ }
196
+
197
+ /**
198
+ * Returns the certificate in OpenSSH format, as used for `-cert.pub` files
199
+ */
200
+ toString(): string {
201
+ return `${this.type} ${base64.encode(this.bytes)}`
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Creates and signs an OpenSSH certificate
207
+ *
208
+ * @param fields The fields of the certificate
209
+ * @param signer The CA key to sign the certificate with
210
+ * @returns The signed certificate
211
+ * @throws Error when the type of the public key being certified is not supported
212
+ */
213
+ export const signCertificate = async (fields: CertificateFields, signer: Signer): Promise<SshCertificate> => {
214
+ const { type, fields: key } = decodePublicKey(fields.publicKey.blob)
215
+
216
+ const value = {
217
+ nonce: crypto.getRandomValues(new Uint8Array(32)),
218
+ key,
219
+ serial: fields.serial,
220
+ kind: CertificateKinds[fields.kind],
221
+ keyId: fields.keyId,
222
+ principals: fields.principals,
223
+ validAfter: toSeconds(fields.validAfter),
224
+ validBefore: toSeconds(fields.validBefore),
225
+ criticalOptions: [],
226
+ // options must be in lexical order and may each appear only once
227
+ extensions: [...new Set(fields.extensions)].sort().map((name) => ({ name, data: new Uint8Array(0) })),
228
+ reserved: new Uint8Array(0),
229
+ signatureKey: signer.publicKey,
230
+ }
231
+
232
+ const tbs = ToBeSigned.encode({ TAG: certificateType(type), data: value })
233
+ const signature = await signer.sign(new Uint8Array(tbs))
234
+
235
+ return new SshCertificate(Certificate.encode({ TAG: certificateType(type), data: { ...value, signature } }))
236
+ }
@@ -0,0 +1,81 @@
1
+ import * as P from "micro-packed"
2
+
3
+ /**
4
+ * Coders for the SSH wire format (RFC 4251 section 5)
5
+ */
6
+
7
+ /** A string of bytes, prefixed by its length as a uint32 */
8
+ export const SSHBytes = P.bytes(P.U32BE)
9
+
10
+ /** A UTF-8 string, prefixed by its length as a uint32 */
11
+ export const SSHText = P.string(P.U32BE)
12
+
13
+ /** A list of strings packed into a string, as used for certificate principals */
14
+ export const SSHTextList = P.prefix(P.U32BE, P.array(null, SSHText))
15
+
16
+ /**
17
+ * A list of named options packed into a string, as used for certificate
18
+ * critical options and extensions
19
+ */
20
+ export const SSHOptions = P.prefix(P.U32BE, P.array(null, P.struct({
21
+ name: SSHText,
22
+ data: SSHBytes,
23
+ })))
24
+
25
+ /**
26
+ * An mpint holding an unsigned integer, decoded to and encoded from its
27
+ * big-endian bytes without leading zeros.
28
+ *
29
+ * An mpint has no leading zero bytes, other than one to keep the value
30
+ * positive when the high bit of the first byte is set.
31
+ */
32
+ export const SSHUnsignedMpint = P.apply(SSHBytes, {
33
+ encode: (mpint: Uint8Array): Uint8Array => {
34
+ let start = 0
35
+ while (start < mpint.length && mpint[start] === 0) {
36
+ start++
37
+ }
38
+
39
+ return mpint.subarray(start)
40
+ },
41
+ decode: (value: Uint8Array): Uint8Array => {
42
+ let start = 0
43
+ while (start < value.length && value[start] === 0) {
44
+ start++
45
+ }
46
+ const trimmed = value.subarray(start)
47
+
48
+ return trimmed.length > 0 && trimmed[0]! & 0x80
49
+ ? P.utils.concatBytes(new Uint8Array([0]), trimmed)
50
+ : trimmed
51
+ },
52
+ })
53
+
54
+ /** The number of fields that follow the type in the wire encoding of a public key of each type */
55
+ export const PublicKeyFieldCounts: Readonly<Record<string, number>> = {
56
+ "ssh-ed25519": 1, // public key
57
+ "ecdsa-sha2-nistp256": 2, // curve, public key
58
+ "ecdsa-sha2-nistp384": 2,
59
+ "ecdsa-sha2-nistp521": 2,
60
+ "ssh-rsa": 2, // e, n
61
+ }
62
+
63
+ /**
64
+ * Splits the wire encoding of a public key into its type and the encoded
65
+ * fields that follow it
66
+ *
67
+ * @param blob The wire encoding of the public key
68
+ * @returns The key type and its fields
69
+ * @throws Error when the key type is not supported or the encoding is invalid
70
+ */
71
+ export const decodePublicKey = (blob: Uint8Array): { type: string, fields: Uint8Array[] } => {
72
+ const type = SSHText.decode(blob, { allowUnreadBytes: true })
73
+ const count = PublicKeyFieldCounts[type]
74
+ if (count === undefined) {
75
+ throw new Error(`unsupported public key type: ${type}`)
76
+ }
77
+
78
+ const { fields } = P.struct({ type: SSHText, fields: P.array(count, SSHBytes) }).decode(blob)
79
+
80
+ return { type, fields }
81
+ }