Skip to content

Client: Allow explicitely specifying a publicKey #808

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

TimWolla
Copy link

@TimWolla TimWolla commented Jul 21, 2019

(see mscdex/ssh2-streams#137)

This is to support SSH certificates. As before the privateKey will
be used for the publicKey (i.e. the derived publicKey) if nothing
is given.

The given publicKey is checked to match the given privateKey.

Closes #551


Example:

const ssh = require('ssh2');
const fs = require('fs');

const conn = new ssh.Client();
conn
.on('ready', function() {
	console.log('Client :: ready');
	conn.exec('ls /', function(err, stream) {
		if (err) throw err;
		stream.on('close', function(code, signal) {
			console.log('Stream :: close :: code: ' + code + ', signal: ' + signal);
			conn.end();
		}).on('data', function(data) {
			console.log('STDOUT: ' + data);
		}).stderr.on('data', function(data) {
			console.log('STDERR: ' + data);
		});
		stream.write('foo');
	});
}).connect({
	// debug: console.log.bind(console),
	username: 'root',
	host: 'xxx',
	privateKey: fs.readFileSync('node'),
	publicKey: fs.readFileSync('node-cert.pub')
})
$ ssh-keygen -s ca -I test_cert -V +60m -O "force-command=env" -O no-agent-forwarding -O no-port-forwarding -O no-pty -O no-user-rc -O no-x11-forwarding node.pub
Signed user key node-cert.pub: id "test_cert" serial 0 valid from 2019-07-21T17:10:00 to 2019-07-21T18:11:34
$ docker run -it --rm -v (pwd):(pwd) -v (pwd):/pwd --workdir /pwd timwolla/node index.js
Client :: ready
STDOUT: SHELL=/bin/bash
PWD=/root
LOGNAME=root
XDG_SESSION_TYPE=tty
HOME=/root
LANG=en_US.UTF-8
SSH_ORIGINAL_COMMAND=ls /
SSH_CONNECTION=xxx 58216 xxx 22
XDG_SESSION_CLASS=user
USER=root
SHLVL=0
XDG_SESSION_ID=36
XDG_RUNTIME_DIR=/run/user/0
SSH_CLIENT=xxx 58216 22
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAIL=/var/mail/root
_=/usr/bin/env

Stream :: close :: code: 0, signal: undefined

This is to support SSH certificates. As before the privateKey will
be used for the publicKey (i.e. the derived publicKey) if nothing
is given.

The given publicKey is checked to match the given privateKey.
@SaviourSelf
Copy link

Can this get merged in?

@jisack
Copy link

jisack commented Mar 5, 2020

confirm this is worked for me with Vault module Signed SSH Certificates

@aadityabhatia
Copy link

Tested this out with the latest mscdex/ssh2:master and two sets of user certificates ([email protected] and [email protected]). Upon calling conn.connect, I receive the following error:

publicKey does not belong to the private key

Removed the check publicKey.getPublicPEM() !== privateKey.getPublicPEM() on lib/client.js:262 and was still unable to connect:

Error: All configured authentication methods failed

Upon closer examination, publicKey.getPublicPEM() output for an ecdsa certificate looks off (31 bytes and ends in nistp521), so I will look at mscdex/ssh2-streams#137 and run additional tests. Please let me know if I got something wrong in the process.

@aadityabhatia
Copy link

I will look at mscdex/ssh2-streams#137 and run additional tests

I did not realize that mscdex/ssh2-streams#137 was still open. Merged those changes into my local workspace and everything works perfectly. Please disregard the last comment.

@travelton
Copy link

Ping: Any chance we can get this merged soon?

@mscdex
Copy link
Owner

mscdex commented May 29, 2021

I haven't forgotten about this feature, however the PR will need to be updated to match the internal changes in v1.0.0.

@TimWolla
Copy link
Author

TimWolla commented Jul 18, 2021

I haven't forgotten about this feature, however the PR will need to be updated to match the internal changes in v1.0.0.

Whoops. Appears I missed the email notification about this comment.

@mscdex Will you handle this, please? You know the internals better than I do and it's almost 2 years since I last touched it myself.

@mast
Copy link

mast commented Aug 29, 2022

Is there any chance this to be merged soon? I'm considering using this module, but it's lacking authentication with signed public key.

@david-autonoma
Copy link

Hi all,

We also need this feature urgently.
@mscdex or @TimWolla: could one of you implement that within the following days? We would be open to fund it with 200€ if handled timely.

Email me via [email protected] if you are interested. Thanks!

@TimWolla
Copy link
Author

@david-autonoma This PR is way outdated and would likely need to be adapted to the 1.0 release of the library. This PR was developed on company time, where we currently don't have a need for this feature (as we've worked around it internally). I'm afraid the costs of relearning how the current version of this library works after 4 years alone would likely exceed the offered compensation.

@david-autonoma
Copy link

Hi @TimWolla, we understand the difficulty. Could you maybe describe how you worked around it internally?

Though, we also have heard, that @mscdex might have a solution for it. We would be very glad if we could have a quick chat or feedback via email. Thank you.

@TimWolla
Copy link
Author

Could you maybe describe how you worked around it internally?

I don't remember the details after 4 years. I believe we used a regular public / private key pair, because while using certificates would have been nicer, the cost of maintaining a fork of this library would not have been worth it.

@psychon
Copy link

psychon commented Feb 11, 2025

Disclaimer: I have no idea what I am doing. I just hit this stuff until it started working.

I would like to use OpenSSH certificates. Thus I looked at the state of this PR and mscdex/ssh2-streams#137 and tried to get something to work on latest master (commit dd5510c). For testing, I used the "uptime" example from the readme (and added publicKey: readFileSync('/tmp/user-key-cert.pub') to it at the right place). After a bit of back and forth, the result successfully queried the uptime from my SSH server.

Hopefully this motivates someone with more knowledge to work again on this and make it work properly. It seems to me that someone with some knowledge about the SSH protocol should be able to get this to work relatively easily.

The resulting patch
diff --git a/lib/client.js b/lib/client.js
index aa94ace..e0678f3 100644
--- a/lib/client.js
+++ b/lib/client.js
@@ -84,6 +84,7 @@ class Client extends EventEmitter {
       username: undefined,
       password: undefined,
       privateKey: undefined,
+      publicKey: undefined,
       tryKeyboard: undefined,
       agent: undefined,
       allowAgentFwd: undefined,
@@ -209,6 +210,10 @@ class Client extends EventEmitter {
                               || Buffer.isBuffer(cfg.privateKey)
                               ? cfg.privateKey
                               : undefined);
+    this.config.publicKey = (typeof cfg.publicKey === 'string'
+                             || Buffer.isBuffer(cfg.publicKey)
+                             ? cfg.publicKey
+                             : undefined);
     this.config.localHostname = (typeof cfg.localHostname === 'string'
                                  ? cfg.localHostname
                                  : undefined);
@@ -253,7 +258,7 @@ class Client extends EventEmitter {
     this._agentFwdEnabled = false;
     this._agent = (this.config.agent ? this.config.agent : undefined);
     this._remoteVer = undefined;
-    let privateKey;
+    let privateKey, publicKey;
 
     if (this.config.privateKey) {
       privateKey = parseKey(this.config.privateKey, cfg.passphrase);
@@ -270,6 +275,22 @@ class Client extends EventEmitter {
       }
     }
 
+    if (this.config.publicKey) {
+      publicKey = parseKey(this.config.publicKey);
+      if (publicKey instanceof Error)
+        throw new Error('Cannot parse publicKey: ' + publicKey.message);
+      if (Array.isArray(publicKey))
+        publicKey = publicKey[0]; // OpenSSH's newer format only stores 1 key for now
+      if (publicKey.getPublicSSH() === null)
+        throw new Error('publicKey value does not contain a (valid) public key');
+      if (publicKey.getPublicPEM() !== privateKey.getPublicPEM()) {
+        throw new Error('publicKey does not belong to the private key');
+      }
+    }
+    else {
+      publicKey = privateKey;
+    }
+
     let hostVerifier;
     if (typeof cfg.hostVerifier === 'function') {
       const hashCb = cfg.hostVerifier;
@@ -462,7 +483,7 @@ class Client extends EventEmitter {
             });
           } else if (curAuth.type === 'publickey') {
             proto.authPK(curAuth.username, curAuth.key, keyAlgo, (buf, cb) => {
-              const signature = curAuth.key.sign(buf, hashAlgo);
+              const signature = privateKey.sign(buf, hashAlgo);
               if (signature instanceof Error) {
                 signature.message =
                   `Error signing data with key: ${signature.message}`;
@@ -836,7 +857,7 @@ class Client extends EventEmitter {
     const authsAllowed = ['none'];
     if (this.config.password !== undefined)
       authsAllowed.push('password');
-    if (privateKey !== undefined)
+    if (privateKey !== undefined && publicKey !== undefined)
       authsAllowed.push('publickey');
     if (this._agent !== undefined)
       authsAllowed.push('agent');
@@ -882,13 +903,13 @@ class Client extends EventEmitter {
             nextAuth = { type, username, password: this.config.password };
             break;
           case 'publickey':
-            nextAuth = { type, username, key: privateKey };
+            nextAuth = { type, username, key: publicKey };
             break;
           case 'hostbased':
             nextAuth = {
               type,
               username,
-              key: privateKey,
+              key: publicKey,
               localHostname: this.config.localHostname,
               localUsername: this.config.localUsername,
             };
diff --git a/lib/protocol/Protocol.js b/lib/protocol/Protocol.js
index 7302488..654cceb 100644
--- a/lib/protocol/Protocol.js
+++ b/lib/protocol/Protocol.js
@@ -697,15 +697,18 @@ class Protocol {
     }
 
     cbSign(packet, (signature) => {
-      signature = convertSignature(signature, keyType);
-      if (signature === false)
+      var converted = convertSignature(signature, keyType);
+      if (converted === false)
         throw new Error('Error while converting handshake signature');
+    signature = converted.signature;
+    var signatureType = converted.keyType;
+    var signatureTypeLen = Buffer.byteLength(signatureType);
 
       const sigLen = signature.length;
       p = this._packetRW.write.allocStart;
       packet = this._packetRW.write.alloc(
         1 + 4 + userLen + 4 + 14 + 4 + 9 + 1 + 4 + algoLen + 4 + pubKeyLen + 4
-          + 4 + algoLen + 4 + sigLen
+          + 4 + signatureTypeLen + 4 + sigLen
       );
 
       // TODO: simply copy from original "packet" to new `packet` to avoid
@@ -729,12 +732,12 @@ class Protocol {
       writeUInt32BE(packet, pubKeyLen, p += algoLen);
       packet.set(pubKey, p += 4);
 
-      writeUInt32BE(packet, 4 + algoLen + 4 + sigLen, p += pubKeyLen);
+      writeUInt32BE(packet, 4 + signatureTypeLen + 4 + sigLen, p += pubKeyLen);
 
-      writeUInt32BE(packet, algoLen, p += 4);
-      packet.utf8Write(keyAlgo, p += 4, algoLen);
+      writeUInt32BE(packet, signatureTypeLen, p += 4);
+      packet.utf8Write(signatureType, p += 4, signatureTypeLen);
 
-      writeUInt32BE(packet, sigLen, p += algoLen);
+      writeUInt32BE(packet, sigLen, p += signatureTypeLen);
       packet.set(signature, p += 4);
 
       // Servers shouldn't send packet type 60 in response to signed publickey
@@ -806,9 +809,10 @@ class Protocol {
     data.utf8Write(userlocal, p += 4, userlocalLen);
 
     cbSign(data, (signature) => {
-      signature = convertSignature(signature, keyType);
-      if (!signature)
+      var converted = convertSignature(signature, keyType);
+      if (!converted)
         throw new Error('Error while converting handshake signature');
+      signature = converted.signature;
 
       const sigLen = signature.length;
       const reqDataLen = (data.length - sesLen - 4);
diff --git a/lib/protocol/keyParser.js b/lib/protocol/keyParser.js
index a276c1a..085ff99 100644
--- a/lib/protocol/keyParser.js
+++ b/lib/protocol/keyParser.js
@@ -1210,6 +1210,12 @@ OpenSSH_Public.prototype = BaseKey;
     if (type === undefined || type.indexOf(baseType) !== 0)
       return new Error('Malformed OpenSSH public key');
 
+   if (/-cert-v0[01]@openssh.com/.test(type)) {
+      var nonce = readString(data, data._pos);
+      if (nonce === false) {
+        return new Error('Malformed OpenSSH certificate');
+      }
+    }
     return parseDER(data, baseType, comment, fullType);
   };
 }
@@ -1327,7 +1333,7 @@ function parseDER(data, baseType, comment, fullType) {
       if (n === undefined)
         return new Error('Malformed OpenSSH public key');
       pubPEM = genOpenSSLRSAPub(n, e);
-      pubSSH = genOpenSSHRSAPub(n, e);
+      pubSSH = data;
       algo = 'sha1';
       break;
     }
@@ -1345,7 +1351,7 @@ function parseDER(data, baseType, comment, fullType) {
       if (y === undefined)
         return new Error('Malformed OpenSSH public key');
       pubPEM = genOpenSSLDSAPub(p, q, g, y);
-      pubSSH = genOpenSSHDSAPub(p, q, g, y);
+      pubSSH = data;
       algo = 'sha1';
       break;
     }
@@ -1354,7 +1360,7 @@ function parseDER(data, baseType, comment, fullType) {
       if (edpub === undefined || edpub.length !== 32)
         return new Error('Malformed OpenSSH public key');
       pubPEM = genOpenSSLEdPub(edpub);
-      pubSSH = genOpenSSHEdPub(edpub);
+      pubSSH = data;
       algo = null;
       break;
     }
@@ -1380,7 +1386,7 @@ function parseDER(data, baseType, comment, fullType) {
       if (ecpub === undefined)
         return new Error('Malformed OpenSSH public key');
       pubPEM = genOpenSSLECDSAPub(oid, ecpub);
-      pubSSH = genOpenSSHECDSAPub(oid, ecpub);
+      pubSSH = data;
       break;
     }
     default:
diff --git a/lib/protocol/utils.js b/lib/protocol/utils.js
index 26f4cab..e7e0928 100644
--- a/lib/protocol/utils.js
+++ b/lib/protocol/utils.js
@@ -282,10 +282,20 @@ module.exports = {
     }
   },
   convertSignature: (signature, keyType) => {
+  switch (keyType) {
+    case '[email protected]':
+    case '[email protected]':
+    case 'ecdsa-sha2-nistp256':
+    case '[email protected]':
+    case '[email protected]':
+    case '[email protected]':
+      keyType = keyType.replace(/[email protected]$/, '');
+  }
     switch (keyType) {
       case 'ssh-dss': {
         if (signature.length <= 40)
           return signature;
+          else {
         // This is a quick and dirty way to get from BER encoded r and s that
         // OpenSSL gives us, to just the bare values back to back (40 bytes
         // total) like OpenSSH (and possibly others) are expecting
@@ -315,13 +325,15 @@ module.exports = {
           Buffer.allocUnsafe((r.length - rOffset) + (s.length - sOffset));
         bufferCopy(r, newSig, rOffset, r.length, 0);
         bufferCopy(s, newSig, sOffset, s.length, r.length - rOffset);
-        return newSig;
+          }
+          signature = newSig;
+          break;
       }
       case 'ecdsa-sha2-nistp256':
       case 'ecdsa-sha2-nistp384':
       case 'ecdsa-sha2-nistp521': {
-        if (signature[0] === 0)
-          return signature;
+        if (signature[0] === 0) {
+        } else {
         // Convert SSH signature parameters to ASN.1 BER values for OpenSSL
         const asnReader = new Ber.Reader(signature);
         asnReader.readSequence();
@@ -334,11 +346,16 @@ module.exports = {
         newSig.set(r, 4);
         writeUInt32BE(newSig, s.length, 4 + r.length);
         newSig.set(s, 4 + 4 + r.length);
-        return newSig;
+            signature = newSig;
+        }
       }
     }
+       if (signature === false) { return false; }
 
-    return signature;
+ return {
+    signature: signature,
+    keyType: keyType
+  }
   },
   sendPacket: (proto, packet, bypass) => {
     if (!bypass && proto._kexinit !== undefined) {
(#1429 also seems related, but I did not look at it)

@BlackHole1
Copy link

BlackHole1 commented Apr 16, 2025

Hi All. I took some time to address this issue in the current version without needing a patch.

class CustomAgent extends ssh2.OpenSSHAgent {
    constructor(private readonly publicKey, sockerPath: string) {
        super(sockerPath);
    }

    override getIdentities(cb) {
        super.getIdentities((err, keys) => {
            if (this.publicKey) {
                const key = keys?.find((key) => {
                    if ("equals" in key) {
                        return key.equals(this.publicKey);
                    }
                    return false;
                });

                cb(err, key ? [key] : []);
                return;
            }

            cb(err, keys);
        });
    }
}

const publicKey = "ssh-ed25519 AAAAC3NzaC2lZDI1NTE5AAAAIORt2rxtrPfh7ea8NQy3ABMZPwLkePmmh8enxOKizmUD";

const conn = new Client();
conn.connect({
    host,
    port,
    username,
    agent: new CustomAgent(publicKey, process.env.SSH_AUTH_SOCK);
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Feature Request: support certificate authentication.