@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.
- package/README.md +55 -1
- package/dist/api/operations.d.ts +49 -0
- package/dist/api/operations.d.ts.map +1 -0
- package/dist/api/operations.js +175 -0
- package/dist/api/operations.js.map +1 -0
- package/dist/api/schema.d.ts +71 -0
- package/dist/api/schema.d.ts.map +1 -0
- package/dist/api/schema.js +107 -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 +10 -100
- 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 +182 -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 +17 -5
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +39 -23
- package/dist/db/index.js.map +1 -1
- package/dist/krl.d.ts +1 -1
- package/dist/krl.d.ts.map +1 -1
- package/dist/krl.js +9 -163
- package/dist/krl.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.map +1 -1
- package/dist/router.js +3 -1
- 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 +80 -0
- package/dist/ssh/keys.d.ts.map +1 -0
- package/dist/ssh/keys.js +249 -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 +12 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +40 -21
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +87 -54
- package/dist/utils.js.map +1 -1
- package/openapi.json +1542 -1
- package/package.json +4 -3
- package/src/api/operations.ts +211 -0
- package/src/api/schema.ts +137 -0
- package/src/api/v3/index.ts +20 -248
- package/src/api/v3/schema.ts +26 -125
- package/src/api/v4/index.ts +265 -0
- package/src/api/v4/schema.ts +235 -0
- package/src/certificate.ts +62 -123
- package/src/db/index.ts +41 -26
- package/src/krl.ts +10 -195
- package/src/proof.ts +65 -31
- package/src/router.ts +3 -1
- package/src/ssh/certificate.ts +236 -0
- package/src/ssh/encoding.ts +81 -0
- package/src/ssh/keys.ts +294 -0
- package/src/ssh/signer.ts +129 -0
- package/src/types.ts +12 -18
- package/src/utils.ts +99 -69
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)
|
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: "
|
|
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
|
+
}
|