@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/certificate.ts
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
import { seconds } from "itty-time"
|
|
2
|
-
import
|
|
3
|
-
import type { Certificate, Identity, Key, PrivateKey } from "sshpk"
|
|
4
|
-
import { SSHExtension } from "./types.js"
|
|
2
|
+
import type { PrivateKey, PublicKey } from "./ssh/keys.js"
|
|
5
3
|
import { getPrivateKey, split } from "./utils.js"
|
|
6
4
|
import type { SshCaBindings } from "./types.js"
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const { createCertificate, identityForHost, identityForUser, identityFromDN } = sshpk
|
|
10
|
-
|
|
11
|
-
// const sshCertificateExtensions = split(env.SSH_CERTIFICATE_EXTENSIONS)
|
|
5
|
+
import { signCertificate, type CertificateFields, type SshCertificate } from "./ssh/certificate.js"
|
|
6
|
+
import { createSigner } from "./ssh/signer.js"
|
|
12
7
|
|
|
13
8
|
export type CreateCertificateOptions = {
|
|
14
9
|
lifetime?: number
|
|
@@ -16,12 +11,6 @@ export type CreateCertificateOptions = {
|
|
|
16
11
|
extensions?: string[]
|
|
17
12
|
}
|
|
18
13
|
|
|
19
|
-
/* const DefaultCreateCertificateOptions: CreateCertificateOptions = {
|
|
20
|
-
lifetime: seconds(env.SSH_CERTIFICATE_LIFETIME),
|
|
21
|
-
principals: [],
|
|
22
|
-
extensions: sshCertificateExtensions
|
|
23
|
-
} */
|
|
24
|
-
|
|
25
14
|
export class CertificateError extends Error {
|
|
26
15
|
constructor(message: string) {
|
|
27
16
|
super(message)
|
|
@@ -41,18 +30,36 @@ export type GenerateCertificateOptions = {
|
|
|
41
30
|
serial?: bigint
|
|
42
31
|
} & CreateCertificateOptions
|
|
43
32
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Signs a certificate with the CA key
|
|
35
|
+
*
|
|
36
|
+
* @throws {@link CertificateError} when the certificate has no principals,
|
|
37
|
+
* as OpenSSH accepts a certificate without principals for any user or host
|
|
38
|
+
*/
|
|
39
|
+
const sign = async (key: PrivateKey, fields: Omit<CertificateFields, "validAfter" | "validBefore">, lifetime: number): Promise<SshCertificate> => {
|
|
40
|
+
if (fields.principals.length === 0) {
|
|
41
|
+
throw new CertificateError("a certificate must have at least one principal")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// the validity is to the second, so starts at the current second rather
|
|
45
|
+
// than in the future
|
|
46
|
+
const validAfter = new Date(Math.floor(Date.now() / 1000) * 1000)
|
|
47
|
+
const validBefore = new Date(validAfter.getTime() + lifetime * 1000)
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
return await signCertificate({ ...fields, validAfter, validBefore }, await createSigner(key))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const generateCertificate = async (env: SshCaBindings, email: string, key: PrivateKey, public_key: PublicKey, options?: GenerateCertificateOptions): Promise<SshCertificate> => {
|
|
53
|
+
const { lifetime, principals, extensions, serial } = options ?? {}
|
|
54
|
+
|
|
55
|
+
// add principals
|
|
49
56
|
const includeSelf = env.SSH_CERTIFICATE_INCLUDE_SELF === undefined
|
|
50
57
|
? false
|
|
51
58
|
: typeof env.SSH_CERTIFICATE_INCLUDE_SELF === "string"
|
|
52
59
|
? env.SSH_CERTIFICATE_INCLUDE_SELF === "true"
|
|
53
60
|
: env.SSH_CERTIFICATE_INCLUDE_SELF
|
|
54
|
-
const
|
|
55
|
-
? [
|
|
61
|
+
const certificatePrincipals = includeSelf
|
|
62
|
+
? [email.split("@")[0]!]
|
|
56
63
|
: []
|
|
57
64
|
|
|
58
65
|
// add email?
|
|
@@ -62,86 +69,43 @@ export const generateCertificate = (env: SshCaBindings, email: string, key: Priv
|
|
|
62
69
|
? env.SSH_CERTIFICATE_INCLUDE_SELF_EMAIL === "true"
|
|
63
70
|
: env.SSH_CERTIFICATE_INCLUDE_SELF_EMAIL
|
|
64
71
|
if (includeSelfEmail) {
|
|
65
|
-
|
|
72
|
+
certificatePrincipals.push(email)
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
if (principals !== undefined) {
|
|
69
|
-
|
|
70
|
-
identity.push(identityForUser(p))
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
for (const p of split(env.SSH_CERTIFICATE_PRINCIPALS)) {
|
|
75
|
-
identity.push(identityForUser(p))
|
|
76
|
+
certificatePrincipals.push(...principals)
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
|
|
79
|
-
// lifetime is the smaller of what was provided in the options or the default
|
|
80
|
-
lifetime = lifetime !== undefined
|
|
81
|
-
? Math.min(lifetime, seconds(env.SSH_CERTIFICATE_LIFETIME))
|
|
82
|
-
: seconds(env.SSH_CERTIFICATE_LIFETIME)
|
|
83
|
-
|
|
84
|
-
// generate value for serial of certificate
|
|
85
|
-
const s = generateSerial(serial)
|
|
86
|
-
|
|
87
|
-
// set issuer of certificate based on ISSUER_DN
|
|
88
|
-
const issuer = identityFromDN(env.ISSUER_DN)
|
|
89
|
-
|
|
90
|
-
// create certificate
|
|
91
|
-
const certificate = createCertificate(identity, public_key, issuer, key, { lifetime: lifetime, serial: s })
|
|
92
|
-
|
|
93
|
-
// ensure openssh info is included in certificate (should not occur)
|
|
94
|
-
if (certificate.signatures.openssh === undefined) {
|
|
95
|
-
throw new CertificateError("missing openssh information in certificate")
|
|
96
|
-
}
|
|
79
|
+
certificatePrincipals.push(...split(env.SSH_CERTIFICATE_PRINCIPALS))
|
|
97
80
|
|
|
98
81
|
const sshCertificateExtensions = split(env.SSH_CERTIFICATE_EXTENSIONS)
|
|
99
82
|
|
|
100
|
-
//
|
|
101
|
-
const sshextensions: SSHExtension[] = []
|
|
83
|
+
// if extensions are provided in request, ensure they do not include extra extensions beyond the defaults
|
|
102
84
|
if (extensions !== undefined) {
|
|
103
|
-
// if extensions are provided in request, ensure they do not include extra extensions beyond the defaults
|
|
104
85
|
for (const ext of extensions) {
|
|
105
86
|
if (!sshCertificateExtensions.includes(ext)) {
|
|
106
87
|
throw new CertificateError(`${ext} is not allowed`)
|
|
107
88
|
}
|
|
108
|
-
|
|
109
|
-
// add to list of allowed extensions
|
|
110
|
-
sshextensions.push({
|
|
111
|
-
critical: false,
|
|
112
|
-
name: ext,
|
|
113
|
-
data: Buffer.alloc(0)
|
|
114
|
-
})
|
|
115
|
-
}
|
|
116
|
-
} else {
|
|
117
|
-
// use defaults if not provided
|
|
118
|
-
sshCertificateExtensions.forEach((ext: string) => {
|
|
119
|
-
sshextensions.push({
|
|
120
|
-
critical: false,
|
|
121
|
-
name: ext,
|
|
122
|
-
data: Buffer.alloc(0)
|
|
123
|
-
})
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// add info to certificate
|
|
129
|
-
certificate.signatures = {
|
|
130
|
-
openssh: {
|
|
131
|
-
nonce: certificate.signatures.openssh.nonce,
|
|
132
|
-
keyId: email,
|
|
133
|
-
signature: certificate.signatures.openssh.signature,
|
|
134
|
-
exts: sshextensions
|
|
135
89
|
}
|
|
136
90
|
}
|
|
137
91
|
|
|
138
|
-
//
|
|
139
|
-
|
|
92
|
+
// lifetime is the smaller of what was provided in the options or the default
|
|
93
|
+
const certificateLifetime = lifetime !== undefined
|
|
94
|
+
? Math.min(lifetime, seconds(env.SSH_CERTIFICATE_LIFETIME))
|
|
95
|
+
: seconds(env.SSH_CERTIFICATE_LIFETIME)
|
|
140
96
|
|
|
141
|
-
return
|
|
97
|
+
return await sign(key, {
|
|
98
|
+
kind: "user",
|
|
99
|
+
publicKey: public_key,
|
|
100
|
+
serial: serial ?? generateSerial(),
|
|
101
|
+
keyId: email,
|
|
102
|
+
principals: certificatePrincipals,
|
|
103
|
+
// use defaults if not provided
|
|
104
|
+
extensions: extensions ?? sshCertificateExtensions,
|
|
105
|
+
}, certificateLifetime)
|
|
142
106
|
}
|
|
143
107
|
|
|
144
|
-
export async function createSignedCertificate(env: SshCaBindings, email: string, public_key:
|
|
108
|
+
export async function createSignedCertificate(env: SshCaBindings, email: string, public_key: PublicKey, options?: CreateCertificateOptions): Promise<SshCertificate> {
|
|
145
109
|
// grab private key from secret store
|
|
146
110
|
const key = await getPrivateKey(env)
|
|
147
111
|
|
|
@@ -153,13 +117,12 @@ export async function createSignedCertificate(env: SshCaBindings, email: string,
|
|
|
153
117
|
}
|
|
154
118
|
}
|
|
155
119
|
|
|
156
|
-
return generateCertificate(env, email, key, public_key, options)
|
|
120
|
+
return await generateCertificate(env, email, key, public_key, options)
|
|
157
121
|
}
|
|
158
122
|
|
|
159
123
|
export type CreateHostCertificateOptions = {
|
|
160
124
|
lifetime?: number
|
|
161
125
|
principals?: string[]
|
|
162
|
-
subjects?: Identity[]
|
|
163
126
|
}
|
|
164
127
|
|
|
165
128
|
export class BadIssuerError extends Error {
|
|
@@ -171,54 +134,30 @@ export class BadIssuerError extends Error {
|
|
|
171
134
|
}
|
|
172
135
|
}
|
|
173
136
|
|
|
174
|
-
export async function createSignedHostCertificate(env: SshCaBindings, public_key:
|
|
175
|
-
|
|
176
|
-
options = {
|
|
177
|
-
lifetime: seconds(env.SSH_HOST_CERTIFICATE_LIFETIME),
|
|
178
|
-
principals: []
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
// generate list of identities for host key
|
|
182
|
-
const identity: Identity[] = []
|
|
183
|
-
if (options.principals !== undefined) {
|
|
184
|
-
for (const p of options.principals) {
|
|
185
|
-
identity.push(identityForHost(p))
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// add any already provided subjects (for renewals only)
|
|
190
|
-
if (options.subjects !== undefined) {
|
|
191
|
-
identity.push(...options.subjects)
|
|
192
|
-
}
|
|
137
|
+
export async function createSignedHostCertificate(env: SshCaBindings, public_key: PublicKey, options?: CreateHostCertificateOptions): Promise<SshCertificate> {
|
|
138
|
+
const principals = options?.principals ?? []
|
|
193
139
|
|
|
194
140
|
// grab private key from secret store
|
|
195
141
|
const key = await getPrivateKey(env)
|
|
196
142
|
|
|
197
143
|
// lifetime is the smaller of what was provided in the options or the default
|
|
198
|
-
const lifetime = options
|
|
144
|
+
const lifetime = options?.lifetime !== undefined
|
|
199
145
|
? Math.min(options.lifetime, seconds(env.SSH_HOST_CERTIFICATE_LIFETIME))
|
|
200
146
|
: seconds(env.SSH_HOST_CERTIFICATE_LIFETIME)
|
|
201
147
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
return certificate
|
|
148
|
+
return await sign(key, {
|
|
149
|
+
kind: "host",
|
|
150
|
+
publicKey: public_key,
|
|
151
|
+
serial: generateSerial(),
|
|
152
|
+
keyId: `host_${principals[0]}`,
|
|
153
|
+
principals,
|
|
154
|
+
extensions: [],
|
|
155
|
+
}, lifetime)
|
|
212
156
|
}
|
|
213
157
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return s
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const randomBytes = crypto.getRandomValues(new Uint8Array(8))
|
|
223
|
-
return Buffer.from(randomBytes)
|
|
158
|
+
/**
|
|
159
|
+
* Returns a random certificate serial number
|
|
160
|
+
*/
|
|
161
|
+
export const generateSerial = (): bigint => {
|
|
162
|
+
return new DataView(crypto.getRandomValues(new Uint8Array(8)).buffer).getBigUint64(0)
|
|
224
163
|
}
|
package/src/db/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { PublicKey } from "../ssh/keys.js"
|
|
2
|
+
import type { SshCertificate } from "../ssh/certificate.js"
|
|
2
3
|
import { logger } from "../logger.js"
|
|
3
4
|
import type { SshCaBindings } from "../types.js"
|
|
4
5
|
import { D1QB } from "workers-qb"
|
|
@@ -48,27 +49,22 @@ export const dbCleanup = async (env: SshCaBindings) => {
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
export const recordCertificate = async (env: SshCaBindings, certificate:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}).join(",")
|
|
56
|
-
const extensions = certificate.getExtensions().map((v: Format.OpenSshSignatureExt | Format.x509SignatureExt): string => {
|
|
57
|
-
// @ts-expect-error: the name property does exist
|
|
58
|
-
return v.name as string
|
|
59
|
-
}).join(",")
|
|
52
|
+
export const recordCertificate = async (env: SshCaBindings, certificate: SshCertificate, keyid: string, certificateType: CertificateType = CertificateType.User) => {
|
|
53
|
+
// principals are recorded as distinguished names, as they were when
|
|
54
|
+
// certificates were created with sshpk
|
|
55
|
+
const principals = certificate.principals.map((p) => `${certificate.kind === "host" ? "CN" : "UID"}=${p}`).join(",")
|
|
60
56
|
const qb = await connect(env)
|
|
61
57
|
const res = await qb.insert({
|
|
62
58
|
tableName: "certificates",
|
|
63
59
|
data: {
|
|
64
|
-
serial: `${serial}`,
|
|
60
|
+
serial: `${certificate.serial}`,
|
|
65
61
|
key_id: keyid,
|
|
66
|
-
principals:
|
|
67
|
-
extensions: extensions,
|
|
68
|
-
valid_after: certificate.
|
|
69
|
-
valid_before: certificate.
|
|
62
|
+
principals: principals,
|
|
63
|
+
extensions: certificate.extensions.join(","),
|
|
64
|
+
valid_after: certificate.validAfter.toISOString(),
|
|
65
|
+
valid_before: certificate.validBefore.toISOString(),
|
|
70
66
|
certificate_type: certificateType,
|
|
71
|
-
public_key: certificate.
|
|
67
|
+
public_key: certificate.publicKey.toString()
|
|
72
68
|
},
|
|
73
69
|
}).execute()
|
|
74
70
|
|
|
@@ -95,21 +91,44 @@ export enum RevocationStatus {
|
|
|
95
91
|
NotFound,
|
|
96
92
|
Revoked,
|
|
97
93
|
Revokable,
|
|
98
|
-
Unrevokable
|
|
99
94
|
}
|
|
100
95
|
|
|
101
|
-
|
|
96
|
+
// whether a public key recorded in OpenSSH format is the same as another key,
|
|
97
|
+
// treating a recorded key that cannot be parsed as a different key
|
|
98
|
+
const isKey = (recorded: string, publicKey: PublicKey): boolean => {
|
|
99
|
+
try {
|
|
100
|
+
return PublicKey.parse(recorded).equals(publicKey)
|
|
101
|
+
} catch {
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Returns whether a certificate can be revoked by the holder of a public key.
|
|
108
|
+
*
|
|
109
|
+
* The public key is compared by fingerprint, so a key with a different
|
|
110
|
+
* comment to the one the certificate was issued for still matches.
|
|
111
|
+
*
|
|
112
|
+
* @param env The CA bindings
|
|
113
|
+
* @param serial The serial of the certificate
|
|
114
|
+
* @param publicKey The public key the requester has proven possession of
|
|
115
|
+
* @param certificateType The type of certificate
|
|
116
|
+
* @returns The revocation status of the certificate
|
|
117
|
+
*/
|
|
118
|
+
export const revocationStatus = async (env: SshCaBindings, serial: bigint, publicKey: PublicKey, certificateType: CertificateType): Promise<RevocationStatus> => {
|
|
102
119
|
const qb = await connect(env)
|
|
103
120
|
const res = await qb.fetchOne({
|
|
104
121
|
tableName: "certificates",
|
|
105
122
|
fields: ["public_key", "revoked_at"],
|
|
106
123
|
where: {
|
|
107
|
-
conditions: "serial = ? AND
|
|
108
|
-
params: [`${serial}`,
|
|
124
|
+
conditions: "serial = ? AND certificate_type = ?",
|
|
125
|
+
params: [`${serial}`, certificateType]
|
|
109
126
|
}
|
|
110
127
|
}).execute()
|
|
111
128
|
|
|
112
|
-
|
|
129
|
+
// a certificate issued for another key is reported as not found, so
|
|
130
|
+
// requests cannot be used to discover which serials exist
|
|
131
|
+
if (res.results?.public_key == null || !isKey(res.results.public_key, publicKey)) {
|
|
113
132
|
return RevocationStatus.NotFound
|
|
114
133
|
}
|
|
115
134
|
|
|
@@ -117,11 +136,7 @@ export const revocationStatus = async (env: SshCaBindings, serial: bigint, publi
|
|
|
117
136
|
return RevocationStatus.Revoked
|
|
118
137
|
}
|
|
119
138
|
|
|
120
|
-
|
|
121
|
-
return RevocationStatus.Revokable
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return RevocationStatus.Unrevokable
|
|
139
|
+
return RevocationStatus.Revokable
|
|
125
140
|
}
|
|
126
141
|
|
|
127
142
|
export const getRevocationList = async (env: SshCaBindings, certificateType: CertificateType): Promise<string[]> => {
|
package/src/krl.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
1
|
+
import type { PrivateKey } from "./ssh/keys.js"
|
|
2
|
+
import { createSigner } from "./ssh/signer.js"
|
|
3
3
|
|
|
4
4
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
5
5
|
|
|
@@ -59,32 +59,6 @@ function encodeSection(sectionType: number, data: Uint8Array): Uint8Array {
|
|
|
59
59
|
return concat(new Uint8Array([sectionType]), encodeString(data))
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
// ── CA public-key extraction ─────────────────────────────────────────────────
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Extract the SSH wire-format public key bytes from an sshpk PrivateKey.
|
|
66
|
-
*
|
|
67
|
-
* sshpk's "ssh" format produces a string like:
|
|
68
|
-
* ssh-ed25519 AAAA...base64... [comment]
|
|
69
|
-
*
|
|
70
|
-
* The base64 blob *is* the SSH wire encoding of the public key, so we just
|
|
71
|
-
* decode it. This works for both Ed25519 and ECDSA keys.
|
|
72
|
-
*/
|
|
73
|
-
function caKeyWireBytes(caKey: PrivateKey): Uint8Array {
|
|
74
|
-
const sshString = caKey.toPublic().toString("ssh")
|
|
75
|
-
// Format: "<type> <base64> [<comment>]" — we want the second token.
|
|
76
|
-
const base64 = sshString.trim().split(/\s+/)[1]
|
|
77
|
-
if (!base64) {
|
|
78
|
-
throw new Error("Unexpected sshpk 'ssh' format output: " + sshString)
|
|
79
|
-
}
|
|
80
|
-
const binary = atob(base64)
|
|
81
|
-
const bytes = new Uint8Array(binary.length)
|
|
82
|
-
for (let i = 0; i < binary.length; i++) {
|
|
83
|
-
bytes[i] = binary.charCodeAt(i)
|
|
84
|
-
}
|
|
85
|
-
return bytes
|
|
86
|
-
}
|
|
87
|
-
|
|
88
62
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
89
63
|
|
|
90
64
|
const SSHSIG_MAGIC = new TextEncoder().encode("SSHSIG")
|
|
@@ -92,156 +66,6 @@ const SIG_VERSION = 1
|
|
|
92
66
|
const NAMESPACE = "krl@com.github.serverless-ssh-ca.andrewheberle"
|
|
93
67
|
const HASH_ALGORITHM = "sha512"
|
|
94
68
|
|
|
95
|
-
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Get the raw bytes of a named part of an sshpk key.
|
|
99
|
-
*/
|
|
100
|
-
function keyPart(key: PrivateKey, name: AlgorithmPart): Uint8Array {
|
|
101
|
-
const part = key.parts.find((p) => p.name === name)
|
|
102
|
-
if (part === undefined) {
|
|
103
|
-
throw new Error(`Missing ${name} part in ${key.type} key`)
|
|
104
|
-
}
|
|
105
|
-
return part.data
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Base64url-encode bytes without padding, as used for JWK values.
|
|
110
|
-
*/
|
|
111
|
-
function base64url(bytes: Uint8Array): string {
|
|
112
|
-
let binary = ""
|
|
113
|
-
for (const byte of bytes) {
|
|
114
|
-
binary += String.fromCharCode(byte)
|
|
115
|
-
}
|
|
116
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Import an sshpk PrivateKey into a WebCrypto CryptoKey.
|
|
121
|
-
* Supports Ed25519 and ECDSA (P-256 / P-384 / P-521).
|
|
122
|
-
*/
|
|
123
|
-
async function importPrivateKey(caKey: PrivateKey): Promise<{
|
|
124
|
-
cryptoKey: CryptoKey
|
|
125
|
-
sigAlgo: string // SSH signature algorithm name
|
|
126
|
-
webCryptoAlgo: Parameters<SubtleCrypto["sign"]>[0]
|
|
127
|
-
}> {
|
|
128
|
-
const type = caKey.type // "ed25519" | "ecdsa" | etc.
|
|
129
|
-
const curve = (caKey as unknown as { curve?: string }).curve
|
|
130
|
-
|
|
131
|
-
if (type === "ed25519") {
|
|
132
|
-
// import from the raw key parts as a JWK rather than via sshpk's PKCS#8
|
|
133
|
-
// output, which drops a leading zero byte from the seed (about 1 key in
|
|
134
|
-
// 512) and is then rejected by WebCrypto
|
|
135
|
-
const seed = keyPart(caKey, "k")
|
|
136
|
-
const publicKey = keyPart(caKey, "A")
|
|
137
|
-
if (seed.length !== 32 || publicKey.length !== 32) {
|
|
138
|
-
throw new Error(`Unexpected Ed25519 key part lengths: k=${seed.length} A=${publicKey.length}`)
|
|
139
|
-
}
|
|
140
|
-
const cryptoKey = await crypto.subtle.importKey(
|
|
141
|
-
"jwk",
|
|
142
|
-
{ kty: "OKP", crv: "Ed25519", d: base64url(seed), x: base64url(publicKey) },
|
|
143
|
-
{ name: "Ed25519" },
|
|
144
|
-
false,
|
|
145
|
-
["sign"],
|
|
146
|
-
)
|
|
147
|
-
return {
|
|
148
|
-
cryptoKey,
|
|
149
|
-
sigAlgo: "ssh-ed25519",
|
|
150
|
-
webCryptoAlgo: { name: "Ed25519" },
|
|
151
|
-
}
|
|
152
|
-
} else if (type === "ecdsa") {
|
|
153
|
-
let namedCurve: string
|
|
154
|
-
let sigAlgo: string
|
|
155
|
-
let hash: string
|
|
156
|
-
let size: number // bytes in each coordinate and the private key
|
|
157
|
-
|
|
158
|
-
if (curve === "nistp256") {
|
|
159
|
-
namedCurve = "P-256"
|
|
160
|
-
sigAlgo = "ecdsa-sha2-nistp256"
|
|
161
|
-
hash = "SHA-256"
|
|
162
|
-
size = 32
|
|
163
|
-
} else if (curve === "nistp384") {
|
|
164
|
-
namedCurve = "P-384"
|
|
165
|
-
sigAlgo = "ecdsa-sha2-nistp384"
|
|
166
|
-
hash = "SHA-384"
|
|
167
|
-
size = 48
|
|
168
|
-
} else if (curve === "nistp521") {
|
|
169
|
-
namedCurve = "P-521"
|
|
170
|
-
sigAlgo = "ecdsa-sha2-nistp521"
|
|
171
|
-
hash = "SHA-512"
|
|
172
|
-
size = 66
|
|
173
|
-
} else {
|
|
174
|
-
throw new Error(`Unsupported ECDSA curve: ${curve}`)
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// import from the raw key parts as a JWK rather than via sshpk's PKCS#8
|
|
178
|
-
// output, which drops leading zero bytes from the private key so it is
|
|
179
|
-
// shorter than RFC 5915 requires (about half of P-521 keys)
|
|
180
|
-
const point = keyPart(caKey, "Q")
|
|
181
|
-
if (point.length !== 1 + size * 2 || point[0] !== 0x04) {
|
|
182
|
-
throw new Error(`Unexpected ECDSA public key encoding for ${curve}`)
|
|
183
|
-
}
|
|
184
|
-
// d is an SSH mpint, so may be short or have a leading sign byte
|
|
185
|
-
const d = toFixedWidth(keyPart(caKey, "d"), size)
|
|
186
|
-
const cryptoKey = await crypto.subtle.importKey(
|
|
187
|
-
"jwk",
|
|
188
|
-
{
|
|
189
|
-
kty: "EC",
|
|
190
|
-
crv: namedCurve,
|
|
191
|
-
d: base64url(d),
|
|
192
|
-
x: base64url(point.subarray(1, 1 + size)),
|
|
193
|
-
y: base64url(point.subarray(1 + size)),
|
|
194
|
-
},
|
|
195
|
-
{ name: "ECDSA", namedCurve },
|
|
196
|
-
false,
|
|
197
|
-
["sign"],
|
|
198
|
-
)
|
|
199
|
-
return {
|
|
200
|
-
cryptoKey,
|
|
201
|
-
sigAlgo,
|
|
202
|
-
webCryptoAlgo: { name: "ECDSA", hash: { name: hash } },
|
|
203
|
-
}
|
|
204
|
-
} else {
|
|
205
|
-
throw new Error(`Unsupported key type: ${type}`)
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Convert a WebCrypto ECDSA signature (IEEE P1363: raw r||s) to the SSH wire
|
|
211
|
-
* encoding: string(algo) + string(mpint(r) + mpint(s)).
|
|
212
|
-
*
|
|
213
|
-
* SSH mpints are big-endian, with a leading 0x00 byte if the high bit is set.
|
|
214
|
-
*/
|
|
215
|
-
function p1363ToSshEcdsaSig(
|
|
216
|
-
p1363: Uint8Array,
|
|
217
|
-
sigAlgo: string,
|
|
218
|
-
): Uint8Array {
|
|
219
|
-
const half = p1363.length / 2
|
|
220
|
-
const r = p1363.slice(0, half)
|
|
221
|
-
const s = p1363.slice(half)
|
|
222
|
-
|
|
223
|
-
function encodeMpint(n: Uint8Array): Uint8Array {
|
|
224
|
-
// Strip leading zero bytes, then re-pad if high bit set.
|
|
225
|
-
let start = 0
|
|
226
|
-
while (start < n.length - 1 && n[start] === 0) start++
|
|
227
|
-
const trimmed = n.slice(start)
|
|
228
|
-
const padded =
|
|
229
|
-
trimmed[0]! & 0x80 ? concat(new Uint8Array([0x00]), trimmed) : trimmed
|
|
230
|
-
return encodeString(padded)
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const innerSig = concat(encodeMpint(r), encodeMpint(s))
|
|
234
|
-
return concat(encodeString(sigAlgo), encodeString(innerSig))
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Wrap an Ed25519 WebCrypto signature into SSH wire encoding:
|
|
239
|
-
* string(algo) + string(sig_bytes)
|
|
240
|
-
*/
|
|
241
|
-
function wrapEd25519Sig(sig: Uint8Array): Uint8Array {
|
|
242
|
-
return concat(encodeString("ssh-ed25519"), encodeString(sig))
|
|
243
|
-
}
|
|
244
|
-
|
|
245
69
|
/**
|
|
246
70
|
* Base64-encode bytes and wrap in SSH signature armor at 76 chars per line.
|
|
247
71
|
*/
|
|
@@ -310,7 +134,7 @@ export class KRLBuilder {
|
|
|
310
134
|
const keyIds = [...this._keyIds].sort()
|
|
311
135
|
|
|
312
136
|
const content = concat(
|
|
313
|
-
encodeString(
|
|
137
|
+
encodeString(this._caKey.publicKey.blob),
|
|
314
138
|
encodeUint32(serials.length),
|
|
315
139
|
...serials.map(encodeUint64),
|
|
316
140
|
encodeUint32(keyIds.length),
|
|
@@ -375,7 +199,7 @@ export class KRLBuilder {
|
|
|
375
199
|
// <one or more cert sub-sections>
|
|
376
200
|
//
|
|
377
201
|
const certSectionData = concat(
|
|
378
|
-
encodeString(
|
|
202
|
+
encodeString(this._caKey.publicKey.blob), // ca_key
|
|
379
203
|
encodeString(""), // reserved
|
|
380
204
|
...certSubSections
|
|
381
205
|
)
|
|
@@ -409,7 +233,7 @@ export class KRLBuilder {
|
|
|
409
233
|
} else {
|
|
410
234
|
krl = this._krl
|
|
411
235
|
}
|
|
412
|
-
const
|
|
236
|
+
const signer = await createSigner(this._caKey)
|
|
413
237
|
|
|
414
238
|
// 1. Hash the KRL bytes with SHA-512.
|
|
415
239
|
const krlHash = new Uint8Array(
|
|
@@ -431,18 +255,10 @@ export class KRLBuilder {
|
|
|
431
255
|
encodeString(krlHash),
|
|
432
256
|
)
|
|
433
257
|
|
|
434
|
-
// 3. Sign the blob.
|
|
435
|
-
const
|
|
436
|
-
await crypto.subtle.sign(webCryptoAlgo, cryptoKey, signedData),
|
|
437
|
-
)
|
|
438
|
-
|
|
439
|
-
// 4. Encode the signature into SSH wire format.
|
|
440
|
-
const sshSig =
|
|
441
|
-
sigAlgo === "ssh-ed25519"
|
|
442
|
-
? wrapEd25519Sig(rawSig)
|
|
443
|
-
: p1363ToSshEcdsaSig(rawSig, sigAlgo)
|
|
258
|
+
// 3. Sign the blob, which returns the signature in SSH wire format.
|
|
259
|
+
const sshSig = await signer.sign(signedData)
|
|
444
260
|
|
|
445
|
-
//
|
|
261
|
+
// 4. Assemble the outer SSHSIG blob.
|
|
446
262
|
//
|
|
447
263
|
// byte[6] "SSHSIG"
|
|
448
264
|
// uint32 SIG_VERSION (1)
|
|
@@ -451,18 +267,17 @@ export class KRLBuilder {
|
|
|
451
267
|
// string reserved (empty)
|
|
452
268
|
// string hash_algorithm
|
|
453
269
|
// string signature
|
|
454
|
-
const publicKeyWire = caKeyWireBytes(this._caKey)
|
|
455
270
|
const sigBlob = concat(
|
|
456
271
|
SSHSIG_MAGIC,
|
|
457
272
|
encodeUint32(SIG_VERSION),
|
|
458
|
-
encodeString(
|
|
273
|
+
encodeString(signer.publicKey),
|
|
459
274
|
encodeString(NAMESPACE),
|
|
460
275
|
encodeString(""), // reserved
|
|
461
276
|
encodeString(HASH_ALGORITHM),
|
|
462
277
|
encodeString(sshSig),
|
|
463
278
|
)
|
|
464
279
|
|
|
465
|
-
//
|
|
280
|
+
// 5. Armor and return.
|
|
466
281
|
return armorSignature(sigBlob)
|
|
467
282
|
}
|
|
468
283
|
}
|