@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
@@ -1,14 +1,9 @@
1
1
  import { seconds } from "itty-time"
2
- import sshpk from "sshpk"
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
- // sshpk is CommonJS and Node's ESM loader cannot detect most of its named exports, so values are taken from the default import
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
- export const generateCertificate = (env: SshCaBindings, email: string, key: PrivateKey, public_key: Key, options?: GenerateCertificateOptions): Certificate => {
45
- let { lifetime } = options ?? {}
46
- const { principals, extensions, serial } = options ?? {}
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
- // add identities
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 identity = includeSelf
55
- ? [identityForUser(email.split("@")[0]!)]
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
- identity.push(identityForUser(email))
72
+ certificatePrincipals.push(email)
66
73
  }
67
74
 
68
75
  if (principals !== undefined) {
69
- for (const p of principals) {
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
- // add usage extensions
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
- // re-sign after changes
139
- certificate.signWith(key)
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 certificate
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: Key, options?: CreateCertificateOptions): Promise<Certificate> {
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: Key, options?: CreateHostCertificateOptions): Promise<Certificate> {
175
- if (options === undefined) {
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.lifetime !== undefined
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
- // generate value for serial of certificate
203
- const serial = generateSerial()
204
-
205
- // set issuer of certificate based on ISSUER_DN
206
- const issuer = identityFromDN(env.ISSUER_DN)
207
-
208
- // create certificate
209
- const certificate = createCertificate(identity, public_key, issuer, key, { lifetime: lifetime, serial: serial })
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
- export const generateSerial = (serial?: bigint): Buffer<ArrayBuffer> => {
215
- if (serial !== undefined) {
216
- const s = Buffer.alloc(8)
217
- s.writeBigUInt64BE(serial)
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 type { Certificate, Format, Identity } from "sshpk"
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: Certificate, keyid: string, certificateType: CertificateType = CertificateType.User) => {
52
- const serial = certificate.serial.readBigUInt64BE(0)
53
- const subjects = certificate.subjects.map((v: Identity): string => {
54
- return v.toString()
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: subjects,
67
- extensions: extensions,
68
- valid_after: certificate.validFrom.toISOString(),
69
- valid_before: certificate.validUntil.toISOString(),
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.subjectKey.toString("ssh")
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
- export const revocationStatus = async (env: SshCaBindings, serial: bigint, publickey: string, certificateType: CertificateType): Promise<RevocationStatus> => {
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 public_key = ? AND certificate_type = ?",
108
- params: [`${serial}`, publickey, certificateType]
124
+ conditions: "serial = ? AND certificate_type = ?",
125
+ params: [`${serial}`, certificateType]
109
126
  }
110
127
  }).execute()
111
128
 
112
- if (res.results === undefined) {
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
- if (publickey === res.results.public_key) {
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 { AlgorithmPart, PrivateKey } from "sshpk"
2
- import { toFixedWidth } from "./sshsig/sig_parser.js"
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(caKeyWireBytes(this._caKey)),
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(caKeyWireBytes(this._caKey)), // ca_key
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 { cryptoKey, sigAlgo, webCryptoAlgo } = await importPrivateKey(this._caKey)
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 rawSig = new Uint8Array(
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
- // 5. Assemble the outer SSHSIG blob.
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(publicKeyWire),
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
- // 6. Armor and return.
280
+ // 5. Armor and return.
466
281
  return armorSignature(sigBlob)
467
282
  }
468
283
  }