diff --git a/README.md b/README.md index e08c929e..fca9af02 100644 --- a/README.md +++ b/README.md @@ -1093,6 +1093,8 @@ You can find more examples in the `examples` directory of this repository. * **debug** - _function_ - Set this to a function that receives a single string argument to get detailed (local) debug information. **Default:** (none) + * **getDHParams** - _function_ - To enable support for `diffie-hellman-group-exchange-*` key exchanges, set this to a function that receives the client's prime size requirements and preference (`minBits`, `prefBits`, `maxBits`) and a `callback` as its four arguments. The callback has the signature `(err, prime, generator)` where `prime` and `generator` are `Buffer`s (see `crypto.createDiffieHellman`). Call the callback with an `Error` as the first argument if no prime matching the client's request is available. The async callback pattern allows offloading the CPU-intensive prime generation/lookup to worker threads or child processes. **Default:** (none) + * **greeting** - _string_ - A message that is sent to clients immediately upon connection, before handshaking begins. **Note:** Most clients usually ignore this. **Default:** (none) * **highWaterMark** - _integer_ - This is the `highWaterMark` to use for the parser stream. **Default:** `32 * 1024` diff --git a/lib/protocol/Protocol.js b/lib/protocol/Protocol.js index 73024881..33e1f294 100644 --- a/lib/protocol/Protocol.js +++ b/lib/protocol/Protocol.js @@ -210,6 +210,17 @@ class Protocol { ? config.banner : `${config.banner}\r\n`); } + + if (typeof config.getDHParams === 'function') { + this._getDHParams = config.getDHParams; + } else { + // Default implementation calls callback with error, + // which will cause the key exchange to fail gracefully + this._getDHParams = (min, pref, max, cb) => { + cb(new Error('No DH parameters available')); + }; + } + } else { this._hostKeys = undefined; } diff --git a/lib/protocol/kex.js b/lib/protocol/kex.js index 811e631b..65bd9539 100644 --- a/lib/protocol/kex.js +++ b/lib/protocol/kex.js @@ -771,7 +771,16 @@ const createKeyExchange = (() => { true ); - packet[p] = MESSAGE.KEXDH_REPLY; + switch (this.type) { + case 'group': + packet[p] = MESSAGE.KEXDH_REPLY; + break; + case 'groupex': + packet[p] = MESSAGE.KEXDH_GEX_REPLY; + break; + default: + packet[p] = MESSAGE.KEXECDH_REPLY; + } writeUInt32BE(packet, serverPublicHostKey.length, ++p); packet.set(serverPublicHostKey, p += 4); @@ -1406,7 +1415,7 @@ const createKeyExchange = (() => { this._public = this._dh.generateKeys(); } } - setDHParams(prime, generator) { + setDHParams(prime, generator = Buffer.from([0x02])) { if (!Buffer.isBuffer(prime)) throw new Error('Invalid prime value'); if (!Buffer.isBuffer(generator)) @@ -1427,6 +1436,8 @@ const createKeyExchange = (() => { switch (this._step) { case 1: { if (this._protocol._server) { + + // Server if (type !== MESSAGE.KEXDH_GEX_REQUEST) { return doFatalError( this._protocol, @@ -1436,73 +1447,143 @@ const createKeyExchange = (() => { DISCONNECT_REASON.KEY_EXCHANGE_FAILED ); } - // TODO: allow user implementation to provide safe prime and - // generator on demand to support group exchange on server side - return doFatalError( - this._protocol, - 'Group exchange not implemented for server', - 'handshake', - DISCONNECT_REASON.KEY_EXCHANGE_FAILED + + this._protocol._debug && this._protocol._debug( + 'Received DH GEX Request' ); - } - if (type !== MESSAGE.KEXDH_GEX_GROUP) { - return doFatalError( - this._protocol, - `Received packet ${type} instead of ${MESSAGE.KEXDH_GEX_GROUP}`, - 'handshake', - DISCONNECT_REASON.KEY_EXCHANGE_FAILED + /* + byte SSH_MSG_KEY_DH_GEX_REQUEST + uint32 min, minimal size in bits of an acceptable group + uint32 n, preferred size in bits of the group the server + will send + uint32 max, maximal size in bits of an acceptable group + */ + bufferParser.init(payload, 1); + let minBits; + let prefBits; + let maxBits; + if ((minBits = bufferParser.readUInt32BE()) === undefined + || (prefBits = bufferParser.readUInt32BE()) === undefined + || (maxBits = bufferParser.readUInt32BE()) === undefined) { + bufferParser.clear(); + return doFatalError( + this._protocol, + 'Received malformed KEXDH_GEX_REQUEST', + 'handshake', + DISCONNECT_REASON.KEY_EXCHANGE_FAILED + ); + } + bufferParser.clear(); + + // Async callback: (err, prime, generator) + this._protocol._getDHParams( + minBits, + prefBits, + maxBits, + (err, prime, generator) => { + if (err) { + return doFatalError( + this._protocol, + err.message || 'No matching prime for KEXDH_GEX_REQUEST', + 'handshake', + DISCONNECT_REASON.KEY_EXCHANGE_FAILED + ); + } + + this._minBits = minBits; + this._prefBits = prefBits; + this._maxBits = maxBits; + + this.setDHParams(prime, generator); + this.generateKeys(); + const dh = this.getDHParams(); + + this._protocol._debug && this._protocol._debug( + 'Outbound: Sending KEXDH_GEX_GROUP' + ); + + let p = this._protocol._packetRW.write.allocStartKEX; + const packet = + this._protocol._packetRW.write.alloc( + 1 + 4 + dh.prime.length + 4 + dh.generator.length, true); + packet[p] = MESSAGE.KEXDH_GEX_GROUP; + writeUInt32BE(packet, dh.prime.length, ++p); + packet.set(dh.prime, p += 4); + writeUInt32BE(packet, dh.generator.length, + p += dh.prime.length); + packet.set(dh.generator, p += 4); + this._protocol._cipher.encrypt( + this._protocol._packetRW.write.finalize(packet, true) + ); + + ++this._step; + } ); - } + return; - this._protocol._debug && this._protocol._debug( - 'Received DH GEX Group' - ); + } else { - /* - byte SSH_MSG_KEX_DH_GEX_GROUP - mpint p, safe prime - mpint g, generator for subgroup in GF(p) - */ - bufferParser.init(payload, 1); - let prime; - let gen; - if ((prime = bufferParser.readString()) === undefined - || (gen = bufferParser.readString()) === undefined) { - bufferParser.clear(); - return doFatalError( - this._protocol, - 'Received malformed KEXDH_GEX_GROUP', - 'handshake', - DISCONNECT_REASON.KEY_EXCHANGE_FAILED + // Client + if (type !== MESSAGE.KEXDH_GEX_GROUP) { + return doFatalError( + this._protocol, + `Received packet ${type} instead of ${MESSAGE.KEXDH_GEX_GROUP}`, + 'handshake', + DISCONNECT_REASON.KEY_EXCHANGE_FAILED + ); + } + + this._protocol._debug && this._protocol._debug( + 'Received DH GEX Group' ); - } - bufferParser.clear(); - // TODO: validate prime - this.setDHParams(prime, gen); - this.generateKeys(); - const pubkey = this.getPublicKey(); + /* + byte SSH_MSG_KEX_DH_GEX_GROUP + mpint p, safe prime + mpint g, generator for subgroup in GF(p) + */ + bufferParser.init(payload, 1); + let prime; + let gen; + if ((prime = bufferParser.readString()) === undefined + || (gen = bufferParser.readString()) === undefined) { + bufferParser.clear(); + return doFatalError( + this._protocol, + 'Received malformed KEXDH_GEX_GROUP', + 'handshake', + DISCONNECT_REASON.KEY_EXCHANGE_FAILED + ); + } + bufferParser.clear(); - this._protocol._debug && this._protocol._debug( - 'Outbound: Sending KEXDH_GEX_INIT' - ); + // TODO: validate prime + this.setDHParams(prime, gen); + this.generateKeys(); + const pubkey = this.getPublicKey(); - let p = this._protocol._packetRW.write.allocStartKEX; - const packet = - this._protocol._packetRW.write.alloc(1 + 4 + pubkey.length, true); - packet[p] = MESSAGE.KEXDH_GEX_INIT; - writeUInt32BE(packet, pubkey.length, ++p); - packet.set(pubkey, p += 4); - this._protocol._cipher.encrypt( - this._protocol._packetRW.write.finalize(packet, true) - ); + this._protocol._debug && this._protocol._debug( + 'Outbound: Sending KEXDH_GEX_INIT' + ); + let p = this._protocol._packetRW.write.allocStartKEX; + const packet = + this._protocol._packetRW.write.alloc(1 + 4 + pubkey.length, true); + packet[p] = MESSAGE.KEXDH_GEX_INIT; + writeUInt32BE(packet, pubkey.length, ++p); + packet.set(pubkey, p += 4); + this._protocol._cipher.encrypt( + this._protocol._packetRW.write.finalize(packet, true) + ); + } ++this._step; break; } case 2: if (this._protocol._server) { + + // Server if (type !== MESSAGE.KEXDH_GEX_INIT) { return doFatalError( this._protocol, @@ -1511,30 +1592,92 @@ const createKeyExchange = (() => { DISCONNECT_REASON.KEY_EXCHANGE_FAILED ); } + this._protocol._debug && this._protocol._debug( 'Received DH GEX Init' ); - return doFatalError( - this._protocol, - 'Group exchange not implemented for server', - 'handshake', - DISCONNECT_REASON.KEY_EXCHANGE_FAILED + + /* + byte SSH_MSG_KEX_DH_GEX_INIT + mpint e + */ + bufferParser.init(payload, 1); + let dhData; + if ((dhData = bufferParser.readString()) === undefined) { + bufferParser.clear(); + return doFatalError( + this._protocol, + 'Received malformed KEXDH_GEX_INIT', + 'handshake', + DISCONNECT_REASON.KEY_EXCHANGE_FAILED + ); + } + bufferParser.clear(); + + this._dhData = dhData; + + let hostKey = + this._protocol._hostKeys[this.negotiated.serverHostKey]; + if (Array.isArray(hostKey)) + hostKey = hostKey[0]; + this._hostKey = hostKey; + + this.finish(); + + } else { + + // Client + if (type !== MESSAGE.KEXDH_GEX_REPLY) { + return doFatalError( + this._protocol, + `Received packet ${type} instead of ${MESSAGE.KEXDH_GEX_REPLY}`, + 'handshake', + DISCONNECT_REASON.KEY_EXCHANGE_FAILED + ); + } + + this._protocol._debug && this._protocol._debug( + 'Received DH GEX Reply' ); - } else if (type !== MESSAGE.KEXDH_GEX_REPLY) { + this._step = 1; + payload[0] = MESSAGE.KEXDH_REPLY; + this.parse = KeyExchange.prototype.parse; + return this.parse(payload); + } + + ++this._step; + break; + + case 3: + + if (type !== MESSAGE.NEWKEYS) { return doFatalError( this._protocol, - `Received packet ${type} instead of ${MESSAGE.KEXDH_GEX_REPLY}`, + `Received packet ${type} instead of ${MESSAGE.NEWKEYS}`, 'handshake', DISCONNECT_REASON.KEY_EXCHANGE_FAILED ); } this._protocol._debug && this._protocol._debug( - 'Received DH GEX Reply' + 'Inbound: NEWKEYS' + ); + this._receivedNEWKEYS = true; + if (this._protocol._strictMode) + this._protocol._decipher.inSeqno = 0; + ++this._step; + if (this._protocol._server || this._hostVerified) + return this.finish(); + + // Signal to current decipher that we need to change to a new decipher + // for the next packet + return false; + default: + return doFatalError( + this._protocol, + `Received unexpected packet ${type} after NEWKEYS`, + 'handshake', + DISCONNECT_REASON.KEY_EXCHANGE_FAILED ); - this._step = 1; - payload[0] = MESSAGE.KEXDH_REPLY; - this.parse = KeyExchange.prototype.parse; - this.parse(payload); } } } diff --git a/lib/server.js b/lib/server.js index 306d6584..f13f5949 100644 --- a/lib/server.js +++ b/lib/server.js @@ -483,6 +483,7 @@ class Client extends EventEmitter { onPacket, greeting: srvCfg.greeting, banner: srvCfg.banner, + getDHParams: srvCfg.getDHParams, onWrite: (data) => { if (isWritable(socket)) socket.write(data); diff --git a/test/test-dhgex.js b/test/test-dhgex.js new file mode 100644 index 00000000..b818bb55 --- /dev/null +++ b/test/test-dhgex.js @@ -0,0 +1,197 @@ +'use strict'; + +const assert = require('assert'); + +const { + fixture, + mustCall, + setup: setup_, +} = require('./common.js'); + +const clientCfg = { username: 'foo', password: 'bar' }; +const serverCfg = { hostKeys: [fixture('ssh_host_rsa_key')] }; + +const debug = false; + +// Use a known safe prime for testing (RFC 3526 2048-bit MODP Group) +const MODP_2048_PRIME = Buffer.from( + 'FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1' + + '29024E088A67CC74020BBEA63B139B22514A08798E3404DD' + + 'EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245' + + 'E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED' + + 'EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D' + + 'C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F' + + '83655D23DCA3AD961C62F356208552BB9ED529077096966D' + + '670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B' + + 'E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9' + + 'DE2BCBF6955817183995497CEA956AE515D2261898FA0510' + + '15728E5A8AACAA68FFFFFFFFFFFFFFFF', + 'hex' +); +const MODP_2048_GENERATOR = Buffer.from([0x02]); + +{ + // Test: DH-GEX with async callback API + const { server } = setup_( + 'DH-GEX with async callback getDHParams', + { + client: { + ...clientCfg, + algorithms: { + kex: ['diffie-hellman-group-exchange-sha256'], + }, + }, + server: { + ...serverCfg, + algorithms: { + kex: ['diffie-hellman-group-exchange-sha256'], + }, + getDHParams: mustCall((minBits, prefBits, maxBits, callback) => { + assert(typeof minBits === 'number', 'minBits should be a number'); + assert(typeof prefBits === 'number', 'prefBits should be a number'); + assert(typeof maxBits === 'number', 'maxBits should be a number'); + assert(typeof callback === 'function', 'callback should be a function'); + assert(minBits <= prefBits, 'minBits should be <= prefBits'); + assert(prefBits <= maxBits, 'prefBits should be <= maxBits'); + + // Simulate async operation + setImmediate(() => { + callback(null, MODP_2048_PRIME, MODP_2048_GENERATOR); + }); + }), + }, + debug, + } + ); + + server.on('connection', mustCall((conn) => { + conn.on('authentication', mustCall((ctx) => { + ctx.accept(); + })).on('ready', mustCall(() => { + conn.end(); + })); + })); +} + +{ + // Test: DH-GEX with sync-style callback (immediate callback) + const { server } = setup_( + 'DH-GEX with sync-style callback getDHParams', + { + client: { + ...clientCfg, + algorithms: { + kex: ['diffie-hellman-group-exchange-sha256'], + }, + }, + server: { + ...serverCfg, + algorithms: { + kex: ['diffie-hellman-group-exchange-sha256'], + }, + getDHParams: mustCall((minBits, prefBits, maxBits, callback) => { + // Call callback synchronously (sync-style usage) + callback(null, MODP_2048_PRIME, MODP_2048_GENERATOR); + }), + }, + debug, + } + ); + + server.on('connection', mustCall((conn) => { + conn.on('authentication', mustCall((ctx) => { + ctx.accept(); + })).on('ready', mustCall(() => { + conn.end(); + })); + })); +} + +{ + // Test: DH-GEX SHA-256 with command execution after handshake + const { server, client } = setup_( + 'DH-GEX SHA-256 key exchange with exec', + { + client: { + ...clientCfg, + algorithms: { + kex: ['diffie-hellman-group-exchange-sha256'], + }, + }, + server: { + ...serverCfg, + algorithms: { + kex: ['diffie-hellman-group-exchange-sha256'], + }, + getDHParams: mustCall((minBits, prefBits, maxBits, callback) => { + callback(null, MODP_2048_PRIME, MODP_2048_GENERATOR); + }), + }, + debug, + } + ); + + server.on('connection', mustCall((conn) => { + conn.on('authentication', mustCall((ctx) => { + ctx.accept(); + })).on('ready', mustCall(() => { + conn.on('session', mustCall((accept, reject) => { + const session = accept(); + session.on('exec', mustCall((accept, reject, info) => { + assert.strictEqual(info.command, 'echo test'); + const stream = accept(); + stream.write('test\n'); + stream.exit(0); + stream.end(); + })); + })); + })); + })); + + client.on('ready', mustCall(() => { + client.exec('echo test', mustCall((err, stream) => { + assert.ifError(err); + let output = ''; + stream.on('data', (data) => { + output += data; + }); + stream.on('close', mustCall(() => { + assert.strictEqual(output, 'test\n'); + client.end(); + })); + })); + })); +} + +{ + // Test: DH-GEX SHA-1 variant + const { server } = setup_( + 'DH-GEX SHA-1 key exchange', + { + client: { + ...clientCfg, + algorithms: { + kex: ['diffie-hellman-group-exchange-sha1'], + }, + }, + server: { + ...serverCfg, + algorithms: { + kex: ['diffie-hellman-group-exchange-sha1'], + }, + getDHParams: mustCall((minBits, prefBits, maxBits, callback) => { + callback(null, MODP_2048_PRIME, MODP_2048_GENERATOR); + }), + }, + debug, + } + ); + + server.on('connection', mustCall((conn) => { + conn.on('authentication', mustCall((ctx) => { + ctx.accept(); + })).on('ready', mustCall(() => { + conn.end(); + })); + })); +}