lemon-tls 0.9.0 → 0.11.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.
@@ -1,6 +1,9 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
+ import * as PEM from 'cryptographic-formats/pem';
5
+ import * as PKCS8 from 'cryptographic-formats/pkcs8';
6
+ import { parse_cert } from './session/x509.js';
4
7
 
5
8
  function looksLikePath(x) {
6
9
  return typeof x === 'string' && (x.indexOf('\n') === -1) && (x.length < 4096) &&
@@ -22,31 +25,36 @@ function isPEM(buf) {
22
25
  return s.indexOf('-----BEGIN ') !== -1 && s.indexOf('-----END ') !== -1;
23
26
  }
24
27
 
28
+ // The PEM blocks of a text (cryptographic-formats, RFC 7468): [{ type, der, block }].
29
+ // Text between blocks — the comments of a CA bundle — is passed over. A
30
+ // block that does not decode fails the whole text: nothing is taken from a
31
+ // file that is not what it says (see normalizeCA).
25
32
  function splitPEMBlocks(pemText) {
26
- let out = [];
27
- let re = /-----BEGIN ([A-Z0-9 \-]+)-----([\s\S]*?)-----END \1-----/g;
28
- let m;
29
- while ((m = re.exec(pemText)) !== null) {
30
- let typ = m[1].trim();
31
- let b64 = m[2].replace(/[\r\n\s]/g, '');
32
- let derBuf = Buffer.from(b64, 'base64');
33
- out.push({ type: typ, der: new Uint8Array(derBuf) });
34
- }
35
- return out;
33
+ return PEM.decode(pemText).map(function (b) { return { type: b.label, der: b.der, block: b }; });
36
34
  }
35
+ // A certificate block's label: 'CERTIFICATE', and the 'X509 CERTIFICATE' /
36
+ // 'TRUSTED CERTIFICATE' spellings (OpenSSL's aux form, whose trailing trust
37
+ // data the certificate reader then refuses).
38
+ function isCertificateBlock(b) { return b.type.indexOf('CERTIFICATE') !== -1; }
37
39
 
38
40
  function ensureArray(x) { return x == null ? [] : (Array.isArray(x) ? x : [x]); }
39
41
 
40
- function normalizeCA(caOption) {
42
+ // The `ca` certificates, as DER. A PEM block that does not decode is
43
+ // skipped, as node:tls skips it — not silently: each is in `skipped`
44
+ // ({ label, line, code, detail }, cryptographic-formats' decode_skipping), which
45
+ // createSecureContext returns as `caSkipped`.
46
+ function normalizeCA(caOption, skipped) {
41
47
  let arr = ensureArray(caOption);
42
48
  let ders = [];
43
49
  for (let i = 0; i < arr.length; i++) {
44
50
  let raw = readMaybeFile(arr[i]);
45
51
  if (!raw) continue;
46
52
  if (isPEM(raw)) {
47
- let blocks = splitPEMBlocks(raw.toString('utf8'));
48
- for (let j = 0; j < blocks.length; j++) {
49
- if (blocks[j].type.indexOf('CERTIFICATE') !== -1) ders.push(blocks[j].der);
53
+ let read;
54
+ try { read = PEM.decode_skipping(raw.toString('utf8')); } catch (e) { read = { blocks: [], skipped: [] }; }
55
+ for (let j = 0; j < read.skipped.length; j++) skipped.push(read.skipped[j]);
56
+ for (let j = 0; j < read.blocks.length; j++) {
57
+ if (read.blocks[j].label.indexOf('CERTIFICATE') !== -1) ders.push(read.blocks[j].der);
50
58
  }
51
59
  } else {
52
60
  ders.push(new Uint8Array(raw));
@@ -55,37 +63,72 @@ function normalizeCA(caOption) {
55
63
  return ders;
56
64
  }
57
65
 
66
+ // node:tls's X509Certificate, for the `certObjs` this returns (an API
67
+ // field). Everything decided here reads the certificate with cryptographic-formats.
58
68
  function makeX509FromDerOrPem(buf) {
59
69
  return new crypto.X509Certificate(Buffer.from(buf));
60
70
  }
61
71
 
72
+ // A certificate's public key as node:crypto exports it (SPKI DER): the form
73
+ // a private key's public half is compared in. Through a KeyObject, so two
74
+ // encodings of one key — named or explicit curve parameters, a compressed
75
+ // point — compare equal, as they did.
76
+ function certPublicKeyDer(der) {
77
+ let cert = parse_cert(der);
78
+ if (!cert) {
79
+ let e = new Error('createSecureContext: the certificate does not parse');
80
+ e.code = 'ERR_INVALID_CERTIFICATE';
81
+ throw e;
82
+ }
83
+ let key = cert.publicKey();
84
+ return key ? key.export({ type: 'spki', format: 'der' }) : null;
85
+ }
86
+
87
+ // The DER form of a private key, by what cryptographic-formats reads it as:
88
+ // PKCS#8 (plain or encrypted), PKCS#1 (RSA) or SEC 1 (EC) — node:crypto is
89
+ // told which, rather than tried with each in turn.
90
+ function derKeyType(der) {
91
+ let readers = [['pkcs8', PKCS8.from_der], ['pkcs8', PKCS8.encrypted_from_der],
92
+ ['pkcs1', PKCS8.pkcs1_from_der], ['sec1', PKCS8.sec1_from_der]];
93
+ for (let i = 0; i < readers.length; i++) {
94
+ try { readers[i][1](der); return readers[i][0]; } catch (e) { /* the next */ }
95
+ }
96
+ return null;
97
+ }
98
+ // The same, from a PEM label (RFC 7468 §10, §11; OpenSSL's traditional ones)
99
+ const PEM_KEY_TYPES = {
100
+ 'PRIVATE KEY': 'pkcs8', 'ENCRYPTED PRIVATE KEY': 'pkcs8',
101
+ 'RSA PRIVATE KEY': 'pkcs1', 'EC PRIVATE KEY': 'sec1',
102
+ };
103
+
62
104
  function makePrivateKeyFromDerOrPem(buf, passphrase) {
63
105
  // node:crypto rejects an explicit `passphrase: null` with ERR_INVALID_ARG_VALUE
64
106
  // — for an unencrypted key the property must be ABSENT, not present-and-null.
65
107
  // Callers that forward an optional option straight through would otherwise
66
108
  // break every unencrypted key, which is the overwhelmingly common case.
67
109
  let pass = (passphrase === null || passphrase === undefined) ? undefined : passphrase;
110
+ let withPass = function (o) { if (pass !== undefined) o.passphrase = pass; return o; };
68
111
 
112
+ let der, type;
69
113
  if (isPEM(buf)) {
70
- let opts = { key: buf, format: 'pem' };
71
- if (pass !== undefined) opts.passphrase = pass;
72
- return crypto.createPrivateKey(opts);
114
+ // The key's block (a file from `openssl ecparam -genkey` starts with an
115
+ // EC PARAMETERS block), read by cryptographic-formats.
116
+ let blocks = splitPEMBlocks(buf.toString('utf8'));
117
+ let block = null;
118
+ for (let i = 0; i < blocks.length && !block; i++) if (PEM_KEY_TYPES[blocks[i].type]) block = blocks[i];
119
+ if (!block) throw new Error('createSecureContext: no private key in the PEM (' +
120
+ blocks.map(function (b) { return b.type; }).join(', ') + ')');
121
+ // RFC 1421's encrypted form (Proc-Type / DEK-Info headers): its
122
+ // decryption is node:crypto's, and node:crypto reads it only as PEM.
123
+ if (block.block.encrypted) return crypto.createPrivateKey(withPass({ key: buf, format: 'pem' }));
124
+ der = Buffer.from(block.der);
125
+ type = PEM_KEY_TYPES[block.type];
73
126
  } else {
74
- let der = Buffer.from(buf), keyObj = null;
75
- try {
76
- keyObj = crypto.createPrivateKey(Object.assign({ key: der, format: 'der', type: 'pkcs8' },
77
- pass !== undefined ? { passphrase: pass } : {}));
78
- } catch (e1) {
79
- try {
80
- keyObj = crypto.createPrivateKey(Object.assign({ key: der, format: 'der', type: 'pkcs1' },
81
- pass !== undefined ? { passphrase: pass } : {}));
82
- } catch (e2) {
83
- keyObj = crypto.createPrivateKey(Object.assign({ key: der, format: 'der', type: 'sec1' },
84
- pass !== undefined ? { passphrase: pass } : {}));
85
- }
86
- }
87
- return keyObj;
127
+ der = Buffer.from(buf);
128
+ type = derKeyType(der);
129
+ if (type === null) throw new Error('createSecureContext: the key is not PKCS#8, PKCS#1 or SEC 1 DER');
88
130
  }
131
+ return crypto.createPrivateKey(withPass({ key: der, format: 'der', type: type }));
89
132
  }
90
133
 
91
134
  function exportKeyPkcs8Der(keyObj) {
@@ -134,10 +177,10 @@ function createSecureContext(options) {
134
177
  let cert = null;
135
178
  for (let j = 0; j < certs.length && cert === null; j++) {
136
179
  let raw = readMaybeFile(certs[j]);
137
- let first = isPEM(raw) ? splitPEMBlocks(raw.toString('utf8')).filter(function (b) { return b.type.indexOf('CERTIFICATE') !== -1; })[0] : { der: new Uint8Array(raw) };
180
+ let first = isPEM(raw) ? splitPEMBlocks(raw.toString('utf8')).filter(isCertificateBlock)[0] : { der: new Uint8Array(raw) };
138
181
  if (!first) continue;
139
- let leafPub = makeX509FromDerOrPem(first.der).publicKey.export({ type: 'spki', format: 'der' });
140
- if (Buffer.compare(Buffer.from(leafPub), Buffer.from(pub)) === 0) cert = certs[j];
182
+ let leafPub = certPublicKeyDer(first.der);
183
+ if (leafPub && Buffer.compare(Buffer.from(leafPub), Buffer.from(pub)) === 0) cert = certs[j];
141
184
  }
142
185
  if (cert === null) throw new Error('createSecureContext: no certificate in `cert` for key ' + i);
143
186
  return createSecureContext(Object.assign({}, options, { key: keyIn, passphrase: pass, cert: cert }));
@@ -159,7 +202,7 @@ function createSecureContext(options) {
159
202
  if (isPEM(cRaw)) {
160
203
  let blocks = splitPEMBlocks(cRaw.toString('utf8'));
161
204
  for (let i=0;i<blocks.length;i++) {
162
- if (blocks[i].type.indexOf('CERTIFICATE') !== -1) {
205
+ if (isCertificateBlock(blocks[i])) {
163
206
  certBlocksDer.push(blocks[i].der);
164
207
  certObjs.push(makeX509FromDerOrPem(blocks[i].der));
165
208
  }
@@ -186,8 +229,23 @@ function createSecureContext(options) {
186
229
  if (!privateKey) throw new Error('createSecureContext: missing private key.');
187
230
  }
188
231
 
232
+ // The key must be the certificate's — node:tls throws on this too
233
+ // (KEY_VALUES_MISMATCH). Unchecked, a renewal that paired the new key with
234
+ // the old certificate went in without a word, and every handshake after it
235
+ // failed at the client's signature check.
236
+ if (keyObj && certBlocksDer.length > 0) {
237
+ let fromKey = crypto.createPublicKey(keyObj).export({ type: 'spki', format: 'der' });
238
+ let fromCert = certPublicKeyDer(certBlocksDer[0]);
239
+ if (!fromCert || Buffer.compare(Buffer.from(fromKey), Buffer.from(fromCert)) !== 0) {
240
+ let e = new Error('createSecureContext: the private key does not match the certificate');
241
+ e.code = 'ERR_OSSL_X509_KEY_VALUES_MISMATCH';
242
+ throw e;
243
+ }
244
+ }
245
+
189
246
  // --- CA (Trust store) ---
190
- let ca = normalizeCA(options.ca);
247
+ let caSkipped = [];
248
+ let ca = normalizeCA(options.ca, caSkipped);
191
249
 
192
250
  // --- OCSP stapling (אופציונלי) ---
193
251
  let ocsp = null;
@@ -207,15 +265,15 @@ function createSecureContext(options) {
207
265
 
208
266
  // מידע עזר לזיהוי סוג המפתח הציבורי של ה-leaf
209
267
  let leafPublicKeyType = null;
210
- if (certObjs.length > 0 && certObjs[0].publicKey) {
211
- try { leafPublicKeyType = certObjs[0].publicKey.asymmetricKeyType || null; } catch (e) { leafPublicKeyType = null; }
212
- }
268
+ let leafCert = certBlocksDer.length > 0 ? parse_cert(certBlocksDer[0]) : null;
269
+ if (leafCert && leafCert.publicKey()) leafPublicKeyType = leafCert.publicKey().asymmetricKeyType || null;
213
270
 
214
271
  return {
215
272
  // חומר לשכבת ההנדשייק/רקורד:
216
273
  certificateChain: certificateChain, // [{ cert: DER(Uint8Array) }, ...]
217
274
  privateKey: privateKey, // PKCS#8 DER (Uint8Array)
218
275
  ca: ca, // Trust store (DER)
276
+ caSkipped: caSkipped, // PEM blocks of `ca` that did not decode: [{ label, line, code, detail }]
219
277
  ocsp: ocsp, // DER (אם הוגדר)
220
278
  ticketKeys: ticketKeys, // Uint8Array (אם הוגדר)
221
279
 
@@ -1,94 +1,105 @@
1
1
  /**
2
- * ECDH key generation and shared secret helpers using node:crypto.
3
- * X25519, P-256 and P-384; ML-KEM alone and in hybrids with them (below).
2
+ * The key-exchange groups: the Diffie-Hellman ones — X25519, X448, P-256,
3
+ * P-384, P-521 — and ML-KEM alone and in hybrids with them (below), over
4
+ * node:crypto. The one place a group is implemented: TLS's key exchange,
5
+ * the hybrids' classical halves and HPKE's DHKEMs (hpke.js) all take their
6
+ * primitives from here.
4
7
  */
5
8
 
6
9
  import * as crypto from 'crypto';
7
-
8
- // DER prefixes for raw X25519 key import/export
9
- let X25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex');
10
- let X25519_SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex');
11
-
12
- /**
13
- * Derive the X25519 public key from a 32-byte private key.
14
- */
15
- function x25519_get_public_key(privateKeyRaw) {
16
- let der = Buffer.concat([X25519_PKCS8_PREFIX, Buffer.from(privateKeyRaw)]);
17
- let privObj = crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' });
18
- let pubObj = crypto.createPublicKey(privObj);
19
- let spki = pubObj.export({ type: 'spki', format: 'der' });
20
- return new Uint8Array(spki.subarray(X25519_SPKI_PREFIX.length));
21
- }
22
-
23
- /**
24
- * Compute X25519 shared secret from local private key and remote public key (both raw 32 bytes).
25
- */
26
- function x25519_get_shared_secret(localPrivateRaw, remotePublicRaw) {
27
- let privDer = Buffer.concat([X25519_PKCS8_PREFIX, Buffer.from(localPrivateRaw)]);
28
- let pubDer = Buffer.concat([X25519_SPKI_PREFIX, Buffer.from(remotePublicRaw)]);
29
- let privObj = crypto.createPrivateKey({ key: privDer, format: 'der', type: 'pkcs8' });
30
- let pubObj = crypto.createPublicKey({ key: pubDer, format: 'der', type: 'spki' });
31
- let secret = new Uint8Array(crypto.diffieHellman({ privateKey: privObj, publicKey: pubObj }));
32
-
33
- // RFC 7748 §6.1: implementations MUST reject an all-zero shared secret. A peer
34
- // that sends a low-order/small-order public key can force the X25519 result to
35
- // be all zeros — a value it knows in advance — which would let it choose the
36
- // key material. The `break` here is safe (does not leak a secret): the peer's
37
- // public key is public, so an early exit reveals nothing exploitable.
38
- let allZero = true;
39
- for (let i = 0; i < secret.length; i++) {
40
- if (secret[i] !== 0) { allZero = false; break; }
41
- }
42
- if (allZero) {
43
- throw new Error('X25519: all-zero shared secret — invalid peer public key (RFC 7748 §6.1)');
44
- }
45
-
10
+ import * as SPKI from 'cryptographic-formats/spki';
11
+ import * as PKCS8 from 'cryptographic-formats/pkcs8';
12
+
13
+ // RFC 7748 §6: an all-zero shared secret means the peer's public key was of
14
+ // low order — a value it knew in advance. MUST be rejected. Constant time
15
+ // (the peer's key is public, but there is no reason to branch on secrets).
16
+ function reject_all_zero(secret, name) {
17
+ let acc = 0;
18
+ for (let i = 0; i < secret.length; i++) acc |= secret[i];
19
+ if (acc === 0) throw new Error(name + ': all-zero shared secret — invalid peer public key (RFC 7748 §6)');
46
20
  return secret;
47
21
  }
48
22
 
49
- /**
50
- * Generate a P-256 keypair. Returns { private_key: Uint8Array, public_key: Uint8Array(65) }.
51
- * Public key is uncompressed format (0x04 || x || y).
52
- */
53
- function p256_generate_keypair() {
54
- let ecdh = crypto.createECDH('prime256v1');
55
- ecdh.generateKeys();
56
- return {
57
- private_key: new Uint8Array(ecdh.getPrivateKey()),
58
- public_key: new Uint8Array(ecdh.getPublicKey(null, 'uncompressed'))
23
+ // The native key behind a private key this module generated, by the bytes
24
+ // it handed out. Importing a raw private key is not cheap — for X25519
25
+ // node:crypto computes the public key on import, ~50 µs, as much as the
26
+ // Diffie-Hellman itself — and every handshake imported its own key twice:
27
+ // once to derive the public key, once for the shared secret. The key made by
28
+ // generate() is kept here and used by shared(); a private key from anywhere
29
+ // else (HPKE's, a test vector's) is imported as before. Keyed by the
30
+ // Uint8Array's identity, so the entry goes with the key: nothing outlives it.
31
+ const NATIVE_PRIVATE = new WeakMap();
32
+
33
+ const b64u = (s) => new Uint8Array(Buffer.from(s, 'base64url'));
34
+
35
+ // A Montgomery curve. node:crypto takes and gives its raw keys only inside
36
+ // PKCS#8 and SPKI: cryptographic-formats writes and reads that wrapping (RFC 8410)
37
+ // — it was two DER prefixes written out here by hand.
38
+ function montgomery(name, len) {
39
+ let type = name.toLowerCase();
40
+ let priv = (sk) => NATIVE_PRIVATE.get(sk) ||
41
+ crypto.createPrivateKey({ key: Buffer.from(PKCS8.to_der({ algorithm: name, scalar: sk })), format: 'der', type: 'pkcs8' });
42
+ let c = {
43
+ kind: 'montgomery', len: len, privLen: len,
44
+ public_key: function (sk) {
45
+ return new Uint8Array(SPKI.from_der(crypto.createPublicKey(priv(sk)).export({ type: 'spki', format: 'der' })).point);
46
+ },
47
+ shared: function (sk, pk) {
48
+ let pub = crypto.createPublicKey({ key: Buffer.from(SPKI.to_der({ algorithm: name, point: pk })), format: 'der', type: 'spki' });
49
+ return reject_all_zero(new Uint8Array(crypto.diffieHellman({ privateKey: priv(sk), publicKey: pub })), name);
50
+ },
51
+ // Made natively (the same CSPRNG as randomBytes), and read back as JWK —
52
+ // the raw scalar and public key, without a DER round trip.
53
+ generate: function () {
54
+ let kp = crypto.generateKeyPairSync(type);
55
+ let jwk = kp.privateKey.export({ format: 'jwk' });
56
+ let sk = b64u(jwk.d), pk = b64u(jwk.x);
57
+ if (sk.length !== len || pk.length !== len) throw new Error(name + ': unexpected key size from node:crypto');
58
+ NATIVE_PRIVATE.set(sk, kp.privateKey);
59
+ return { private_key: sk, public_key: pk };
60
+ },
59
61
  };
62
+ return c;
60
63
  }
61
-
62
- /**
63
- * Compute P-256 shared secret (raw x-coordinate, 32 bytes).
64
- */
65
- function p256_get_shared_secret(localPrivateRaw, remotePublicRaw) {
66
- let ecdh = crypto.createECDH('prime256v1');
67
- ecdh.setPrivateKey(Buffer.from(localPrivateRaw));
68
- return new Uint8Array(ecdh.computeSecret(Buffer.from(remotePublicRaw)));
69
- }
70
-
71
- /**
72
- * Generate a P-384 keypair. Returns { private_key: Uint8Array(48), public_key: Uint8Array(97) }.
73
- * Public key is uncompressed format (0x04 || x || y).
74
- */
75
- function p384_generate_keypair() {
76
- let ecdh = crypto.createECDH('secp384r1');
77
- ecdh.generateKeys();
64
+ // A NIST prime curve: shares are uncompressed points (0x04 || X || Y,
65
+ // RFC 8446 §4.2.8.2), the shared secret the x-coordinate.
66
+ function nist(curve, coordLen) {
67
+ let ecdh = (sk) => {
68
+ let e = NATIVE_PRIVATE.get(sk);
69
+ if (e) return e;
70
+ e = crypto.createECDH(curve); e.setPrivateKey(Buffer.from(sk));
71
+ return e;
72
+ };
78
73
  return {
79
- private_key: new Uint8Array(ecdh.getPrivateKey()),
80
- public_key: new Uint8Array(ecdh.getPublicKey(null, 'uncompressed'))
74
+ kind: 'nist', len: 1 + 2 * coordLen, coordLen: coordLen, privLen: coordLen,
75
+ public_key: function (sk) {
76
+ return new Uint8Array(ecdh(sk).getPublicKey(null, 'uncompressed'));
77
+ },
78
+ // computeSecret validates the peer point (on the curve, not infinity)
79
+ // before any secret exists.
80
+ shared: function (sk, pk) {
81
+ return new Uint8Array(ecdh(sk).computeSecret(Buffer.from(pk)));
82
+ },
83
+ generate: function () {
84
+ let e = crypto.createECDH(curve); e.generateKeys();
85
+ // getPrivateKey drops leading zero bytes — half of all P-521 scalars,
86
+ // 1 in 256 of P-256's — and a private key is privLen bytes.
87
+ let raw = e.getPrivateKey(), sk = new Uint8Array(coordLen);
88
+ sk.set(raw, coordLen - raw.length);
89
+ NATIVE_PRIVATE.set(sk, e);
90
+ return { private_key: sk, public_key: new Uint8Array(e.getPublicKey(null, 'uncompressed')) };
91
+ },
81
92
  };
82
93
  }
83
94
 
84
- /**
85
- * Compute P-384 shared secret (raw x-coordinate, 48 bytes).
86
- */
87
- function p384_get_shared_secret(localPrivateRaw, remotePublicRaw) {
88
- let ecdh = crypto.createECDH('secp384r1');
89
- ecdh.setPrivateKey(Buffer.from(localPrivateRaw));
90
- return new Uint8Array(ecdh.computeSecret(Buffer.from(remotePublicRaw)));
91
- }
95
+ const CURVES = {
96
+ x25519: montgomery('X25519', 32),
97
+ x448: montgomery('X448', 56),
98
+ p256: nist('prime256v1', 32),
99
+ p384: nist('secp384r1', 48),
100
+ p521: nist('secp521r1', 66),
101
+ };
102
+
92
103
 
93
104
  /**
94
105
  * Named groups this module actually implements, in preference order.
@@ -104,8 +115,10 @@ function p384_get_shared_secret(localPrivateRaw, remotePublicRaw) {
104
115
  */
105
116
  const NAMED_GROUP = {
106
117
  X25519: 0x001d,
118
+ X448: 0x001e,
107
119
  SECP256R1: 0x0017,
108
120
  SECP384R1: 0x0018,
121
+ SECP521R1: 0x0019,
109
122
  // Post-quantum. KEMs, not Diffie-Hellman: the server does not make a key
110
123
  // pair of its own, it ENCAPSULATES to the client's — see kem_server_exchange.
111
124
  // Hybrids (draft-ietf-tls-ecdhe-mlkem): ML-KEM (FIPS 203) and an ECDH
@@ -124,12 +137,24 @@ const NAMED_GROUP = {
124
137
 
125
138
  // Each group's name — the IANA registry's, as OpenSSL and node:tls spell them.
126
139
  const GROUP_NAMES = {
127
- 0x001d: 'X25519', 0x0017: 'P-256', 0x0018: 'P-384',
140
+ 0x001d: 'X25519', 0x001e: 'X448', 0x0017: 'P-256', 0x0018: 'P-384', 0x0019: 'P-521',
128
141
  0x11ec: 'X25519MLKEM768', 0x11eb: 'SecP256r1MLKEM768', 0x11ed: 'SecP384r1MLKEM1024',
129
142
  0x0200: 'MLKEM512', 0x0201: 'MLKEM768', 0x0202: 'MLKEM1024',
130
143
  };
131
144
  function group_name(group) { return GROUP_NAMES[group] || null; }
132
145
 
146
+ // The Diffie-Hellman groups, by code point: their curve, and how node:tls
147
+ // (getEphemeralKeyInfo) names and sizes them.
148
+ const DH_GROUPS = {
149
+ 0x001d: { curve: CURVES.x25519, ecdhName: 'x25519', bits: 253 },
150
+ 0x001e: { curve: CURVES.x448, ecdhName: 'x448', bits: 448 },
151
+ 0x0017: { curve: CURVES.p256, ecdhName: 'prime256v1', bits: 256 },
152
+ 0x0018: { curve: CURVES.p384, ecdhName: 'secp384r1', bits: 384 },
153
+ 0x0019: { curve: CURVES.p521, ecdhName: 'secp521r1', bits: 521 },
154
+ };
155
+ /** node:tls's description of a DH group ({ name, size }), or null. */
156
+ function dh_group_info(group) { let d = DH_GROUPS[group]; return d ? { name: d.ecdhName, size: d.bits } : null; }
157
+
133
158
  // ML-KEM's three parameter sets (FIPS 203 Table 3): encapsulation key and
134
159
  // ciphertext sizes. The shared secret is 32 bytes for each.
135
160
  const MLKEM = {
@@ -138,18 +163,12 @@ const MLKEM = {
138
163
  1024: { type: 'ml-kem-1024', ek: 1568, ct: 1568 },
139
164
  };
140
165
 
141
- // The classical halves of the hybrids: a share's size, a key pair, a secret.
142
- const ECDH_PART = {
143
- x25519: { len: 32,
144
- generate: function () { let priv = new Uint8Array(crypto.randomBytes(32)); return { private_key: priv, public_key: x25519_get_public_key(priv) }; },
145
- shared: function (priv, pub) { return x25519_get_shared_secret(priv, pub); } },
146
- p256: { len: 65, generate: function () { return p256_generate_keypair(); },
147
- shared: function (priv, pub) { check_point(pub, 65); return p256_get_shared_secret(priv, pub); } },
148
- p384: { len: 97, generate: function () { return p384_generate_keypair(); },
149
- shared: function (priv, pub) { check_point(pub, 97); return p384_get_shared_secret(priv, pub); } },
150
- };
151
- function check_point(pub, len) {
152
- if (pub.length !== len || pub[0] !== 0x04) throw new Error('the ECDH part must be a ' + len + '-byte uncompressed point');
166
+ // The classical halves of the hybrids: the curves above, their parts checked
167
+ // as a group's share is (normalize_dh_share).
168
+ const ECDH_PART = {};
169
+ for (const name of ['x25519', 'p256', 'p384']) {
170
+ let c = CURVES[name];
171
+ ECDH_PART[name] = { len: c.len, generate: c.generate, shared: function (sk, pk) { return c.shared(sk, normalize_dh_share(c, pk)); } };
153
172
  }
154
173
 
155
174
  // The KEM groups: which ML-KEM, which ECDH half (none for ML-KEM alone), and
@@ -172,9 +191,12 @@ const KEM_GROUPS = {
172
191
  //
173
192
  // Decided once per parameter set, by a real round trip rather than by the
174
193
  // functions existing (on BoringSSL, crypto.encapsulate can be defined and
175
- // still not know ML-KEM) — and the raw key's SPKI wrapping taken from the key
176
- // made: a fixed DER prefix, as for X25519 above.
177
- const MLKEM_SPKI_PREFIX = {};
194
+ // still not know ML-KEM). The raw key's SPKI wrapping is cryptographic-formats'
195
+ // (RFC 9935), and the probe checks it is the one node:crypto writes: a set
196
+ // where they differ is not offered.
197
+ function mlkem_spki(bits, raw) {
198
+ return Buffer.from(SPKI.to_der({ algorithm: 'mlKem' + bits, point: raw }));
199
+ }
178
200
  function probe_mlkem(bits) {
179
201
  if (typeof crypto.encapsulate !== 'function' || typeof crypto.decapsulate !== 'function') return false;
180
202
  try {
@@ -184,8 +206,8 @@ function probe_mlkem(bits) {
184
206
  let dec = crypto.decapsulate(kp.privateKey, enc.ciphertext);
185
207
  if (enc.ciphertext.length !== p.ct || Buffer.compare(Buffer.from(enc.sharedKey), Buffer.from(dec)) !== 0) return false;
186
208
  let spki = kp.publicKey.export({ format: 'der', type: 'spki' });
187
- MLKEM_SPKI_PREFIX[bits] = Buffer.from(spki.subarray(0, spki.length - p.ek));
188
- return true;
209
+ let ek = SPKI.from_der(spki).point;
210
+ return ek.length === p.ek && Buffer.compare(mlkem_spki(bits, ek), Buffer.from(spki)) === 0;
189
211
  } catch (e) {
190
212
  return false;
191
213
  }
@@ -196,7 +218,7 @@ const MLKEM_AVAILABLE = MLKEM_SETS[768];
196
218
  // Import checks the key (FIPS 203 §7.2: every coefficient below q) and throws
197
219
  // on a bad one — before any secret exists.
198
220
  function mlkem_public_key(bits, raw) {
199
- return crypto.createPublicKey({ key: Buffer.concat([MLKEM_SPKI_PREFIX[bits], Buffer.from(raw)]), format: 'der', type: 'spki' });
221
+ return crypto.createPublicKey({ key: mlkem_spki(bits, raw), format: 'der', type: 'spki' });
200
222
  }
201
223
 
202
224
  function kem_group_available(group) {
@@ -239,6 +261,11 @@ const SERVER_DEFAULT_GROUPS = [
239
261
  NAMED_GROUP.X25519,
240
262
  NAMED_GROUP.SECP256R1,
241
263
  NAMED_GROUP.SECP384R1,
264
+ // Taken from a client that offers them — none offers only these by
265
+ // default, so they come last (OpenSSL's order: X448 after the NIST
266
+ // curves it offers by default, P-521 last).
267
+ NAMED_GROUP.X448,
268
+ NAMED_GROUP.SECP521R1,
242
269
  ]);
243
270
 
244
271
  // Everything implemented here, on either side. A KEM group only where
@@ -260,16 +287,13 @@ function is_kem_group(group) {
260
287
  * Returns { private_key, public_key } or null for an unsupported group.
261
288
  */
262
289
  function generate_keypair(group) {
263
- if (group === NAMED_GROUP.X25519) return ECDH_PART.x25519.generate();
264
- if (group === NAMED_GROUP.SECP256R1) return p256_generate_keypair();
265
- if (group === NAMED_GROUP.SECP384R1) return p384_generate_keypair();
290
+ if (DH_GROUPS[group]) return DH_GROUPS[group].curve.generate();
266
291
  if (kem_group_available(group)) {
267
292
  // The CLIENT side: an ML-KEM key pair, and an ECDH one for a hybrid; the
268
293
  // share is the two public keys in the group's order.
269
294
  let d = KEM_GROUPS[group], sz = kem_sizes(group);
270
295
  let kem = crypto.generateKeyPairSync(MLKEM[d.kem].type);
271
- let spki = kem.publicKey.export({ format: 'der', type: 'spki' });
272
- let ek = spki.subarray(spki.length - sz.ek);
296
+ let ek = SPKI.from_der(kem.publicKey.export({ format: 'der', type: 'spki' })).point;
273
297
  let ec = d.ecdh ? ECDH_PART[d.ecdh].generate() : null;
274
298
  let pub = new Uint8Array(sz.client);
275
299
  if (!ec) pub.set(ek, 0);
@@ -351,17 +375,17 @@ function normalize_peer_public_key(group, pub) {
351
375
  return pub;
352
376
  }
353
377
 
354
- if (group === NAMED_GROUP.X25519) {
355
- if (pub.length !== 32) {
356
- throw new Error('X25519 key_exchange must be 32 bytes, got ' + pub.length);
357
- }
378
+ if (!DH_GROUPS[group]) throw new Error('unsupported group 0x' + (group >>> 0).toString(16));
379
+ return normalize_dh_share(DH_GROUPS[group].curve, pub);
380
+ }
381
+
382
+ // A Diffie-Hellman share as the curve has it, or an error.
383
+ function normalize_dh_share(c, pub) {
384
+ if (c.kind === 'montgomery') {
385
+ if (pub.length !== c.len) throw new Error('key_exchange must be ' + c.len + ' bytes, got ' + pub.length);
358
386
  return pub;
359
387
  }
360
-
361
- let coordLen = (group === NAMED_GROUP.SECP256R1) ? 32
362
- : (group === NAMED_GROUP.SECP384R1) ? 48 : 0;
363
- if (coordLen === 0) throw new Error('unsupported group 0x' + (group >>> 0).toString(16));
364
-
388
+ let coordLen = c.coordLen;
365
389
  // RFC 8446 §4.2.8.2: for the secp*r1 groups the key_exchange is an
366
390
  // UNCOMPRESSED point — 0x04 || X || Y — and nothing else. The length is
367
391
  // therefore exact.
@@ -373,7 +397,7 @@ function normalize_peer_public_key(group, pub) {
373
397
  // as a DIFFERENT, prefix-less point and happily fed to the ECDH. The same
374
398
  // arithmetic applies to P-384 (97 → 96). Malformed input must be rejected,
375
399
  // not silently reinterpreted.
376
- let expected = 1 + 2 * coordLen;
400
+ let expected = c.len;
377
401
 
378
402
  if (pub.length !== expected) {
379
403
  // Name the two shapes we know we are refusing, so the failure is
@@ -394,7 +418,7 @@ function normalize_peer_public_key(group, pub) {
394
418
  }
395
419
 
396
420
  // Whether the point actually lies on the curve is checked by the ECDH
397
- // primitive below (node throws ERR_CRYPTO_ECDH_INVALID_PUBLIC_KEY), which
421
+ // primitive (node throws ERR_CRYPTO_ECDH_INVALID_PUBLIC_KEY), which
398
422
  // happens before any secret is produced — so an off-curve point can never
399
423
  // yield key material.
400
424
  return pub;
@@ -408,9 +432,7 @@ function normalize_peer_public_key(group, pub) {
408
432
  function get_shared_secret(group, localPrivateRaw, remotePublicRaw) {
409
433
  let pub = normalize_peer_public_key(group, remotePublicRaw);
410
434
 
411
- if (group === NAMED_GROUP.X25519) return x25519_get_shared_secret(localPrivateRaw, pub);
412
- if (group === NAMED_GROUP.SECP256R1) return p256_get_shared_secret(localPrivateRaw, pub);
413
- if (group === NAMED_GROUP.SECP384R1) return p384_get_shared_secret(localPrivateRaw, pub);
435
+ if (DH_GROUPS[group]) return DH_GROUPS[group].curve.shared(localPrivateRaw, pub);
414
436
  if (kem_group_available(group)) {
415
437
  // The CLIENT side: the server's share is the ciphertext (and, for a
416
438
  // hybrid, the server's ECDH public key), in the group's order.
@@ -442,10 +464,6 @@ export {
442
464
  generate_keypair,
443
465
  normalize_peer_public_key,
444
466
  get_shared_secret,
445
- x25519_get_public_key,
446
- x25519_get_shared_secret,
447
- p256_generate_keypair,
448
- p256_get_shared_secret,
449
- p384_generate_keypair,
450
- p384_get_shared_secret
467
+ dh_group_info,
468
+ CURVES,
451
469
  };