diff --git a/changelog.html b/changelog.html
index ef152ab5f2779f8313dfd9706ae9462dbbea9bde..1e3d7df18c223de681d9b470223999ad4ea65e31 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 697a607390fdea268b0442f298b5f71ae4dccdb3..f6242c7ceb26b96b82fd5800c9dcbfa5f156f473 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 0000000000000000000000000000000000000000..1507704d9c0e79dc40c88aa25d8bbe369c159e2a
--- /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 543e6acb3e1d504bbf353156ebee6e9bb9e0b196..232970869d95ae3b3206c60cfb3859eb5fd99a87 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 0000000000000000000000000000000000000000..e9cc086affda83a0987cd65735986ce54553cb40
--- /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 ccdfefd96be8fd8f7ec3b1bf34cf96b0baa51bf6..2f2f35d41843fc4d2f093f2f2b5ea0dd477b5588 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 6e6d7143cf8a461e81e9e75713e49ffaf5a160c7..2cf181f02c94f710e39e924db45b43c1edbfe4b2 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.
+