提交 25d990c5 编写于 作者: K Kohsuke Kawaguchi

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.
上级 8ec14ce2
...@@ -72,6 +72,8 @@ Upcoming changes</a> ...@@ -72,6 +72,8 @@ Upcoming changes</a>
<li class=rfe> <li class=rfe>
Allow file parameters to be viewed as plain text. Allow file parameters to be viewed as plain text.
(<a href="https://issues.jenkins-ci.org/browse/JENKINS-13640">issue 13640</a>) (<a href="https://issues.jenkins-ci.org/browse/JENKINS-13640">issue 13640</a>)
<li class=rfe>
CLI connection to the master is now encrypted.
<li class=rfe> <li class=rfe>
Improve the low disk space warning message. Improve the low disk space warning message.
(<a href="https://issues.jenkins-ci.org/browse/JENKINS-13826">issue 13826</a>) (<a href="https://issues.jenkins-ci.org/browse/JENKINS-13826">issue 13826</a>)
......
...@@ -33,6 +33,9 @@ import hudson.remoting.RemoteOutputStream; ...@@ -33,6 +33,9 @@ import hudson.remoting.RemoteOutputStream;
import hudson.remoting.SocketInputStream; import hudson.remoting.SocketInputStream;
import hudson.remoting.SocketOutputStream; import hudson.remoting.SocketOutputStream;
import javax.crypto.KeyAgreement;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.io.BufferedInputStream; import java.io.BufferedInputStream;
import java.io.BufferedOutputStream; import java.io.BufferedOutputStream;
import java.io.BufferedReader; import java.io.BufferedReader;
...@@ -57,8 +60,10 @@ import java.security.GeneralSecurityException; ...@@ -57,8 +60,10 @@ import java.security.GeneralSecurityException;
import java.security.KeyFactory; import java.security.KeyFactory;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.PublicKey; import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.DSAPrivateKeySpec; import java.security.spec.DSAPrivateKeySpec;
import java.security.spec.DSAPublicKeySpec; import java.security.spec.DSAPublicKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
...@@ -120,7 +125,7 @@ public class CLI { ...@@ -120,7 +125,7 @@ public class CLI {
pool = exec!=null ? exec : Executors.newCachedThreadPool(); pool = exec!=null ? exec : Executors.newCachedThreadPool();
Channel channel = null; Channel channel = null;
InetSocketAddress clip = getCliTcpPort(url); CliPort clip = getCliTcpPort(url);
if(clip!=null) { if(clip!=null) {
// connect via CLI port // connect via CLI port
try { try {
...@@ -161,8 +166,8 @@ public class CLI { ...@@ -161,8 +166,8 @@ public class CLI {
return ch; return ch;
} }
private Channel connectViaCliPort(URL jenkins, InetSocketAddress endpoint) throws IOException { private Channel connectViaCliPort(URL jenkins, CliPort clip) throws IOException {
LOGGER.fine("Trying to connect directly via TCP/IP to "+endpoint); LOGGER.fine("Trying to connect directly via TCP/IP to "+clip.endpoint);
final Socket s; final Socket s;
OutputStream out; OutputStream out;
...@@ -170,7 +175,7 @@ public class CLI { ...@@ -170,7 +175,7 @@ public class CLI {
String[] tokens = httpsProxyTunnel.split(":"); String[] tokens = httpsProxyTunnel.split(":");
s = new Socket(tokens[0], Integer.parseInt(tokens[1])); s = new Socket(tokens[0], Integer.parseInt(tokens[1]));
PrintStream o = new PrintStream(s.getOutputStream()); 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 // read the response from the proxy
ByteArrayOutputStream rsp = new ByteArrayOutputStream(); ByteArrayOutputStream rsp = new ByteArrayOutputStream();
...@@ -194,7 +199,7 @@ public class CLI { ...@@ -194,7 +199,7 @@ public class CLI {
}; };
} else { } else {
s = new Socket(); s = new Socket();
s.connect(endpoint,3000); s.connect(clip.endpoint,3000);
out = new SocketOutputStream(s); out = new SocketOutputStream(s);
} }
...@@ -204,18 +209,51 @@ public class CLI { ...@@ -204,18 +209,51 @@ public class CLI {
} }
}); });
DataOutputStream dos = new DataOutputStream(s.getOutputStream()); Connection c = new Connection(new SocketInputStream(s),out);
dos.writeUTF("Protocol:CLI-connect");
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, return new Channel("CLI connection to "+jenkins, pool,
new BufferedInputStream(new SocketInputStream(s)), new BufferedInputStream(c.in), new BufferedOutputStream(c.out));
new BufferedOutputStream(out));
} }
/** /**
* If the server advertises CLI endpoint, returns its location. * 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); URL _url = new URL(url);
if (_url.getHost()==null || _url.getHost().length()==0) { if (_url.getHost()==null || _url.getHost().length()==0) {
throw new IOException("Invalid URL: "+url); throw new IOException("Invalid URL: "+url);
...@@ -226,15 +264,20 @@ public class CLI { ...@@ -226,15 +264,20 @@ public class CLI {
} catch (IOException e) { } catch (IOException e) {
throw (IOException)new IOException("Failed to connect to "+url).initCause(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"); String h = head.getHeaderField("X-Jenkins-CLI-Host");
if (h==null) h = head.getURL().getHost(); 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); flushURLConnection(head);
if (p==null) return null; if (p1==null && p2==null) return null;
return new InetSocketAddress(h,Integer.parseInt(p)); 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);
} }
/** /**
......
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));
}
}
...@@ -27,9 +27,15 @@ import hudson.remoting.SocketInputStream; ...@@ -27,9 +27,15 @@ import hudson.remoting.SocketInputStream;
import hudson.remoting.SocketOutputStream; import hudson.remoting.SocketOutputStream;
import org.apache.commons.codec.binary.Base64; 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.KeyAgreement;
import javax.crypto.SecretKey;
import javax.crypto.interfaces.DHPublicKey; import javax.crypto.interfaces.DHPublicKey;
import javax.crypto.spec.DHParameterSpec; import javax.crypto.spec.DHParameterSpec;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.DataInputStream; import java.io.DataInputStream;
import java.io.DataOutputStream; import java.io.DataOutputStream;
import java.io.IOException; import java.io.IOException;
...@@ -44,7 +50,6 @@ import java.security.Key; ...@@ -44,7 +50,6 @@ import java.security.Key;
import java.security.KeyFactory; import java.security.KeyFactory;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.KeyPairGenerator; import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey; import java.security.PublicKey;
import java.security.Signature; import java.security.Signature;
import java.security.interfaces.DSAPublicKey; import java.security.interfaces.DSAPublicKey;
...@@ -113,10 +118,21 @@ public class Connection { ...@@ -113,10 +118,21 @@ public class Connection {
} }
public X509EncodedKeySpec readKey() throws IOException { 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); 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. * Performs a Diffie-Hellman key exchange and produce a common secret between two ends of the connection.
* *
...@@ -125,12 +141,15 @@ public class Connection { ...@@ -125,12 +141,15 @@ public class Connection {
* each other. * each other.
*/ */
public KeyAgreement diffieHellman(boolean side) throws IOException, GeneralSecurityException { public KeyAgreement diffieHellman(boolean side) throws IOException, GeneralSecurityException {
return diffieHellman(side,512);
}
public KeyAgreement diffieHellman(boolean side, int keySize) throws IOException, GeneralSecurityException {
KeyPair keyPair; KeyPair keyPair;
PublicKey otherHalf; PublicKey otherHalf;
if (side) { if (side) {
AlgorithmParameterGenerator paramGen = AlgorithmParameterGenerator.getInstance("DH"); AlgorithmParameterGenerator paramGen = AlgorithmParameterGenerator.getInstance("DH");
paramGen.init(512); paramGen.init(keySize);
KeyPairGenerator dh = KeyPairGenerator.getInstance("DH"); KeyPairGenerator dh = KeyPairGenerator.getInstance("DH");
dh.initialize(paramGen.generateParameters().getParameterSpec(DHParameterSpec.class)); dh.initialize(paramGen.generateParameters().getParameterSpec(DHParameterSpec.class));
...@@ -157,6 +176,38 @@ public class Connection { ...@@ -157,6 +176,38 @@ public class Connection {
return ka; 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) { private String detectKeyAlgorithm(KeyPair kp) {
return detectKeyAlgorithm(kp.getPublic()); return detectKeyAlgorithm(kp.getPublic());
} }
......
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;
}
}
...@@ -23,6 +23,8 @@ ...@@ -23,6 +23,8 @@
*/ */
package hudson; package hudson;
import hudson.cli.Connection;
import hudson.util.Secret;
import jenkins.model.Jenkins; import jenkins.model.Jenkins;
import hudson.model.Computer; import hudson.model.Computer;
import hudson.slaves.OfflineCause; import hudson.slaves.OfflineCause;
...@@ -37,17 +39,31 @@ import hudson.cli.CliManagerImpl; ...@@ -37,17 +39,31 @@ import hudson.cli.CliManagerImpl;
import hudson.cli.CliEntryPoint; import hudson.cli.CliEntryPoint;
import hudson.util.IOException2; 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.ByteArrayInputStream;
import java.io.DataInputStream; import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.io.PrintWriter; import java.io.PrintWriter;
import java.io.BufferedInputStream; import java.io.BufferedInputStream;
import java.io.BufferedOutputStream; import java.io.BufferedOutputStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.ServerSocket; import java.net.ServerSocket;
import java.net.Socket; import java.net.Socket;
import java.net.BindException; import java.net.BindException;
import java.security.GeneralSecurityException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.security.Signature;
import java.util.Map.Entry; import java.util.Map.Entry;
import java.util.Properties; import java.util.Properties;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
...@@ -173,7 +189,7 @@ public final class TcpSlaveAgentListener extends Thread { ...@@ -173,7 +189,7 @@ public final class TcpSlaveAgentListener extends Thread {
LOGGER.info("Accepted connection #"+id+" from "+s.getRemoteSocketAddress()); LOGGER.info("Accepted connection #"+id+" from "+s.getRemoteSocketAddress());
DataInputStream in = new DataInputStream(s.getInputStream()); 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(); String s = in.readUTF();
...@@ -185,6 +201,8 @@ public final class TcpSlaveAgentListener extends Thread { ...@@ -185,6 +201,8 @@ public final class TcpSlaveAgentListener extends Thread {
runJnlp2Connect(in, out); runJnlp2Connect(in, out);
} else if(protocol.equals("CLI-connect")) { } else if(protocol.equals("CLI-connect")) {
runCliConnect(in, out); runCliConnect(in, out);
} else if(protocol.equals("CLI2-connect")) {
runCliConnect2();
} else { } else {
error(out, "Unknown protocol:" + s); error(out, "Unknown protocol:" + s);
} }
...@@ -213,10 +231,54 @@ public final class TcpSlaveAgentListener extends Thread { ...@@ -213,10 +231,54 @@ public final class TcpSlaveAgentListener extends Thread {
*/ */
private void runCliConnect(DataInputStream in, PrintWriter out) throws IOException, InterruptedException { private void runCliConnect(DataInputStream in, PrintWriter out) throws IOException, InterruptedException {
out.println("Welcome"); 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(), Channel channel = new Channel("CLI channel from " + s.getInetAddress(),
Computer.threadPoolForRemoting, Mode.BINARY, Computer.threadPoolForRemoting, Mode.BINARY,
new BufferedInputStream(new SocketInputStream(this.s)), new BufferedInputStream(c.in), new BufferedOutputStream(c.out), null, true, Jenkins.getInstance().pluginManager.uberClassLoader);
new BufferedOutputStream(new SocketOutputStream(this.s)), null, true, Jenkins.getInstance().pluginManager.uberClassLoader);
channel.setProperty(CliEntryPoint.class.getName(),new CliManagerImpl(channel)); channel.setProperty(CliEntryPoint.class.getName(),new CliManagerImpl(channel));
channel.join(); channel.join();
} }
......
...@@ -84,6 +84,7 @@ THE SOFTWARE. ...@@ -84,6 +84,7 @@ THE SOFTWARE.
<!-- advertise the CLI TCP port --> <!-- advertise the CLI TCP port -->
<st:header name="X-Hudson-CLI-Port" value="${app.tcpSlaveAgentListener.port}"/> <st:header name="X-Hudson-CLI-Port" value="${app.tcpSlaveAgentListener.port}"/>
<st:header name="X-Jenkins-CLI-Port" value="${app.tcpSlaveAgentListener.port}"/> <st:header name="X-Jenkins-CLI-Port" value="${app.tcpSlaveAgentListener.port}"/>
<st:header name="X-Jenkins-CLI2-Port" value="${app.tcpSlaveAgentListener.port}"/>
<st:header name="X-Jenkins-CLI-Host" value="${app.tcpSlaveAgentListener.CLI_HOST_NAME}"/> <st:header name="X-Jenkins-CLI-Host" value="${app.tcpSlaveAgentListener.CLI_HOST_NAME}"/>
</j:if> </j:if>
<j:forEach var="pd" items="${h.pageDecorators}"> <j:forEach var="pd" items="${h.pageDecorators}">
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册