From b6cb074c043a4b883eb1f1a169a8e8dcb880b929 Mon Sep 17 00:00:00 2001 From: zzz <zzz@mail.i2p> Date: Fri, 26 Jun 2015 15:40:20 +0000 Subject: [PATCH] Add sam.config file support and -c file option Add partial SSL support (will require Java 7 due to SocketChannel changes) won't compile, SSLServerSocketChannel and SSLSocketChannel not checked in, pending decisions on implementation Bump version to 3.2 --- apps/sam/java/src/net/i2p/sam/SAMBridge.java | 115 ++++++++--- .../src/net/i2p/sam/SAMHandlerFactory.java | 5 +- apps/sam/java/src/net/i2p/sam/SSLUtil.java | 188 ++++++++++++++++++ 3 files changed, 284 insertions(+), 24 deletions(-) create mode 100644 apps/sam/java/src/net/i2p/sam/SSLUtil.java diff --git a/apps/sam/java/src/net/i2p/sam/SAMBridge.java b/apps/sam/java/src/net/i2p/sam/SAMBridge.java index 5f03436c2b..77ec2cffea 100644 --- a/apps/sam/java/src/net/i2p/sam/SAMBridge.java +++ b/apps/sam/java/src/net/i2p/sam/SAMBridge.java @@ -9,15 +9,16 @@ package net.i2p.sam; */ import java.io.BufferedReader; +import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStreamReader; +import java.net.InetAddress; import java.net.InetSocketAddress; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; -import java.nio.ByteBuffer; import java.util.Arrays; import java.util.ArrayList; import java.util.HashMap; @@ -27,16 +28,22 @@ import java.util.Map; import java.util.Properties; import java.util.Set; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLServerSocketFactory; + import gnu.getopt.Getopt; import net.i2p.I2PAppContext; import net.i2p.app.*; import static net.i2p.app.ClientAppState.*; import net.i2p.data.DataFormatException; +import net.i2p.data.DataHelper; import net.i2p.data.Destination; import net.i2p.util.I2PAppThread; +import net.i2p.util.I2PSSLSocketFactory; import net.i2p.util.Log; import net.i2p.util.PortMapper; +import net.i2p.util.SystemVersion; /** * SAM bridge implementation. @@ -50,6 +57,7 @@ public class SAMBridge implements Runnable, ClientApp { private final String _listenHost; private final int _listenPort; private final Properties i2cpProps; + private final boolean _useSSL; private volatile Thread _runner; /** @@ -72,6 +80,9 @@ public class SAMBridge implements Runnable, ClientApp { private static final int SAM_LISTENPORT = 7656; public static final String DEFAULT_SAM_KEYFILE = "sam.keys"; + static final String DEFAULT_SAM_CONFIGFILE = "sam.config"; + private static final String PROP_SAM_KEYFILE = "sam.keyfile"; + private static final String PROP_SAM_SSL = "sam.useSSL"; public static final String PROP_TCP_HOST = "sam.tcp.host"; public static final String PROP_TCP_PORT = "sam.tcp.port"; protected static final String DEFAULT_TCP_HOST = "127.0.0.1"; @@ -99,6 +110,9 @@ public class SAMBridge implements Runnable, ClientApp { Options options = getOptions(args); _listenHost = options.host; _listenPort = options.port; + _useSSL = options.isSSL; + if (_useSSL && !SystemVersion.isJava7()) + throw new IllegalArgumentException("SSL requires Java 7 or higher"); persistFilename = options.keyFile; nameToPrivKeys = new HashMap<String,String>(8); _handlers = new HashSet<Handler>(8); @@ -123,11 +137,15 @@ public class SAMBridge implements Runnable, ClientApp { * @param persistFile location to store/load named keys to/from * @throws RuntimeException if a server socket can't be opened */ - public SAMBridge(String listenHost, int listenPort, Properties i2cpProps, String persistFile) { + public SAMBridge(String listenHost, int listenPort, boolean isSSL, Properties i2cpProps, String persistFile) { _log = I2PAppContext.getGlobalContext().logManager().getLog(SAMBridge.class); _mgr = null; _listenHost = listenHost; _listenPort = listenPort; + _useSSL = isSSL; + if (_useSSL && !SystemVersion.isJava7()) + throw new IllegalArgumentException("SSL requires Java 7 or higher"); + this.i2cpProps = i2cpProps; persistFilename = persistFile; nameToPrivKeys = new HashMap<String,String>(8); _handlers = new HashSet<Handler>(8); @@ -141,7 +159,6 @@ public class SAMBridge implements Runnable, ClientApp { + ":" + listenPort, e); throw new RuntimeException(e); } - this.i2cpProps = i2cpProps; _state = INITIALIZED; } @@ -149,17 +166,28 @@ public class SAMBridge implements Runnable, ClientApp { * @since 0.9.6 */ private void openSocket() throws IOException { - if ( (_listenHost != null) && !("0.0.0.0".equals(_listenHost)) ) { - serverSocket = ServerSocketChannel.open(); - serverSocket.socket().bind(new InetSocketAddress(_listenHost, _listenPort)); - if (_log.shouldLog(Log.DEBUG)) - _log.debug("SAM bridge listening on " - + _listenHost + ":" + _listenPort); + if (_useSSL) { + SSLServerSocketFactory fact = SSLUtil.initializeFactory(i2cpProps); + InetAddress addr; + if (_listenHost != null && !_listenHost.equals("0.0.0.0")) + addr = InetAddress.getByName(_listenHost); + else + addr = null; + SSLServerSocket sock = (SSLServerSocket) fact.createServerSocket(_listenPort, 0, addr); + I2PSSLSocketFactory.setProtocolsAndCiphers(sock); + serverSocket = new SSLServerSocketChannel(sock); } else { serverSocket = ServerSocketChannel.open(); - serverSocket.socket().bind(new InetSocketAddress(_listenPort)); - if (_log.shouldLog(Log.DEBUG)) - _log.debug("SAM bridge listening on 0.0.0.0:" + _listenPort); + if (_listenHost != null && !_listenHost.equals("0.0.0.0")) { + serverSocket.socket().bind(new InetSocketAddress(_listenHost, _listenPort)); + if (_log.shouldLog(Log.DEBUG)) + _log.debug("SAM bridge listening on " + + _listenHost + ":" + _listenPort); + } else { + serverSocket.socket().bind(new InetSocketAddress(_listenPort)); + if (_log.shouldLog(Log.DEBUG)) + _log.debug("SAM bridge listening on 0.0.0.0:" + _listenPort); + } } } @@ -420,7 +448,7 @@ public class SAMBridge implements Runnable, ClientApp { public static void main(String args[]) { try { Options options = getOptions(args); - SAMBridge bridge = new SAMBridge(options.host, options.port, options.opts, options.keyFile); + SAMBridge bridge = new SAMBridge(options.host, options.port, options.isSSL, options.opts, options.keyFile); bridge.startThread(); } catch (RuntimeException e) { e.printStackTrace(); @@ -477,15 +505,18 @@ public class SAMBridge implements Runnable, ClientApp { * depth, etc. * @param args [ keyfile [ listenHost ] listenPort [ name=val ]* ] * @return non-null Options or throws Exception + * @throws HelpRequestedException on command line problems + * @throws IllegalArgumentException if specified config file does not exist + * @throws IOException if specified config file cannot be read, or on SSL keystore problems * @since 0.9.6 */ private static Options getOptions(String args[]) throws Exception { - String keyfile = DEFAULT_SAM_KEYFILE; - int port = SAM_LISTENPORT; - String host = DEFAULT_TCP_HOST; + String keyfile = null; + int port = -1; + String host = null; boolean isSSL = false; - Properties opts = null; - Getopt g = new Getopt("SAM", args, "hs"); + String cfile = null; + Getopt g = new Getopt("SAM", args, "hsc:"); int c; while ((c = g.getopt()) != -1) { switch (c) { @@ -493,6 +524,10 @@ public class SAMBridge implements Runnable, ClientApp { isSSL = true; break; + case 'c': + cfile = g.getOptarg(); + break; + case 'h': case '?': case ':': @@ -539,19 +574,52 @@ public class SAMBridge implements Runnable, ClientApp { throw new HelpRequestedException(); } + String scfile = cfile != null ? cfile : DEFAULT_SAM_CONFIGFILE; + File file = new File(scfile); + if (!file.isAbsolute()) + file = new File(I2PAppContext.getGlobalContext().getConfigDir(), scfile); + + Properties opts = new Properties(); + if (file.exists()) { + DataHelper.loadProps(opts, file); + } else if (cfile != null) { + // only throw if specified on command line + throw new IllegalArgumentException("Config file not found: " + file); + } + // command line trumps config file trumps defaults + if (host == null) + host = opts.getProperty(PROP_TCP_HOST, DEFAULT_TCP_HOST); + if (port < 0) { + try { + port = Integer.parseInt(opts.getProperty(PROP_TCP_PORT, DEFAULT_TCP_PORT)); + } catch (NumberFormatException nfe) { + throw new HelpRequestedException(); + } + } + if (keyfile == null) + keyfile = opts.getProperty(PROP_SAM_KEYFILE, DEFAULT_SAM_KEYFILE); + if (!isSSL) + isSSL = Boolean.parseBoolean(opts.getProperty(PROP_SAM_SSL)); + if (isSSL) { + // must do this before we add command line opts since we may be writing them back out + boolean shouldSave = SSLUtil.verifyKeyStore(opts); + if (shouldSave) + DataHelper.storeProps(opts, file); + } + int remaining = args.length - startOpts; if (remaining > 0) { - opts = parseOptions(args, startOpts); + parseOptions(args, startOpts, opts); } return new Options(host, port, isSSL, opts, keyfile); } /** * Parse key=value options starting at startArgs. + * @param props out parameter, any options found are added * @throws HelpRequestedException on any item not of the form key=value. */ - private static Properties parseOptions(String args[], int startArgs) throws HelpRequestedException { - Properties props = new Properties(); + private static void parseOptions(String args[], int startArgs, Properties props) throws HelpRequestedException { for (int i = startArgs; i < args.length; i++) { int eq = args[i].indexOf('='); if (eq <= 0) @@ -567,13 +635,14 @@ public class SAMBridge implements Runnable, ClientApp { else throw new HelpRequestedException(); } - return props; } private static void usage() { - System.err.println("Usage: SAMBridge [keyfile [listenHost] listenPortNum[ name=val]*]\n" + + System.err.println("Usage: SAMBridge [-s] [-c sam.config] [keyfile [listenHost] listenPortNum[ name=val]*]\n" + "or:\n" + " SAMBridge [ name=val ]*\n" + + " -s: Use SSL\n" + + " -c sam.config: Specify config file\n" + " keyfile: location to persist private keys (default sam.keys)\n" + " listenHost: interface to listen on (0.0.0.0 for all interfaces)\n" + " listenPort: port to listen for SAM connections on (default 7656)\n" + diff --git a/apps/sam/java/src/net/i2p/sam/SAMHandlerFactory.java b/apps/sam/java/src/net/i2p/sam/SAMHandlerFactory.java index 582854d870..dc4c8f24f6 100644 --- a/apps/sam/java/src/net/i2p/sam/SAMHandlerFactory.java +++ b/apps/sam/java/src/net/i2p/sam/SAMHandlerFactory.java @@ -25,7 +25,7 @@ import net.i2p.util.VersionComparator; */ class SAMHandlerFactory { - private static final String VERSION = "3.1"; + private static final String VERSION = "3.2"; private static final int HELLO_TIMEOUT = 60*1000; @@ -131,6 +131,9 @@ class SAMHandlerFactory { if (VersionComparator.comp(VERSION, minVer) >= 0 && VersionComparator.comp(VERSION, maxVer) <= 0) return VERSION; + if (VersionComparator.comp("3.1", minVer) >= 0 && + VersionComparator.comp("3.1", maxVer) <= 0) + return "3.1"; // in VersionComparator, "3" < "3.0" so // use comparisons carefully if (VersionComparator.comp("3.0", minVer) >= 0 && diff --git a/apps/sam/java/src/net/i2p/sam/SSLUtil.java b/apps/sam/java/src/net/i2p/sam/SSLUtil.java new file mode 100644 index 0000000000..bc0331417e --- /dev/null +++ b/apps/sam/java/src/net/i2p/sam/SSLUtil.java @@ -0,0 +1,188 @@ +package net.i2p.sam; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.KeyStore; +import java.security.GeneralSecurityException; +import java.util.Properties; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLServerSocketFactory; +import javax.net.ssl.SSLContext; + +import net.i2p.I2PAppContext; +import net.i2p.crypto.KeyStoreUtil; +import net.i2p.util.Log; +import net.i2p.util.SecureDirectory; + +/** + * Utilities for SAM SSL server sockets. + * + * @since 0.9.22 adopted from net.i2p.i2ptunnel.SSLClientUtil + */ +class SSLUtil { + + private static final String PROP_KEYSTORE_PASSWORD = "sam.keystorePassword"; + private static final String DEFAULT_KEYSTORE_PASSWORD = "changeit"; + private static final String PROP_KEY_PASSWORD = "sam.keyPassword"; + private static final String PROP_KEY_ALIAS = "sam.keyAlias"; + private static final String ASCII_KEYFILE_SUFFIX = ".local.crt"; + private static final String PROP_KS_NAME = "sam.keystoreFile"; + private static final String KS_DIR = "keystore"; + private static final String PREFIX = "sam-"; + private static final String KS_SUFFIX = ".ks"; + private static final String CERT_DIR = "certificates/sam"; + + /** + * Create a new selfsigned cert and keystore and pubkey cert if they don't exist. + * May take a while. + * + * @param opts in/out, updated if rv is true + * @return false if it already exists; if true, caller must save opts + * @throws IOException on creation fail + */ + public static boolean verifyKeyStore(Properties opts) throws IOException { + String name = opts.getProperty(PROP_KEY_ALIAS); + if (name == null) { + name = KeyStoreUtil.randomString(); + opts.setProperty(PROP_KEY_ALIAS, name); + } + String ksname = opts.getProperty(PROP_KS_NAME); + if (ksname == null) { + ksname = PREFIX + name + KS_SUFFIX; + opts.setProperty(PROP_KS_NAME, ksname); + } + File ks = new File(ksname); + if (!ks.isAbsolute()) { + ks = new File(I2PAppContext.getGlobalContext().getConfigDir(), KS_DIR); + ks = new File(ks, ksname); + } + if (ks.exists()) + return false; + File dir = ks.getParentFile(); + if (!dir.exists()) { + File sdir = new SecureDirectory(dir.getAbsolutePath()); + if (!sdir.mkdirs()) + throw new IOException("Unable to create keystore " + ks); + } + boolean rv = createKeyStore(ks, name, opts); + if (!rv) + throw new IOException("Unable to create keystore " + ks); + + // Now read it back out of the new keystore and save it in ascii form + // where the clients can get to it. + // Failure of this part is not fatal. + exportCert(ks, name, opts); + return true; + } + + + /** + * Call out to keytool to create a new keystore with a keypair in it. + * + * @param name used in CNAME + * @param opts in/out, updated if rv is true, must contain PROP_KEY_ALIAS + * @return success, if true, opts will have password properties added to be saved + */ + private static boolean createKeyStore(File ks, String name, Properties opts) { + // make a random 48 character password (30 * 8 / 5) + String keyPassword = KeyStoreUtil.randomString(); + // and one for the cname + String cname = name + ".sam.i2p.net"; + + String keyName = opts.getProperty(PROP_KEY_ALIAS); + boolean success = KeyStoreUtil.createKeys(ks, keyName, cname, "SAM", keyPassword); + if (success) { + success = ks.exists(); + if (success) { + opts.setProperty(PROP_KEYSTORE_PASSWORD, DEFAULT_KEYSTORE_PASSWORD); + opts.setProperty(PROP_KEY_PASSWORD, keyPassword); + } + } + if (success) { + logAlways("Created self-signed certificate for " + cname + " in keystore: " + ks.getAbsolutePath() + "\n" + + "The certificate name was generated randomly, and is not associated with your " + + "IP address, host name, router identity, or destination keys."); + } else { + error("Failed to create SAM SSL keystore.\n" + + "If you create the keystore manually, you must add " + PROP_KEYSTORE_PASSWORD + " and " + PROP_KEY_PASSWORD + + " to " + (new File(I2PAppContext.getGlobalContext().getConfigDir(), SAMBridge.DEFAULT_SAM_CONFIGFILE)).getAbsolutePath()); + } + return success; + } + + /** + * Pull the cert back OUT of the keystore and save it as ascii + * so the clients can get to it. + * + * @param name used to generate output file name + * @param opts must contain PROP_KEY_ALIAS + */ + private static void exportCert(File ks, String name, Properties opts) { + File sdir = new SecureDirectory(I2PAppContext.getGlobalContext().getConfigDir(), CERT_DIR); + if (sdir.exists() || sdir.mkdirs()) { + String keyAlias = opts.getProperty(PROP_KEY_ALIAS); + String ksPass = opts.getProperty(PROP_KEYSTORE_PASSWORD, DEFAULT_KEYSTORE_PASSWORD); + File out = new File(sdir, PREFIX + name + ASCII_KEYFILE_SUFFIX); + boolean success = KeyStoreUtil.exportCert(ks, ksPass, keyAlias, out); + if (!success) + error("Error getting SSL cert to save as ASCII"); + } else { + error("Error saving ASCII SSL keys"); + } + } + + /** + * Sets up the SSLContext and sets the socket factory. + * No option prefix allowed. + * + * @throws IOException; GeneralSecurityExceptions are wrapped in IOE for convenience + * @return factory, throws on all errors + */ + public static SSLServerSocketFactory initializeFactory(Properties opts) throws IOException { + String ksPass = opts.getProperty(PROP_KEYSTORE_PASSWORD, DEFAULT_KEYSTORE_PASSWORD); + String keyPass = opts.getProperty(PROP_KEY_PASSWORD); + if (keyPass == null) { + throw new IOException("No key password, set " + PROP_KEY_PASSWORD + " in " + + (new File(I2PAppContext.getGlobalContext().getConfigDir(), SAMBridge.DEFAULT_SAM_CONFIGFILE)).getAbsolutePath()); + } + String ksname = opts.getProperty(PROP_KS_NAME); + if (ksname == null) { + throw new IOException("No keystore, set " + PROP_KS_NAME + " in " + + (new File(I2PAppContext.getGlobalContext().getConfigDir(), SAMBridge.DEFAULT_SAM_CONFIGFILE)).getAbsolutePath()); + } + File ks = new File(ksname); + if (!ks.isAbsolute()) { + ks = new File(I2PAppContext.getGlobalContext().getConfigDir(), KS_DIR); + ks = new File(ks, ksname); + } + + InputStream fis = null; + try { + SSLContext sslc = SSLContext.getInstance("TLS"); + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + fis = new FileInputStream(ks); + keyStore.load(fis, ksPass.toCharArray()); + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keyStore, keyPass.toCharArray()); + sslc.init(kmf.getKeyManagers(), null, I2PAppContext.getGlobalContext().random()); + return sslc.getServerSocketFactory(); + } catch (GeneralSecurityException gse) { + IOException ioe = new IOException("keystore error"); + ioe.initCause(gse); + throw ioe; + } finally { + if (fis != null) try { fis.close(); } catch (IOException ioe) {} + } + } + + private static void error(String s) { + I2PAppContext.getGlobalContext().logManager().getLog(SSLUtil.class).error(s); + } + + private static void logAlways(String s) { + I2PAppContext.getGlobalContext().logManager().getLog(SSLUtil.class).logAlways(Log.INFO, s); + } +} -- GitLab