From 25d990c5398f29a596e4a30fe2e2a4180c1ab783 Mon Sep 17 00:00:00 2001 From: Kohsuke Kawaguchi Date: Thu, 24 May 2012 15:49:37 -0700 Subject: [PATCH] Implemented transport security to CLI connection. It uses Diffie Hellman to come up with one-time session key, then have the server sign this session key to allow the client to verify that there's no man in the middle. --- changelog.html | 2 + cli/src/main/java/hudson/cli/CLI.java | 75 +++++++++++++++---- cli/src/main/java/hudson/cli/CliPort.java | 44 +++++++++++ cli/src/main/java/hudson/cli/Connection.java | 57 +++++++++++++- .../test/java/hudson/cli/ConnectionTest.java | 70 +++++++++++++++++ .../java/hudson/TcpSlaveAgentListener.java | 68 ++++++++++++++++- .../main/resources/lib/layout/layout.jelly | 1 + 7 files changed, 295 insertions(+), 22 deletions(-) create mode 100644 cli/src/main/java/hudson/cli/CliPort.java create mode 100644 cli/src/test/java/hudson/cli/ConnectionTest.java diff --git a/changelog.html b/changelog.html index ef152ab5f2..1e3d7df18c 100644 --- a/changelog.html +++ b/changelog.html @@ -72,6 +72,8 @@ Upcoming changes
  • Allow file parameters to be viewed as plain text. (issue 13640) +
  • + CLI connection to the master is now encrypted.
  • Improve the low disk space warning message. (issue 13826) diff --git a/cli/src/main/java/hudson/cli/CLI.java b/cli/src/main/java/hudson/cli/CLI.java index 697a607390..f6242c7ceb 100644 --- a/cli/src/main/java/hudson/cli/CLI.java +++ b/cli/src/main/java/hudson/cli/CLI.java @@ -33,6 +33,9 @@ import hudson.remoting.RemoteOutputStream; import hudson.remoting.SocketInputStream; import hudson.remoting.SocketOutputStream; +import javax.crypto.KeyAgreement; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.BufferedReader; @@ -57,8 +60,10 @@ import java.security.GeneralSecurityException; import java.security.KeyFactory; import java.security.KeyPair; import java.security.PublicKey; +import java.security.Signature; import java.security.spec.DSAPrivateKeySpec; import java.security.spec.DSAPublicKeySpec; +import java.security.spec.X509EncodedKeySpec; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -120,7 +125,7 @@ public class CLI { pool = exec!=null ? exec : Executors.newCachedThreadPool(); Channel channel = null; - InetSocketAddress clip = getCliTcpPort(url); + CliPort clip = getCliTcpPort(url); if(clip!=null) { // connect via CLI port try { @@ -161,8 +166,8 @@ public class CLI { return ch; } - private Channel connectViaCliPort(URL jenkins, InetSocketAddress endpoint) throws IOException { - LOGGER.fine("Trying to connect directly via TCP/IP to "+endpoint); + private Channel connectViaCliPort(URL jenkins, CliPort clip) throws IOException { + LOGGER.fine("Trying to connect directly via TCP/IP to "+clip.endpoint); final Socket s; OutputStream out; @@ -170,7 +175,7 @@ public class CLI { String[] tokens = httpsProxyTunnel.split(":"); s = new Socket(tokens[0], Integer.parseInt(tokens[1])); PrintStream o = new PrintStream(s.getOutputStream()); - o.print("CONNECT " + endpoint.getHostName() + ":" + endpoint.getPort() + " HTTP/1.0\r\n\r\n"); + o.print("CONNECT " + clip.endpoint.getHostName() + ":" + clip.endpoint.getPort() + " HTTP/1.0\r\n\r\n"); // read the response from the proxy ByteArrayOutputStream rsp = new ByteArrayOutputStream(); @@ -194,7 +199,7 @@ public class CLI { }; } else { s = new Socket(); - s.connect(endpoint,3000); + s.connect(clip.endpoint,3000); out = new SocketOutputStream(s); } @@ -204,18 +209,51 @@ public class CLI { } }); - DataOutputStream dos = new DataOutputStream(s.getOutputStream()); - dos.writeUTF("Protocol:CLI-connect"); + Connection c = new Connection(new SocketInputStream(s),out); + + switch (clip.version) { + case 1: + DataOutputStream dos = new DataOutputStream(s.getOutputStream()); + dos.writeUTF("Protocol:CLI-connect"); + // we aren't checking greeting from the server here because I'm too lazy. It gets ignored by Channel constructor. + break; + case 2: + DataInputStream dis = new DataInputStream(s.getInputStream()); + dos = new DataOutputStream(s.getOutputStream()); + dos.writeUTF("Protocol:CLI2-connect"); + String greeting = dis.readUTF(); + if (!greeting.equals("Welcome")) + throw new IOException("Handshaking failed: "+greeting); + try { + byte[] secret = c.diffieHellman(false).generateSecret(); + SecretKey sessionKey = new SecretKeySpec(Connection.fold(secret,128/8),"AES"); + c = c.encryptConnection(sessionKey,"AES/CFB8/NoPadding"); + + // validate the instance identity, so that we can be sure that we are talking to the same server + // and there's no one in the middle. + byte[] signature = c.readByteArray(); + + if (clip.identity!=null) { + Signature verifier = Signature.getInstance("SHA1withRSA"); + verifier.initVerify(clip.getIdentity()); + verifier.update(secret); + if (!verifier.verify(signature)) + throw new IOException("Server identity signature validation failed."); + } + + } catch (GeneralSecurityException e) { + throw (IOException)new IOException("Failed to negotiate transport security").initCause(e); + } + } return new Channel("CLI connection to "+jenkins, pool, - new BufferedInputStream(new SocketInputStream(s)), - new BufferedOutputStream(out)); + new BufferedInputStream(c.in), new BufferedOutputStream(c.out)); } /** * If the server advertises CLI endpoint, returns its location. */ - private InetSocketAddress getCliTcpPort(String url) throws IOException { + private CliPort getCliTcpPort(String url) throws IOException { URL _url = new URL(url); if (_url.getHost()==null || _url.getHost().length()==0) { throw new IOException("Invalid URL: "+url); @@ -226,15 +264,20 @@ public class CLI { } catch (IOException e) { throw (IOException)new IOException("Failed to connect to "+url).initCause(e); } - String p = head.getHeaderField("X-Jenkins-CLI-Port"); - if (p==null) p = head.getHeaderField("X-Hudson-CLI-Port"); // backward compatibility + String h = head.getHeaderField("X-Jenkins-CLI-Host"); if (h==null) h = head.getURL().getHost(); - + String p1 = head.getHeaderField("X-Jenkins-CLI-Port"); + if (p1==null) p1 = head.getHeaderField("X-Hudson-CLI-Port"); // backward compatibility + String p2 = head.getHeaderField("X-Jenkins-CLI2-Port"); + + String identity = head.getHeaderField("X-Instance-Identity"); + flushURLConnection(head); - if (p==null) return null; - - return new InetSocketAddress(h,Integer.parseInt(p)); + if (p1==null && p2==null) return null; + + if (p2!=null) return new CliPort(new InetSocketAddress(h,Integer.parseInt(p2)),identity,2); + else return new CliPort(new InetSocketAddress(h,Integer.parseInt(p1)),identity,1); } /** diff --git a/cli/src/main/java/hudson/cli/CliPort.java b/cli/src/main/java/hudson/cli/CliPort.java new file mode 100644 index 0000000000..1507704d9c --- /dev/null +++ b/cli/src/main/java/hudson/cli/CliPort.java @@ -0,0 +1,44 @@ +package hudson.cli; + +import org.apache.commons.codec.binary.Base64; + +import java.net.InetSocketAddress; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.X509EncodedKeySpec; + +/** + * @author Kohsuke Kawaguchi + */ +final class CliPort { + /** + * The TCP endpoint to talk to. + */ + final InetSocketAddress endpoint; + + /** + * CLI protocol version. 1 and 2 are currently defined. + */ + final int version; + + /** + * Server instance identity. Can be null. + */ + final String identity; + + CliPort(InetSocketAddress endpoint, String identity, int version) { + this.endpoint = endpoint; + this.identity = identity; + this.version = version; + } + + /** + * Gets the public part of the RSA key that represents the server identity. + */ + public PublicKey getIdentity() throws GeneralSecurityException { + if (identity==null) return null; + byte[] image = Base64.decodeBase64(identity); + return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(image)); + } +} diff --git a/cli/src/main/java/hudson/cli/Connection.java b/cli/src/main/java/hudson/cli/Connection.java index 543e6acb3e..232970869d 100644 --- a/cli/src/main/java/hudson/cli/Connection.java +++ b/cli/src/main/java/hudson/cli/Connection.java @@ -27,9 +27,15 @@ import hudson.remoting.SocketInputStream; import hudson.remoting.SocketOutputStream; import org.apache.commons.codec.binary.Base64; +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import javax.crypto.CipherOutputStream; import javax.crypto.KeyAgreement; +import javax.crypto.SecretKey; import javax.crypto.interfaces.DHPublicKey; import javax.crypto.spec.DHParameterSpec; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; @@ -44,7 +50,6 @@ import java.security.Key; import java.security.KeyFactory; import java.security.KeyPair; import java.security.KeyPairGenerator; -import java.security.NoSuchAlgorithmException; import java.security.PublicKey; import java.security.Signature; import java.security.interfaces.DSAPublicKey; @@ -113,10 +118,21 @@ public class Connection { } public X509EncodedKeySpec readKey() throws IOException { - byte[] otherHalf = Base64.decodeBase64(readUTF()); + byte[] otherHalf = Base64.decodeBase64(readUTF()); // for historical reasons, we don't use readByteArray() return new X509EncodedKeySpec(otherHalf); } + public void writeByteArray(byte[] data) throws IOException { + dout.writeInt(data.length); + dout.write(data); + } + + public byte[] readByteArray() throws IOException { + byte[] buf = new byte[din.readInt()]; + din.readFully(buf); + return buf; + } + /** * Performs a Diffie-Hellman key exchange and produce a common secret between two ends of the connection. * @@ -125,12 +141,15 @@ public class Connection { * each other. */ public KeyAgreement diffieHellman(boolean side) throws IOException, GeneralSecurityException { + return diffieHellman(side,512); + } + public KeyAgreement diffieHellman(boolean side, int keySize) throws IOException, GeneralSecurityException { KeyPair keyPair; PublicKey otherHalf; if (side) { AlgorithmParameterGenerator paramGen = AlgorithmParameterGenerator.getInstance("DH"); - paramGen.init(512); + paramGen.init(keySize); KeyPairGenerator dh = KeyPairGenerator.getInstance("DH"); dh.initialize(paramGen.generateParameters().getParameterSpec(DHParameterSpec.class)); @@ -157,6 +176,38 @@ public class Connection { return ka; } + /** + * Upgrades a connection with transport encryption by the specified symmetric cipher. + * + * @return + * A new {@link Connection} object that includes the transport encryption. + */ + public Connection encryptConnection(SecretKey sessionKey, String algorithm) throws IOException, GeneralSecurityException { + Cipher cout = Cipher.getInstance(algorithm); + cout.init(Cipher.ENCRYPT_MODE, sessionKey, new IvParameterSpec(sessionKey.getEncoded())); + CipherOutputStream o = new CipherOutputStream(out, cout); + + Cipher cin = Cipher.getInstance(algorithm); + cin.init(Cipher.DECRYPT_MODE, sessionKey, new IvParameterSpec(sessionKey.getEncoded())); + CipherInputStream i = new CipherInputStream(in, cin); + + return new Connection(i,o); + } + + /** + * Given a byte array that contains arbitrary number of bytes, digests or expands those bits into the specified + * number of bytes without loss of entropy. + * + * Cryptographic utility code. + */ + public static byte[] fold(byte[] bytes, int size) { + byte[] r = new byte[size]; + for (int i=Math.max(bytes.length,size)-1; i>=0; i-- ) { + r[i%r.length] ^= bytes[i%bytes.length]; + } + return r; + } + private String detectKeyAlgorithm(KeyPair kp) { return detectKeyAlgorithm(kp.getPublic()); } diff --git a/cli/src/test/java/hudson/cli/ConnectionTest.java b/cli/src/test/java/hudson/cli/ConnectionTest.java new file mode 100644 index 0000000000..e9cc086aff --- /dev/null +++ b/cli/src/test/java/hudson/cli/ConnectionTest.java @@ -0,0 +1,70 @@ +package hudson.cli; + +import hudson.remoting.FastPipedInputStream; +import hudson.remoting.FastPipedOutputStream; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; + +/** + * @author Kohsuke Kawaguchi + */ +public class ConnectionTest extends Assert { + Throwable e; + private Connection c1; + private Connection c2; + + @Before + public void setUp() throws IOException { + FastPipedInputStream i = new FastPipedInputStream(); + FastPipedInputStream j = new FastPipedInputStream(); + + c1 = new Connection(i,new FastPipedOutputStream(j)); + c2 = new Connection(j,new FastPipedOutputStream(i)); + } + + @Test + public void testEncyrpt() throws Throwable { + final SecretKey sessionKey = new SecretKeySpec(new byte[16],"AES"); + + Thread t1 = new Thread() { + @Override + public void run() { + try { + c1.encryptConnection(sessionKey,"AES/CFB8/NoPadding").writeUTF("Hello"); + } catch (Throwable x) { + e = x; + } + } + }; + t1.start(); + + Thread t2 = new Thread() { + @Override + public void run() { + try { + String data = c2.encryptConnection(sessionKey,"AES/CFB8/NoPadding").readUTF(); + assertEquals("Hello", data); + } catch (Throwable x) { + e = x; + } + } + }; + t2.start(); + + t1.join(3000); + t2.join(3000); + + if (t1.isAlive() || t2.isAlive()) { + t1.interrupt(); + t2.interrupt(); + throw new Error("thread is still alive"); + } + + if (e!=null) throw e; + } +} diff --git a/core/src/main/java/hudson/TcpSlaveAgentListener.java b/core/src/main/java/hudson/TcpSlaveAgentListener.java index ccdfefd96b..2f2f35d418 100644 --- a/core/src/main/java/hudson/TcpSlaveAgentListener.java +++ b/core/src/main/java/hudson/TcpSlaveAgentListener.java @@ -23,6 +23,8 @@ */ package hudson; +import hudson.cli.Connection; +import hudson.util.Secret; import jenkins.model.Jenkins; import hudson.model.Computer; import hudson.slaves.OfflineCause; @@ -37,17 +39,31 @@ import hudson.cli.CliManagerImpl; import hudson.cli.CliEntryPoint; import hudson.util.IOException2; +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import javax.crypto.CipherOutputStream; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; import java.io.ByteArrayInputStream; import java.io.DataInputStream; +import java.io.DataOutputStream; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.io.PrintWriter; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.net.ServerSocket; import java.net.Socket; import java.net.BindException; +import java.security.GeneralSecurityException; +import java.security.PrivateKey; +import java.security.PublicKey; import java.security.SecureRandom; +import java.security.Signature; import java.util.Map.Entry; import java.util.Properties; import java.util.concurrent.ExecutionException; @@ -173,7 +189,7 @@ public final class TcpSlaveAgentListener extends Thread { LOGGER.info("Accepted connection #"+id+" from "+s.getRemoteSocketAddress()); DataInputStream in = new DataInputStream(s.getInputStream()); - PrintWriter out = new PrintWriter(s.getOutputStream(),true); + PrintWriter out = new PrintWriter(s.getOutputStream(),true); // DEPRECATED: newer protocol shouldn't use PrintWriter but should use DataOutputStream String s = in.readUTF(); @@ -185,6 +201,8 @@ public final class TcpSlaveAgentListener extends Thread { runJnlp2Connect(in, out); } else if(protocol.equals("CLI-connect")) { runCliConnect(in, out); + } else if(protocol.equals("CLI2-connect")) { + runCliConnect2(); } else { error(out, "Unknown protocol:" + s); } @@ -213,10 +231,54 @@ public final class TcpSlaveAgentListener extends Thread { */ private void runCliConnect(DataInputStream in, PrintWriter out) throws IOException, InterruptedException { out.println("Welcome"); + runCli(new Connection(s)); + } + + /** + * CLI connection version2 that does transport encryption. + */ + private void runCliConnect2() throws IOException, InterruptedException { + try { + DataOutputStream out = new DataOutputStream(s.getOutputStream()); + out.writeUTF("Welcome"); + + // perform coin-toss and come up with a session key to encrypt data + Connection c = new Connection(s); + byte[] secret = c.diffieHellman(true).generateSecret(); + SecretKey sessionKey = new SecretKeySpec(Connection.fold(secret,128/8),"AES"); + c = c.encryptConnection(sessionKey,"AES/CFB8/NoPadding"); + + try { + // HACK: TODO: move the transport support into modules + Class cls = Jenkins.getInstance().pluginManager.uberClassLoader.loadClass("org.jenkinsci.main.modules.instance_identity.InstanceIdentity"); + Object iid = cls.getDeclaredMethod("get").invoke(null); + PrivateKey instanceId = (PrivateKey)cls.getDeclaredMethod("getPrivate").invoke(iid); + + // send a signature to prove our identity + Signature signer = Signature.getInstance("SHA1withRSA"); + signer.initSign(instanceId); + signer.update(secret); + c.writeByteArray(signer.sign()); + } catch (ClassNotFoundException e) { + throw new Error(e); + } catch (IllegalAccessException e) { + throw new Error(e); + } catch (InvocationTargetException e) { + throw new Error(e); + } catch (NoSuchMethodException e) { + throw new Error(e); + } + + runCli(c); + } catch (GeneralSecurityException e) { + throw new IOException2("Failed to encrypt the CLI channel",e); + } + } + + private void runCli(Connection c) throws IOException, InterruptedException { Channel channel = new Channel("CLI channel from " + s.getInetAddress(), Computer.threadPoolForRemoting, Mode.BINARY, - new BufferedInputStream(new SocketInputStream(this.s)), - new BufferedOutputStream(new SocketOutputStream(this.s)), null, true, Jenkins.getInstance().pluginManager.uberClassLoader); + new BufferedInputStream(c.in), new BufferedOutputStream(c.out), null, true, Jenkins.getInstance().pluginManager.uberClassLoader); channel.setProperty(CliEntryPoint.class.getName(),new CliManagerImpl(channel)); channel.join(); } diff --git a/core/src/main/resources/lib/layout/layout.jelly b/core/src/main/resources/lib/layout/layout.jelly index 6e6d7143cf..2cf181f02c 100644 --- a/core/src/main/resources/lib/layout/layout.jelly +++ b/core/src/main/resources/lib/layout/layout.jelly @@ -84,6 +84,7 @@ THE SOFTWARE. + -- GitLab