diff --git a/apps/sam/java/src/net/i2p/sam/SAMHandlerFactory.java b/apps/sam/java/src/net/i2p/sam/SAMHandlerFactory.java
index 71a617e25fc8ad436f19e304277cae3487c12577..c484c1b84165dfa943eebb1c0b91cf3016a793dc 100644
--- a/apps/sam/java/src/net/i2p/sam/SAMHandlerFactory.java
+++ b/apps/sam/java/src/net/i2p/sam/SAMHandlerFactory.java
@@ -13,7 +13,6 @@ import java.net.Socket;
 import java.net.SocketTimeoutException;
 import java.nio.channels.SocketChannel;
 import java.util.Properties;
-import java.util.StringTokenizer;
 
 import net.i2p.I2PAppContext;
 import net.i2p.data.DataHelper;
@@ -41,7 +40,7 @@ class SAMHandlerFactory {
      */
     public static SAMHandler createSAMHandler(SocketChannel s, Properties i2cpProps,
                                               SAMBridge parent) throws SAMException {
-        StringTokenizer tok;
+        String line;
         Log log = I2PAppContext.getGlobalContext().logManager().getLog(SAMHandlerFactory.class);
 
         try {
@@ -49,9 +48,8 @@ class SAMHandlerFactory {
             sock.setKeepAlive(true);
             StringBuilder buf = new StringBuilder(128);
             ReadLine.readLine(sock, buf, HELLO_TIMEOUT);
-            String line = buf.toString();
             sock.setSoTimeout(0);
-            tok = new StringTokenizer(line.trim(), " ");
+            line = buf.toString();
         } catch (SocketTimeoutException e) {
             throw new SAMException("Timeout waiting for HELLO VERSION", e);
         } catch (IOException e) {
@@ -61,15 +59,13 @@ class SAMHandlerFactory {
         }
 
         // Message format: HELLO VERSION [MIN=v1] [MAX=v2]
-        if (tok.countTokens() < 2) {
+        Properties props = SAMUtils.parseParams(line);
+        if (!"HELLO".equals(props.getProperty(SAMUtils.COMMAND)) ||
+            !"VERSION".equals(props.getProperty(SAMUtils.OPCODE))) {
             throw new SAMException("Must start with HELLO VERSION");
         }
-        if (!tok.nextToken().equals("HELLO") ||
-            !tok.nextToken().equals("VERSION")) {
-            throw new SAMException("Must start with HELLO VERSION");
-        }
-
-        Properties props = SAMUtils.parseParams(tok);
+        props.remove(SAMUtils.COMMAND);
+        props.remove(SAMUtils.OPCODE);
 
         String minVer = props.getProperty("MIN");
         if (minVer == null) {
diff --git a/apps/sam/java/src/net/i2p/sam/SAMUtils.java b/apps/sam/java/src/net/i2p/sam/SAMUtils.java
index e1e52f4fc4c1dead45aff0f3231fe043b2ce725d..723defab627411e935ea01f3944fb008f35c357b 100644
--- a/apps/sam/java/src/net/i2p/sam/SAMUtils.java
+++ b/apps/sam/java/src/net/i2p/sam/SAMUtils.java
@@ -11,9 +11,9 @@ package net.i2p.sam;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.OutputStream;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Properties;
-import java.util.StringTokenizer;
 
 import net.i2p.I2PAppContext;
 import net.i2p.I2PException;
@@ -159,95 +159,176 @@ class SAMUtils {
     	return d;
     }
 
+    public static final String COMMAND = "\"\"COMMAND\"\"";
+    public static final String OPCODE = "\"\"OPCODE\"\"";
+
     /**
-     * Parse SAM parameters, and put them into a Propetries object
+     *  Parse SAM parameters, and put them into a Propetries object
+     *
+     *  Modified from EepGet.
+     *  All keys, major, and minor are mapped to upper case.
+     *  Double quotes around values are stripped.
+     *
+     *  Possible input:
+     *<pre>
+     *  COMMAND
+     *  COMMAND OPCODE
+     *  COMMAND OPCODE [key=val]...
+     *  COMMAND OPCODE [key=" val with spaces "]...
+     *  PING
+     *  PONG
+     *  PING any   thing goes
+     *  PONG any   thing   goes
      *
-     * @param tok A StringTokenizer pointing to the SAM parameters
+     *  No escaping of '"' or anything else is allowed or defined
+     *  No spaces before or after '=' allowed
+     *  Keys may not be quoted
+     *  COMMAND and OPCODE may not have '='
+     *  Duplicate keys not allowed
+     *</pre>
      *
-     * @throws SAMException if the data was formatted incorrectly
-     * @return Properties with the parsed SAM params, never null
+     *  A key without a value is not allowed by the spec, but is
+     *  returned with the value "true".
+     *
+     *  COMMAND is returned as the value of the key ""COMMAND"".
+     *  OPCODE, or the remainder of the PING/PONG line if any, is returned as the value of the key ""OPCODE"".
+     *
+     *  @param args non-null
+     *  @throws SAMException on some errors but not all
+     *  @return non-null, may be empty. Does not throw on missing COMMAND or OPCODE; caller must check.
      */
-    public static Properties parseParams(StringTokenizer tok) throws SAMException {
-        int ntoks = tok.countTokens();
-        Properties props = new Properties();
-        
-        StringBuilder value = new StringBuilder();
-        for (int i = 0; i < ntoks; ++i) {
-            String token = tok.nextToken();
-
-            int pos = token.indexOf("=");
-            if (pos <= 0) {
-                //_log.debug("Error in params format");
-                if (pos == 0) {
-                    throw new SAMException("No param specified [" + token + "]");
-                } else {
-                    throw new SAMException("Bad formatting for param [" + token + "]");
-                }
-            }
-            
-            String param = token.substring(0, pos);
-            value.append(token.substring(pos+1));
-            if (value.length() == 0)
-                throw new SAMException("Empty value for param " + param);
-            
-            // FIXME: The following code does not take into account that there
-            // may have been multiple subsequent space chars in the input that
-            // StringTokenizer treates as one.
-            if (value.charAt(0) == '"') {
-                while ( (i < ntoks) && (value.lastIndexOf("\"") <= 0) ) {
-                    value.append(' ').append(tok.nextToken());
-                    i++;
-                }
-            }
+    public static Properties parseParams(String args) throws SAMException {
+        final Properties rv = new Properties();
+        final StringBuilder buf = new StringBuilder(32);
+        final int length = args.length();
+        boolean isQuoted = false;
+        String key = null;
+        // We go one past the end to force a fake trailing space
+        // to make things easier, so we don't need cleanup at the end
+        for (int i = 0; i <= length; i++) {
+            char c = (i < length) ? args.charAt(i) : ' ';
+            switch (c) {
+                case '"':
+                    if (isQuoted) {
+                        // keys never quoted
+                        if (key != null) {
+                            if (rv.setProperty(key, buf.length() > 0 ? buf.toString() : "true") != null)
+                                throw new SAMException("Duplicate parameter " + key);
+                            key = null;
+                        }
+                        buf.setLength(0);
+                    }
+                    isQuoted = !isQuoted;
+                    break;
 
-            props.setProperty(param, value.toString());
-            value.setLength(0);
-        }
+                case '\r':
+                case '\n':
+                    break;
 
-        //if (_log.shouldLog(Log.DEBUG)) {
-        //    _log.debug("Parsed properties: " + dumpProperties(props));
-        //}
+                case ' ':
+                case '\b':
+                case '\f':
+                case '\t':
+                    // whitespace - if we're in a quoted section, keep this as part of the quote,
+                    // otherwise use it as a delim
+                    if (isQuoted) {
+                        buf.append(c);
+                    } else {
+                        if (key != null) {
+                            if (rv.setProperty(key, buf.length() > 0 ? buf.toString() : "true") != null)
+                                throw new SAMException("Duplicate parameter " + key);
+                            key = null;
+                        } else if (buf.length() > 0) {
+                            // key without value
+                            String k = buf.toString().trim().toUpperCase(Locale.US);
+                            if (rv.isEmpty()) {
+                                rv.setProperty(COMMAND, k);
+                                if (k.equals("PING") || k.equals("PONG")) {
+                                    // eat the rest of the line
+                                    if (i + 1 < args.length()) {
+                                        String pingData = args.substring(i + 1);
+                                        rv.setProperty(OPCODE, pingData);
+                                    }
+                                    // this will force an end of the loop
+                                    i = length + 1;
+                                }
+                            } else if (rv.size() == 1) {
+                                rv.setProperty(OPCODE, k);
+                            } else {
+                                if (rv.setProperty(k, "true") != null)
+                                    throw new SAMException("Duplicate parameter " + k);
+                            }
+                        }
+                        buf.setLength(0);
+                    }
+                    break;
 
-        return props;
-    }
+                case '=':
+                    if (isQuoted) {
+                        buf.append(c);
+                    } else {
+                        if (buf.length() == 0)
+                            throw new SAMException("Empty parameter name");
+                        key = buf.toString().toUpperCase(Locale.US);
+                        buf.setLength(0);
+                    }
+                    break;
 
-    /* Dump a Properties object in an human-readable form */
-/****
-    private static String dumpProperties(Properties props) {
-        StringBuilder builder = new StringBuilder();
-        String key, val;
-        boolean firstIter = true;
-        
-        for (Map.Entry<Object, Object> entry : props.entrySet()) {
-            key = (String) entry.getKey();
-            val = (String) entry.getValue();
-            
-            if (!firstIter) {
-                builder.append(";");
-            } else {
-                firstIter = false;
+                default:
+                    buf.append(c);
+                    break;
             }
-            builder.append(" \"" + key + "\" -> \"" + val + "\"");
         }
-        
-        return builder.toString();
+        // nothing needed here, as we forced a trailing space in the loop
+        // unterminated quoted content will be lost
+        if (isQuoted)
+            throw new SAMException("Unterminated quote");
+        return rv;
     }
-****/
-    
+
 /****
     public static void main(String args[]) {
         try {
             test("a=b c=d e=\"f g h\"");
             test("a=\"b c d\" e=\"f g h\" i=\"j\"");
             test("a=\"b c d\" e=f i=\"j\"");
+            if (args.length == 0) {
+                System.out.println("Usage: CommandParser file || CommandParser text to parse");
+                return;
+            }
+            if (args.length > 1 || !(new java.io.File(args[0])).exists()) {
+                StringBuilder buf = new StringBuilder(128);
+                for (int i = 0; i < args.length; i++) {
+                    if (i != 0)
+                        buf.append(' ');
+                    buf.append(args[i]);
+                }
+                test(buf.toString());
+            } else {
+                java.io.InputStream in = new java.io.FileInputStream(args[0]);
+                String line;
+                while ((line = net.i2p.data.DataHelper.readLine(in)) != null) {
+                    try {
+                        test(line);
+                    } catch (Exception e) {
+                        e.printStackTrace();
+                    }
+                }
+            }
         } catch (Exception e) {
             e.printStackTrace();
         }
     }
+
     private static void test(String props) throws Exception {
-        StringTokenizer tok = new StringTokenizer(props);
-        Properties p = parseParams(tok);
-        System.out.println(p);
+        System.out.println("Testing: " + props);
+        Properties m = parseParams(props);
+        System.out.println("Found " + m.size() + " keys");
+        for (Map.Entry e : m.entrySet()) {
+            System.out.println(e.getKey() + "=[" + e.getValue() + ']');
+        }
+        System.out.println("-------------");
     }
 ****/
 }
+
diff --git a/apps/sam/java/src/net/i2p/sam/SAMv1Handler.java b/apps/sam/java/src/net/i2p/sam/SAMv1Handler.java
index 5b594701686bea2de347ec5dba1471d466117c4e..5eb23544861bf5c8658e565743d1d16d137341d4 100644
--- a/apps/sam/java/src/net/i2p/sam/SAMv1Handler.java
+++ b/apps/sam/java/src/net/i2p/sam/SAMv1Handler.java
@@ -18,7 +18,6 @@ import java.net.NoRouteToHostException;
 import java.nio.channels.SocketChannel;
 import java.nio.ByteBuffer;
 import java.util.Properties;
-import java.util.StringTokenizer;
 import java.util.concurrent.atomic.AtomicLong;
 
 import net.i2p.I2PException;
@@ -98,7 +97,6 @@ class SAMv1Handler extends SAMHandler implements SAMRawReceiver, SAMDatagramRece
         String domain = null;
         String opcode = null;
         boolean canContinue = false;
-        StringTokenizer tok;
         Properties props;
 
         this.thread.setName("SAMv1Handler " + _id);
@@ -132,32 +130,29 @@ class SAMv1Handler extends SAMHandler implements SAMRawReceiver, SAMDatagramRece
                     _log.info("Connection closed by client (line read : null)");
                     break;
                 }
-                msg = msg.trim();
 
                 if (_log.shouldLog(Log.DEBUG)) {
                     _log.debug("New message received: [" + msg + "]");
                 }
-
-                if(msg.equals("")) {
+                props = SAMUtils.parseParams(msg);
+                domain = props.getProperty(SAMUtils.COMMAND);
+                if (domain == null) {
                     if (_log.shouldLog(Log.DEBUG))
                         _log.debug("Ignoring newline");
                     continue;
                 }
-
-                tok = new StringTokenizer(msg, " ");
-                if (tok.countTokens() < 2) {
-                    // This is not a correct message, for sure
+                opcode = props.getProperty(SAMUtils.OPCODE);
+                if (opcode == null) {
                     if (_log.shouldLog(Log.DEBUG))
                         _log.debug("Error in message format");
                     break;
                 }
-                domain = tok.nextToken();
-                opcode = tok.nextToken();
+                props.remove(SAMUtils.COMMAND);
+                props.remove(SAMUtils.OPCODE);
                 if (_log.shouldLog(Log.DEBUG)) {
                     _log.debug("Parsing (domain: \"" + domain
                                + "\"; opcode: \"" + opcode + "\")");
                 }
-                props = SAMUtils.parseParams(tok);
 
                 if (domain.equals("STREAM")) {
                     canContinue = execStreamMessage(opcode, props);
diff --git a/apps/sam/java/src/net/i2p/sam/SAMv3DatagramServer.java b/apps/sam/java/src/net/i2p/sam/SAMv3DatagramServer.java
index a482318fa66b69cea6d94e28b179747747eeb81e..a2395e4df155714bf685b80915941a4d7228ed65 100644
--- a/apps/sam/java/src/net/i2p/sam/SAMv3DatagramServer.java
+++ b/apps/sam/java/src/net/i2p/sam/SAMv3DatagramServer.java
@@ -135,6 +135,7 @@ class SAMv3DatagramServer implements Handler {
 		public void run() {
 			try {
 				String header = DataHelper.readLine(is).trim();
+				// we cannot use SAMUtils.parseParams() here
 				StringTokenizer tok = new StringTokenizer(header, " ");
 				if (tok.countTokens() < 3) {
 					// This is not a correct message, for sure
diff --git a/apps/sam/java/src/net/i2p/sam/SAMv3Handler.java b/apps/sam/java/src/net/i2p/sam/SAMv3Handler.java
index 9cc4a500c752a710125ec09d1db5d3ab601e3fed..5d7bff33f312e99f3a1bca37be617b543cf757fa 100644
--- a/apps/sam/java/src/net/i2p/sam/SAMv3Handler.java
+++ b/apps/sam/java/src/net/i2p/sam/SAMv3Handler.java
@@ -24,7 +24,6 @@ import java.nio.channels.SocketChannel;
 import java.nio.ByteBuffer;
 import java.util.Properties;
 import java.util.HashMap;
-import java.util.StringTokenizer;
 
 import net.i2p.I2PAppContext;
 import net.i2p.I2PException;
@@ -262,7 +261,6 @@ class SAMv3Handler extends SAMv1Handler
 		String domain = null;
 		String opcode = null;
 		boolean canContinue = false;
-		StringTokenizer tok;
 		Properties props;
 
 		this.thread.setName("SAMv3Handler " + _id);
@@ -341,53 +339,46 @@ class SAMv3Handler extends SAMv1Handler
 						_log.debug("Connection closed by client (line read : null)");
 					break;
 				}
-				msg = line.trim();
 
 				if (_log.shouldLog(Log.DEBUG)) {
 					if (_log.shouldLog(Log.DEBUG))
 						_log.debug("New message received: [" + msg + "]");
 				}
-
-				if(msg.equals("")) {
+				props = SAMUtils.parseParams(line);
+				domain = props.getProperty(SAMUtils.COMMAND);
+				if (domain == null) {
 					if (_log.shouldLog(Log.DEBUG))
 						_log.debug("Ignoring newline");
 					continue;
 				}
-
-				tok = new StringTokenizer(msg, " ");
-				int count = tok.countTokens();
-				if (count <= 0) {
-					// This is not a correct message, for sure
-					if (_log.shouldLog(Log.DEBUG))
-						_log.debug("Ignoring whitespace");
-					continue;
+				opcode = props.getProperty(SAMUtils.OPCODE);
+				props.remove(SAMUtils.COMMAND);
+				props.remove(SAMUtils.OPCODE);
+				if (_log.shouldLog(Log.DEBUG)) {
+					_log.debug("Parsing (domain: \"" + domain
+							+ "\"; opcode: \"" + opcode + "\")");
 				}
-				domain = tok.nextToken();
+
 				// these may not have a second token
 				if (domain.equals("PING")) {
-					execPingMessage(tok);
+					execPingMessage(opcode);
 					continue;
 				} else if (domain.equals("PONG")) {
-					execPongMessage(tok);
+					execPongMessage(opcode);
 					continue;
 				} else if (domain.equals("QUIT") || domain.equals("STOP") ||
 				           domain.equals("EXIT")) {
 					writeString(domain + " STATUS RESULT=OK MESSAGE=bye\n");
 					break;
 				}
-				if (count <= 1) {
+
+				if (opcode == null) {
 					// This is not a correct message, for sure
 					if (writeString(domain + " STATUS RESULT=I2P_ERROR MESSAGE=\"command not specified\"\n"))
 						continue;
 					else
 						break;
 				}
-				opcode = tok.nextToken();
-				if (_log.shouldLog(Log.DEBUG)) {
-					_log.debug("Parsing (domain: \"" + domain
-							+ "\"; opcode: \"" + opcode + "\")");
-				}
-				props = SAMUtils.parseParams(tok);
 
 				if (domain.equals("STREAM")) {
 					canContinue = execStreamMessage(opcode, props);
@@ -909,13 +900,15 @@ class SAMv3Handler extends SAMv1Handler
 	/**
 	 * Handle a PING.
 	 * Send a PONG.
+	 *
+	 * @param msg to append, may be null
 	 * @since 0.9.24
 	 */
-	private void execPingMessage(StringTokenizer tok) {
+	private void execPingMessage(String msg) {
 		StringBuilder buf = new StringBuilder();
 		buf.append("PONG");
-		while (tok.hasMoreTokens()) {
-			buf.append(' ').append(tok.nextToken());
+		if (msg != null) {
+			buf.append(' ').append(msg);
 		}
 		buf.append('\n');
 		writeString(buf.toString());
@@ -923,13 +916,12 @@ class SAMv3Handler extends SAMv1Handler
 
 	/**
 	 * Handle a PONG.
+	 *
+	 * @param s received, may be null
 	 * @since 0.9.24
 	 */
-	private void execPongMessage(StringTokenizer tok) {
-		String s;
-		if (tok.hasMoreTokens()) {
-			s = tok.nextToken();
-		} else {
+	private void execPongMessage(String s) {
+		if (s == null) {
 			s = "";
 		}
 		if (_lastPing > 0) {