/* dCache - http://www.dcache.org/ * * Copyright (C) 2015 Deutsches Elektronen-Synchrotron * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.dcache.ftp.client.extended; import com.google.common.base.Splitter; import com.google.common.io.BaseEncoding; import org.apache.http.conn.ssl.SSLConnectionSocketFactory; import javax.net.ssl.HostnameVerifier; import javax.net.ssl.SSLPeerUnverifiedException; import javax.net.ssl.SSLSession; import javax.security.auth.x500.X500Principal; import java.io.BufferedReader; import java.io.ByteArrayOutputStream; import java.io.EOFException; import java.io.IOException; import java.io.StringReader; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.security.cert.Certificate; import java.security.cert.X509Certificate; import java.util.List; import org.dcache.dss.DssContext; import org.dcache.dss.DssContextFactory; import org.dcache.dss.SslEngineDssContext; import org.dcache.ftp.client.exception.FTPReplyParseException; import org.dcache.ftp.client.exception.ServerException; import org.dcache.ftp.client.exception.UnexpectedReplyCodeException; import org.dcache.ftp.client.vanilla.Command; import org.dcache.ftp.client.vanilla.FTPControlChannel; import org.dcache.ftp.client.vanilla.Flag; import org.dcache.ftp.client.vanilla.Reply; import static com.google.common.io.BaseEncoding.base64; /** * GridFTP control channel wraps a vanilla control channel and * adds GSI encryption. */ public class GridFTPControlChannel extends FTPControlChannel { protected final FTPControlChannel inner; protected final DssContext context; protected final HostnameVerifier hostnameVerifier = SSLConnectionSocketFactory.getDefaultHostnameVerifier(); protected Reply lastReply; /** * Creates an encrypted control channel wrapping an unencrypted control channel. * The constructor will establish a common security context with the server. */ public GridFTPControlChannel(FTPControlChannel inner, DssContextFactory factory, String expectedHostName) throws IOException, ServerException { super(inner.getHost(), inner.getPort()); this.inner = inner; this.context = authenticate(factory, expectedHostName); } /** * Performs authentication with specified user credentials and * a specific username (assuming the user dn maps to the passed username). * * @throws IOException on i/o error * @throws ServerException on server refusal or faulty server behavior */ private DssContext authenticate(DssContextFactory factory, String expectedHostName) throws IOException, ServerException { DssContext context; try { try { Reply reply = inner.exchange(new Command("AUTH", "GSSAPI")); if (!Reply.isPositiveIntermediate(reply)) { throw ServerException.embedUnexpectedReplyCodeException( new UnexpectedReplyCodeException(reply), "Server refused GSSAPI authentication."); } } catch (FTPReplyParseException rpe) { throw ServerException.embedFTPReplyParseException( rpe, "Received faulty reply to AUTH GSSAPI."); } context = factory.create(inner.getRemoteAddress(), inner.getLocalAddress()); Reply reply; byte[] inToken = new byte[0]; do { byte[] outToken = context.init(inToken); reply = inner.exchange(new Command("ADAT", BaseEncoding.base64().encode(outToken != null ? outToken : new byte[0]))); if (reply.getMessage().startsWith("ADAT=")) { inToken = BaseEncoding.base64().decode(reply.getMessage().substring(5)); } else { inToken = new byte[0]; } } while (Reply.isPositiveIntermediate(reply) && !context.isEstablished()); if (!Reply.isPositiveCompletion(reply)) { throw ServerException.embedUnexpectedReplyCodeException( new UnexpectedReplyCodeException(reply), "Server failed GSI handshake."); } if (inToken.length > 0 || !context.isEstablished()) { byte[] outToken = context.init(inToken); if (outToken != null || !context.isEstablished()) { throw new ServerException(ServerException.WRONG_PROTOCOL, "Unexpected GSI handshake completion."); } } SSLSession session = ((SslEngineDssContext) context).getSSLSession(); if (!this.hostnameVerifier.verify(expectedHostName, session)) { final Certificate[] certs = session.getPeerCertificates(); final X509Certificate x509 = (X509Certificate) certs[0]; final X500Principal x500Principal = x509.getSubjectX500Principal(); throw new SSLPeerUnverifiedException("Host name '" + expectedHostName + "' does not match " + "the certificate subject provided by the peer (" + x500Principal.toString() + ")"); } } catch (FTPReplyParseException e) { throw ServerException.embedFTPReplyParseException(e, "Received faulty reply to ADAT."); } return context; } @Override public String getHost() { return inner.getHost(); } @Override public int getPort() { return inner.getPort(); } @Override public InetSocketAddress getLocalAddress() { return inner.getLocalAddress(); } @Override public InetSocketAddress getRemoteAddress() { return inner.getRemoteAddress(); } @Override public boolean isIPv6() { return inner.isIPv6(); } @Override public void open() throws IOException, ServerException { throw new UnsupportedOperationException("GridFTPControlChannel wraps existing control channel and cannot be opened."); } @Override public Reply getLastReply() { return lastReply; } @Override public void close() throws IOException { inner.close(); } @Override public void waitFor(Flag aborted, int ioDelay, int maxWait) throws ServerException, IOException, InterruptedException { inner.waitFor(aborted, ioDelay, maxWait); } @Override public Reply read() throws ServerException, IOException, FTPReplyParseException, EOFException { Reply reply = inner.read(); if (reply.getCode() != 632 && reply.getCode() != 633) { throw ServerException.embedUnexpectedReplyCodeException( new UnexpectedReplyCodeException(reply), "Expected 632 or 633 reply."); } // FIXME: this is a work-around against problems in Reply to fix // multi-line 63x responses in a way that can be back-ported. ByteArrayOutputStream out = new ByteArrayOutputStream(); boolean isFirstLine = true; for (String line : Splitter.on('\n').split(reply.getMessage())) { byte[] token = base64().decode(isFirstLine ? line : line.substring(4)); out.write(token, 0, token.length); isFirstLine = false; } String unwrapped = new String(context.unwrap(out.toByteArray())); lastReply = new Reply(new BufferedReader(new StringReader(unwrapped))); return lastReply; } @Override public void abortTransfer() { inner.abortTransfer(); } @Override public void write(Command cmd) throws IOException, IllegalArgumentException { byte[] bytes = cmd.toString().getBytes(StandardCharsets.US_ASCII); byte[] token = context.wrap(bytes, 0, bytes.length); inner.write(new Command("ENC", BaseEncoding.base64().encode(token))); } }