@andrewheberle/serverless-ssh-ca 0.21.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 (98) 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 +25 -212
  12. package/dist/api/v3/index.js.map +1 -1
  13. package/dist/api/v3/schema.d.ts +30 -74
  14. package/dist/api/v3/schema.d.ts.map +1 -1
  15. package/dist/api/v3/schema.js +18 -73
  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 +32 -24
  30. package/dist/db/index.d.ts.map +1 -1
  31. package/dist/db/index.js +101 -28
  32. package/dist/db/index.js.map +1 -1
  33. package/dist/db/migrations/0002_krl_version.d.ts +3 -0
  34. package/dist/db/migrations/0002_krl_version.d.ts.map +1 -0
  35. package/dist/db/migrations/0002_krl_version.js +15 -0
  36. package/dist/db/migrations/0002_krl_version.js.map +1 -0
  37. package/dist/db/migrations/index.d.ts.map +1 -1
  38. package/dist/db/migrations/index.js +3 -1
  39. package/dist/db/migrations/index.js.map +1 -1
  40. package/dist/db/types.d.ts +37 -0
  41. package/dist/db/types.d.ts.map +1 -0
  42. package/dist/db/types.js +13 -0
  43. package/dist/db/types.js.map +1 -0
  44. package/dist/krl.d.ts +9 -1
  45. package/dist/krl.d.ts.map +1 -1
  46. package/dist/krl.js +22 -162
  47. package/dist/krl.js.map +1 -1
  48. package/dist/proof.d.ts +36 -7
  49. package/dist/proof.d.ts.map +1 -1
  50. package/dist/proof.js +53 -21
  51. package/dist/proof.js.map +1 -1
  52. package/dist/router.d.ts.map +1 -1
  53. package/dist/router.js +3 -1
  54. package/dist/router.js.map +1 -1
  55. package/dist/ssh/certificate.d.ts +93 -0
  56. package/dist/ssh/certificate.d.ts.map +1 -0
  57. package/dist/ssh/certificate.js +178 -0
  58. package/dist/ssh/certificate.js.map +1 -0
  59. package/dist/ssh/encoding.d.ts +41 -0
  60. package/dist/ssh/encoding.d.ts.map +1 -0
  61. package/dist/ssh/encoding.js +70 -0
  62. package/dist/ssh/encoding.js.map +1 -0
  63. package/dist/ssh/keys.d.ts +80 -0
  64. package/dist/ssh/keys.d.ts.map +1 -0
  65. package/dist/ssh/keys.js +249 -0
  66. package/dist/ssh/keys.js.map +1 -0
  67. package/dist/ssh/signer.d.ts +35 -0
  68. package/dist/ssh/signer.d.ts.map +1 -0
  69. package/dist/ssh/signer.js +99 -0
  70. package/dist/ssh/signer.js.map +1 -0
  71. package/dist/types.d.ts +12 -17
  72. package/dist/types.d.ts.map +1 -1
  73. package/dist/utils.d.ts +40 -21
  74. package/dist/utils.d.ts.map +1 -1
  75. package/dist/utils.js +87 -54
  76. package/dist/utils.js.map +1 -1
  77. package/openapi.json +1960 -82
  78. package/package.json +5 -17
  79. package/src/api/operations.ts +211 -0
  80. package/src/api/schema.ts +137 -0
  81. package/src/api/v3/index.ts +32 -259
  82. package/src/api/v3/schema.ts +34 -96
  83. package/src/api/v4/index.ts +265 -0
  84. package/src/api/v4/schema.ts +235 -0
  85. package/src/certificate.ts +62 -123
  86. package/src/db/index.ts +113 -49
  87. package/src/db/migrations/0002_krl_version.ts +16 -0
  88. package/src/db/migrations/index.ts +3 -1
  89. package/src/db/types.ts +40 -0
  90. package/src/krl.ts +32 -194
  91. package/src/proof.ts +65 -31
  92. package/src/router.ts +3 -1
  93. package/src/ssh/certificate.ts +236 -0
  94. package/src/ssh/encoding.ts +81 -0
  95. package/src/ssh/keys.ts +294 -0
  96. package/src/ssh/signer.ts +129 -0
  97. package/src/types.ts +12 -18
  98. package/src/utils.ts +99 -69
@@ -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
+ }
@@ -0,0 +1,294 @@
1
+ import { sha256 } from "@noble/hashes/sha2.js"
2
+ import { base64, base64nopad, base64urlnopad } from "@scure/base"
3
+ import * as P from "micro-packed"
4
+ import { toFixedWidth } from "../sshsig/sig_parser.js"
5
+ import { decodePublicKey, SSHBytes, SSHText } from "./encoding.js"
6
+
7
+ /**
8
+ * Thrown when a key cannot be parsed
9
+ */
10
+ export class KeyParseError extends Error {
11
+ constructor(message: string, cause?: unknown) {
12
+ super(message)
13
+ this.name = "KeyParseError"
14
+ this.cause = cause
15
+
16
+ Object.setPrototypeOf(this, KeyParseError.prototype)
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Thrown when a private key is of a type that cannot be used to sign
22
+ */
23
+ export class UnsupportedKeyError extends Error {
24
+ constructor(message: string) {
25
+ super(message)
26
+ this.name = "UnsupportedKeyError"
27
+
28
+ Object.setPrototypeOf(this, UnsupportedKeyError.prototype)
29
+ }
30
+ }
31
+
32
+ const equalBytes = (a: Uint8Array, b: Uint8Array): boolean => {
33
+ return a.length === b.length && a.every((v, i) => v === b[i])
34
+ }
35
+
36
+ /**
37
+ * An SSH public key
38
+ */
39
+ export class PublicKey {
40
+ /** The key type, for example "ssh-ed25519" */
41
+ readonly type: string
42
+ /** The wire encoding of the key (RFC 4253 section 6.6) */
43
+ readonly blob: Uint8Array
44
+ /** The comment of the key, if any */
45
+ readonly comment: string | undefined
46
+
47
+ /**
48
+ * @param blob The wire encoding of the key
49
+ * @param comment The comment of the key
50
+ * @throws {@link KeyParseError} when the key cannot be decoded or its type is not supported
51
+ */
52
+ constructor(blob: Uint8Array, comment?: string) {
53
+ try {
54
+ this.type = decodePublicKey(blob).type
55
+ } catch (err) {
56
+ throw new KeyParseError(err instanceof Error ? err.message : "public key could not be decoded", err)
57
+ }
58
+ this.blob = blob
59
+ this.comment = comment === "" ? undefined : comment
60
+ }
61
+
62
+ /**
63
+ * Parses a public key in OpenSSH format, `${type} ${base64}[ ${comment}]`
64
+ *
65
+ * @param text The public key
66
+ * @throws {@link KeyParseError} when the key cannot be parsed or its type is not supported
67
+ */
68
+ static parse(text: string): PublicKey {
69
+ const [type, data, ...comment] = text.trim().split(/\s+/)
70
+ if (type === undefined || type === "" || data === undefined) {
71
+ throw new KeyParseError("public key was not in OpenSSH format")
72
+ }
73
+
74
+ let blob: Uint8Array
75
+ try {
76
+ blob = base64.decode(data)
77
+ } catch (err) {
78
+ throw new KeyParseError("public key was not valid base64", err)
79
+ }
80
+
81
+ const key = new PublicKey(blob, comment.join(" "))
82
+ if (key.type !== type) {
83
+ throw new KeyParseError(`public key type ${type} did not match encoded type ${key.type}`)
84
+ }
85
+
86
+ return key
87
+ }
88
+
89
+ /**
90
+ * Returns the SHA256 fingerprint of the key, as shown by `ssh-keygen -l`,
91
+ * for example "SHA256:79mq0wtpQMTvS4+Of8VzLN0qmYWNUyTXYmqKwhEgSLs"
92
+ */
93
+ fingerprint(): string {
94
+ return `SHA256:${base64nopad.encode(sha256(this.blob))}`
95
+ }
96
+
97
+ /**
98
+ * Returns whether this is the same key as another, ignoring comments
99
+ */
100
+ equals(other: PublicKey): boolean {
101
+ return equalBytes(this.blob, other.blob)
102
+ }
103
+
104
+ /**
105
+ * Returns a copy of the key with a different comment
106
+ */
107
+ withComment(comment: string | undefined): PublicKey {
108
+ return new PublicKey(this.blob, comment)
109
+ }
110
+
111
+ /**
112
+ * Returns the key in OpenSSH format, `${type} ${base64}[ ${comment}]`
113
+ */
114
+ toString(): string {
115
+ const key = `${this.type} ${base64.encode(this.blob)}`
116
+
117
+ return this.comment === undefined ? key : `${key} ${this.comment}`
118
+ }
119
+ }
120
+
121
+ /** The ECDSA curves that can be used to sign */
122
+ export type EcdsaCurve = "nistp256" | "nistp384" | "nistp521"
123
+
124
+ const EcdsaCurves: Readonly<Record<EcdsaCurve, { namedCurve: string, size: number }>> = {
125
+ nistp256: { namedCurve: "P-256", size: 32 },
126
+ nistp384: { namedCurve: "P-384", size: 48 },
127
+ nistp521: { namedCurve: "P-521", size: 66 },
128
+ }
129
+
130
+ /**
131
+ * A private key that can be used to sign, with its key material as a JWK for
132
+ * WebCrypto
133
+ */
134
+ export type PrivateKey = {
135
+ readonly type: "ed25519"
136
+ readonly publicKey: PublicKey
137
+ readonly jwk: JsonWebKey
138
+ } | {
139
+ readonly type: "ecdsa"
140
+ readonly curve: EcdsaCurve
141
+ readonly publicKey: PublicKey
142
+ readonly jwk: JsonWebKey
143
+ }
144
+
145
+ // "openssh-key-v1\0"
146
+ const AUTH_MAGIC = new TextEncoder().encode("openssh-key-v1\0")
147
+
148
+ /** The container of an OpenSSH private key (PROTOCOL.key) */
149
+ const OpenSshPrivateKey = P.struct({
150
+ magic: P.magicBytes(AUTH_MAGIC),
151
+ cipherName: SSHText,
152
+ kdfName: SSHText,
153
+ kdfOptions: SSHBytes,
154
+ publicKeys: P.array(P.U32BE, SSHBytes),
155
+ privateKeys: SSHBytes,
156
+ })
157
+
158
+ /** The start of the (decrypted) private section of an OpenSSH private key */
159
+ const PrivateSectionStart = P.struct({
160
+ check1: P.U32BE,
161
+ check2: P.U32BE,
162
+ type: SSHText,
163
+ })
164
+
165
+ const Ed25519PrivateKey = P.struct({
166
+ check1: P.U32BE,
167
+ check2: P.U32BE,
168
+ type: SSHText,
169
+ publicKey: SSHBytes,
170
+ // the seed followed by the public key
171
+ privateKey: SSHBytes,
172
+ comment: SSHText,
173
+ })
174
+
175
+ const EcdsaPrivateKey = P.struct({
176
+ check1: P.U32BE,
177
+ check2: P.U32BE,
178
+ type: SSHText,
179
+ curve: SSHText,
180
+ publicKey: SSHBytes,
181
+ // an mpint
182
+ privateKey: SSHBytes,
183
+ comment: SSHText,
184
+ })
185
+
186
+ /**
187
+ * Returns a short name for the type of a key, for example "rsa" for "ssh-rsa"
188
+ */
189
+ const keyTypeName = (type: string): string => {
190
+ return type.replace(/^ssh-/, "").replace(/^ecdsa-sha2-.*$/, "ecdsa")
191
+ }
192
+
193
+ /**
194
+ * Parses an unencrypted Ed25519 or ECDSA private key in OpenSSH format, as
195
+ * created by `ssh-keygen`
196
+ *
197
+ * @param text The private key, starting with `-----BEGIN OPENSSH PRIVATE KEY-----`
198
+ * @returns The private key
199
+ * @throws {@link KeyParseError} when the key cannot be parsed or is encrypted
200
+ * @throws {@link UnsupportedKeyError} when the key is not an Ed25519 or ECDSA key
201
+ */
202
+ export const parsePrivateKey = (text: string): PrivateKey => {
203
+ const match = /-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]*?)-----END OPENSSH PRIVATE KEY-----/.exec(text)
204
+ if (match === null) {
205
+ throw new KeyParseError("private key was not in OpenSSH format, as created by ssh-keygen (starting with -----BEGIN OPENSSH PRIVATE KEY-----)")
206
+ }
207
+
208
+ let container: P.UnwrapCoder<typeof OpenSshPrivateKey>
209
+ try {
210
+ container = OpenSshPrivateKey.decode(base64.decode(match[1]!.replace(/\s+/g, "")))
211
+ } catch (err) {
212
+ throw new KeyParseError("private key could not be decoded", err)
213
+ }
214
+
215
+ if (container.cipherName !== "none" || container.kdfName !== "none") {
216
+ throw new KeyParseError("encrypted private keys are not supported")
217
+ }
218
+ if (container.publicKeys.length !== 1) {
219
+ throw new KeyParseError(`private key must contain one key, not ${container.publicKeys.length}`)
220
+ }
221
+
222
+ // the private section is padded to a block size, so may have trailing bytes
223
+ const options = { allowUnreadBytes: true }
224
+ let start: P.UnwrapCoder<typeof PrivateSectionStart>
225
+ try {
226
+ start = PrivateSectionStart.decode(container.privateKeys, options)
227
+ } catch (err) {
228
+ throw new KeyParseError("private key could not be decoded", err)
229
+ }
230
+ if (start.check1 !== start.check2) {
231
+ throw new KeyParseError("private key check values did not match")
232
+ }
233
+
234
+ try {
235
+ if (start.type === "ssh-ed25519") {
236
+ const key = Ed25519PrivateKey.decode(container.privateKeys, options)
237
+ if (key.publicKey.length !== 32 || key.privateKey.length !== 64) {
238
+ throw new KeyParseError("Ed25519 private key was an unexpected length")
239
+ }
240
+ const publicKey = new PublicKey(container.publicKeys[0]!, key.comment)
241
+ if (!equalBytes(P.struct({ type: SSHText, key: SSHBytes }).encode({ type: start.type, key: key.publicKey }), publicKey.blob)) {
242
+ throw new KeyParseError("private key did not match its public key")
243
+ }
244
+
245
+ return {
246
+ type: "ed25519",
247
+ publicKey,
248
+ jwk: {
249
+ kty: "OKP",
250
+ crv: "Ed25519",
251
+ d: base64urlnopad.encode(key.privateKey.subarray(0, 32)),
252
+ x: base64urlnopad.encode(key.publicKey),
253
+ },
254
+ }
255
+ }
256
+
257
+ if (start.type.startsWith("ecdsa-sha2-")) {
258
+ const key = EcdsaPrivateKey.decode(container.privateKeys, options)
259
+ const curve = EcdsaCurves[key.curve as EcdsaCurve]
260
+ if (curve === undefined || start.type !== `ecdsa-sha2-${key.curve}`) {
261
+ throw new UnsupportedKeyError(`ECDSA curve ${key.curve} is not supported`)
262
+ }
263
+ if (key.publicKey.length !== 1 + curve.size * 2 || key.publicKey[0] !== 0x04) {
264
+ throw new KeyParseError(`unexpected ECDSA public key encoding for ${key.curve}`)
265
+ }
266
+ const publicKey = new PublicKey(container.publicKeys[0]!, key.comment)
267
+ if (!equalBytes(P.struct({ type: SSHText, curve: SSHText, key: SSHBytes }).encode({ type: start.type, curve: key.curve, key: key.publicKey }), publicKey.blob)) {
268
+ throw new KeyParseError("private key did not match its public key")
269
+ }
270
+
271
+ return {
272
+ type: "ecdsa",
273
+ curve: key.curve as EcdsaCurve,
274
+ publicKey,
275
+ jwk: {
276
+ kty: "EC",
277
+ crv: curve.namedCurve,
278
+ // d is an mpint, so may be short or have a leading sign byte
279
+ d: base64urlnopad.encode(toFixedWidth(key.privateKey, curve.size)),
280
+ x: base64urlnopad.encode(key.publicKey.subarray(1, 1 + curve.size)),
281
+ y: base64urlnopad.encode(key.publicKey.subarray(1 + curve.size)),
282
+ },
283
+ }
284
+ }
285
+ } catch (err) {
286
+ if (err instanceof KeyParseError || err instanceof UnsupportedKeyError) {
287
+ throw err
288
+ }
289
+ throw new KeyParseError("private key could not be decoded", err)
290
+ }
291
+
292
+ throw new UnsupportedKeyError(`CA key type ${keyTypeName(start.type)} is not supported, the CA key must be Ed25519 or ECDSA`)
293
+ }
294
+