diff --git a/apps/i2ptunnel/java/src/net/i2p/i2ptunnel/I2PTunnelHTTPClientBase.java b/apps/i2ptunnel/java/src/net/i2p/i2ptunnel/I2PTunnelHTTPClientBase.java index 2ae58a39844e5db7eb7ea899867807f3897e35c7..9abcdd0fd61162a12d0b044db7a6aba9d60de0e9 100644 --- a/apps/i2ptunnel/java/src/net/i2p/i2ptunnel/I2PTunnelHTTPClientBase.java +++ b/apps/i2ptunnel/java/src/net/i2p/i2ptunnel/I2PTunnelHTTPClientBase.java @@ -24,6 +24,7 @@ import net.i2p.I2PAppContext; import net.i2p.client.streaming.I2PSocketManager; import net.i2p.data.Base64; import net.i2p.data.DataHelper; +import net.i2p.util.EepGet; import net.i2p.util.EventDispatcher; import net.i2p.util.InternalSocket; import net.i2p.util.Log; @@ -409,60 +410,8 @@ public abstract class I2PTunnelHTTPClientBase extends I2PTunnelClientBase implem * @since 0.9.4 */ private static Map<String, String> parseArgs(String args) { - Map<String, String> rv = new HashMap<String, String>(8); - char data[] = args.toCharArray(); - StringBuilder buf = new StringBuilder(32); - boolean isQuoted = false; - String key = null; - for (int i = 0; i < data.length; i++) { - switch (data[i]) { - case '\"': - if (isQuoted) { - // keys never quoted - if (key != null) { - rv.put(key, buf.toString().trim()); - key = null; - } - buf.setLength(0); - } - isQuoted = !isQuoted; - break; - - case ' ': - case '\r': - case '\n': - case '\t': - case ',': - // 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(data[i]); - } else { - if (key != null) { - rv.put(key, buf.toString().trim()); - key = null; - } - buf.setLength(0); - } - break; - - case '=': - if (isQuoted) { - buf.append(data[i]); - } else { - key = buf.toString().trim().toLowerCase(Locale.US); - buf.setLength(0); - } - break; - - default: - buf.append(data[i]); - break; - } - } - if (key != null) - rv.put(key, buf.toString().trim()); - return rv; + // moved to EepGet, since it needs this too + return EepGet.parseAuthArgs(args); } //////// Error page stuff diff --git a/core/java/src/net/i2p/util/EepGet.java b/core/java/src/net/i2p/util/EepGet.java index 72f6391acc1f09b9159b99058274a3a9cb60e6b2..1a6e23a011fe84a73823de2263cc44ef6e3a3056 100644 --- a/core/java/src/net/i2p/util/EepGet.java +++ b/core/java/src/net/i2p/util/EepGet.java @@ -19,12 +19,15 @@ import java.text.DecimalFormat; import java.util.ArrayList; import java.util.Date; import java.util.Formatter; +import java.util.HashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import gnu.getopt.Getopt; import net.i2p.I2PAppContext; +import net.i2p.data.Base32; import net.i2p.data.Base64; import net.i2p.data.ByteArray; import net.i2p.data.DataHelper; @@ -87,6 +90,10 @@ public class EepGet { protected boolean _isGzippedResponse; protected IOException _decompressException; + // following for proxy digest auth + // only created via addAuthorization() + private AuthState _authState; + /** this will be replaced by the HTTP Proxy if we are using it */ protected static final String USER_AGENT = "Wget/1.11.4"; protected static final long CONNECT_TIMEOUT = 45*1000; @@ -662,6 +669,7 @@ public class EepGet { } if (_redirectLocation != null) { + // we also are here after a 407 //try { if (_redirectLocation.startsWith("http://")) { _actualURL = _redirectLocation; @@ -680,10 +688,27 @@ public class EepGet { //} catch (MalformedURLException mue) { // throw new IOException("Redirected from an invalid URL"); //} - _redirects++; - if (_redirects > 5) - throw new IOException("Too many redirects: to " + _redirectLocation); - if (_log.shouldLog(Log.INFO)) _log.info("Redirecting to " + _redirectLocation); + + AuthState as = _authState; + if (_responseCode == 407) { + if (!_shouldProxy) + throw new IOException("Proxy auth response from non-proxy"); + if (as == null) + throw new IOException("Proxy requires authentication"); + if (as.authSent) + throw new IOException("Proxy authentication failed"); // ignore stale + if (as.authChallenge == null) + throw new IOException("Bad proxy auth response"); + if (_log.shouldLog(Log.INFO)) _log.info("Adding auth"); + // actually happens in getRequest() + } else { + _redirects++; + if (_redirects > 5) + throw new IOException("Too many redirects: to " + _redirectLocation); + if (_log.shouldLog(Log.INFO)) _log.info("Redirecting to " + _redirectLocation); + if (as != null) + as.authSent = false; + } // reset some important variables, we don't want to save the values from the redirect _bytesRemaining = -1; @@ -871,6 +896,7 @@ public class EepGet { _keepFetching = false; _notModified = true; return; + case 401: // server auth case 403: // bad req case 404: // not found case 409: // bad addr helper @@ -889,6 +915,17 @@ public class EepGet { _out = new FileOutputStream(_outputFile, true); } break; + case 407: // proxy auth + // we will treat this is a redirect if we haven't sent auth yet + //_redirectLocation will be set to _actualURL below + _alreadyTransferred = 0; + if (_authState != null) + rcOk = !_authState.authSent; + else + rcOk = false; + redirect = rcOk; + _keepFetching = rcOk; + break; case 416: // completed (or range out of reach) _bytesRemaining = 0; if (_alreadyTransferred > 0 || !_shouldWriteErrorToOutput) { @@ -970,11 +1007,14 @@ public class EepGet { increment(lookahead, cur); if (isEndOfHeaders(lookahead)) { if (!rcOk) - throw new IOException("Invalid HTTP response code: " + _responseCode); + throw new IOException("Invalid HTTP response code: " + _responseCode + ' ' + _responseText); if (_encodingChunked) { _bytesRemaining = readChunkLength(); } - if (!redirect) _redirectLocation = null; + if (!redirect) + _redirectLocation = null; + else if (_responseCode == 407) + _redirectLocation = _actualURL; return; } break; @@ -1081,6 +1121,8 @@ public class EepGet { _contentType=val; } else if (key.equals("location")) { _redirectLocation=val; + } else if (key.equals("proxy-authenticate") && _responseCode == 407 && _authState != null && _shouldProxy) { + _authState.setAuthChallenge(val); } else { // ignore the rest } @@ -1192,10 +1234,11 @@ public class EepGet { urlToSend += '?' + query; } if (post) { - buf.append("POST ").append(urlToSend).append(" HTTP/1.1\r\n"); + buf.append("POST "); } else { - buf.append("GET ").append(urlToSend).append(" HTTP/1.1\r\n"); + buf.append("GET "); } + buf.append(urlToSend).append(" HTTP/1.1\r\n"); // RFC 2616 sec 5.1.2 - host + port (NOT authority, which includes userinfo) buf.append("Host: ").append(host); if (port >= 0) @@ -1240,6 +1283,12 @@ public class EepGet { } if(!uaOverridden) buf.append("User-Agent: " + USER_AGENT + "\r\n"); + if (_authState != null && _shouldProxy && _authState.authMode != AUTH_MODE.NONE) { + buf.append("Proxy-Authorization: "); + String method = post ? "POST" : "GET"; + buf.append(_authState.getAuthHeader(method, urlToSend)); + buf.append("\r\n"); + } buf.append("Connection: close\r\n\r\n"); if (post) buf.append(_postData); @@ -1335,9 +1384,240 @@ public class EepGet { * @since 0.8.9 */ public void addAuthorization(String userName, String password) { - if (_shouldProxy) - addHeader("Proxy-Authorization", - "Basic " + Base64.encode(DataHelper.getUTF8(userName + ':' + password), true)); // true = use standard alphabet + if (_shouldProxy) { + // Could only do this for Basic + // Now we always wait for the 407, in the hope we can use Digest + //addHeader("Proxy-Authorization", + // "Basic " + Base64.encode(DataHelper.getUTF8(userName + ':' + password), true)); // true = use standard alphabet + if (_authState != null) + throw new IllegalStateException(); + _authState = new AuthState(userName, password); + } + } + + /** + * Parse the args in an authentication header. + * + * Modified from LoadClientAppsJob. + * All keys are mapped to lower case. + * Double quotes around values are stripped. + * Ref: RFC 2617 + * + * Public for I2PTunnelHTTPClientBase; use outside of tree at own risk, subject to change or removal + * + * @param args non-null, starting after "Digest " or "Basic " + * @since 0.9.4, moved from I2PTunnelHTTPClientBase in 0.9.12 + */ + public static Map<String, String> parseAuthArgs(String args) { + Map<String, String> rv = new HashMap<String, String>(8); + char data[] = args.toCharArray(); + StringBuilder buf = new StringBuilder(32); + boolean isQuoted = false; + String key = null; + for (int i = 0; i < data.length; i++) { + switch (data[i]) { + case '\"': + if (isQuoted) { + // keys never quoted + if (key != null) { + rv.put(key, buf.toString().trim()); + key = null; + } + buf.setLength(0); + } + isQuoted = !isQuoted; + break; + + case ' ': + case '\r': + case '\n': + case '\t': + case ',': + // 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(data[i]); + } else { + if (key != null) { + rv.put(key, buf.toString().trim()); + key = null; + } + buf.setLength(0); + } + break; + + case '=': + if (isQuoted) { + buf.append(data[i]); + } else { + key = buf.toString().trim().toLowerCase(Locale.US); + buf.setLength(0); + } + break; + + default: + buf.append(data[i]); + break; + } + } + if (key != null) + rv.put(key, buf.toString().trim()); + return rv; + } + + + /** + * @since 0.9.12 + */ + private enum AUTH_MODE {NONE, BASIC, DIGEST, UNKNOWN} + + /** + * Manage the authentication parameters + * Ref: RFC 2617 + * Supports both Basic and Digest, however i2ptunnel HTTP proxy + * has migrated all previous Basic support to Digest. + * + * @since 0.9.12 + */ + private class AuthState { + private final String username; + private final String password; + // as recvd in 407 + public AUTH_MODE authMode = AUTH_MODE.NONE; + // as recvd in 407, after the mode string + private String authChallenge; + public boolean authSent; + private int nonceCount; + private String cnonce; + // as parsed from authChallenge + private Map<String, String> args; + + public AuthState(String user, String pw) { + username = user; + password = pw; + } + + /** + * May be called multiple times, save the best one + */ + public void setAuthChallenge(String auth) { + String authLC = auth.toLowerCase(Locale.US); + if (authLC.startsWith("basic ")) { + // better than anything but DIGEST + if (authMode != AUTH_MODE.DIGEST) { + // use standard alphabet + authMode = AUTH_MODE.BASIC; + authChallenge = auth.substring(6); + } + } else if (authLC.startsWith("digest ")) { + // better than anything + authMode = AUTH_MODE.DIGEST; + authChallenge = auth.substring(7); + } else { + // better than NONE only + if (authMode == AUTH_MODE.NONE) { + authMode = AUTH_MODE.UNKNOWN; + authChallenge = ""; + } + } + nonceCount = 0; + args = null; + } + + public String getAuthHeader(String method, String uri) throws IOException { + switch (authMode) { + case BASIC: + authSent = true; + // use standard alphabet + return "Basic " + + Base64.encode(DataHelper.getUTF8(username + ':' + password), true); + + case DIGEST: + if (args == null) + args = parseAuthArgs(authChallenge); + Map<String, String> outArgs = generateAuthArgs(method, uri); + if (outArgs == null) + throw new IOException("Bad proxy auth response"); + StringBuilder buf = new StringBuilder(256); + buf.append("Digest"); + for (Map.Entry<String, String> e : outArgs.entrySet()) { + buf.append(' ').append(e.getKey()).append('=').append(e.getValue()); + } + authSent = true; + return buf.toString(); + + default: + throw new IOException("Unknown proxy auth type " + authChallenge); + } + } + + /** + * Generate the digest authentication parameters + * Ref: RFC 2617 + * + * @since 0.9.12 modified from I2PTunnelHTTPClientBase.validateDigest() + */ + public Map<String, String> generateAuthArgs(String method, String uri) throws IOException { + Map<String, String> rv = new HashMap<String, String>(12); + String realm = args.get("realm"); + String nonce = args.get("nonce"); + String qop = args.get("qop"); + String opaque = args.get("opaque"); + //String algorithm = args.get("algorithm"); + //String stale = args.get("stale"); + if (realm == null || nonce == null) { + if (_log.shouldLog(Log.INFO)) + _log.info("Bad digest request: " + DataHelper.toString(args)); + throw new IOException("Bad auth response"); + } + rv.put("username", '"' + username + '"'); + rv.put("realm", '"' + realm + '"'); + rv.put("nonce", '"' + nonce + '"'); + rv.put("uri", '"' + uri + '"'); + if (opaque != null) + rv.put("opaque", '"' + opaque + '"'); + String kdMiddle; + if ("auth".equals(qop)) { + rv.put("qop", "\"auth\""); + if (cnonce == null) { + byte[] rand = new byte[5]; + _context.random().nextBytes(rand); + cnonce = Base32.encode(rand); + } // else reuse on redirect + rv.put("cnonce", '"' + cnonce + '"'); + String nc = lc8hex(++nonceCount); + rv.put("nc", nc); + kdMiddle = ':' + nc + ':' + cnonce + ':' + qop; + } else { + kdMiddle = ""; + } + + // get H(A1) + String ha1 = PasswordManager.md5Hex(username + ':' + realm + ':' + password); + // get H(A2) + String a2 = method + ':' + uri; + String ha2 = PasswordManager.md5Hex(a2); + // response + String kd = ha1 + ':' + nonce + kdMiddle + ':' + ha2; + rv.put("response", '"' + PasswordManager.md5Hex(kd) + '"'); + return rv; + } + } + + /** + * @return 8 hex chars, lower case, e.g. 00000001 + * @since 0.8.10 + */ + private static String lc8hex(int nc) { + StringBuilder buf = new StringBuilder(8); + for (int i = 28; i >= 0; i -= 4) { + int v = (nc >> i) & 0xf; + if (v < 10) + buf.append((char) (v + '0')); + else + buf.append((char) (v + 'a' - 10)); + } + return buf.toString(); } /**