提交 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>
<li class=rfe>
Allow file parameters to be viewed as plain text.
(<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>
Improve the low disk space warning message.
(<a href="https://issues.jenkins-ci.org/browse/JENKINS-13826">issue 13826</a>)
......
......@@ -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);
}
/**
......
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;
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());
}
......
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 @@
*/
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();
}
......
......@@ -84,6 +84,7 @@ THE SOFTWARE.
<!-- advertise the CLI TCP 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-CLI2-Port" value="${app.tcpSlaveAgentListener.port}"/>
<st:header name="X-Jenkins-CLI-Host" value="${app.tcpSlaveAgentListener.CLI_HOST_NAME}"/>
</j:if>
<j:forEach var="pd" items="${h.pageDecorators}">
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册