diff --git a/apps/jetty/java/src/net/i2p/jetty/MultiAuthenticator.java b/apps/jetty/java/src/net/i2p/jetty/MultiAuthenticator.java new file mode 100644 index 000000000..d1c148867 --- /dev/null +++ b/apps/jetty/java/src/net/i2p/jetty/MultiAuthenticator.java @@ -0,0 +1,539 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// ------------------------------------------------------------------------ +// All rights reserved. This program and the accompanying materials +// are made available under the terms of the Eclipse Public License v1.0 +// and Apache License v2.0 which accompanies this distribution. +// +// The Eclipse Public License is available at +// http://www.eclipse.org/legal/epl-v10.html +// +// The Apache License v2.0 is available at +// http://www.opensource.org/licenses/apache2.0.php +// +// You may elect to redistribute this code under either of these licenses. +// ======================================================================== +// + +package net.i2p.jetty; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.BitSet; +import java.util.Objects; +import java.util.Queue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ConcurrentMap; + +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.security.SecurityHandler; +import org.eclipse.jetty.security.ServerAuthException; +import org.eclipse.jetty.security.UserAuthentication; +import org.eclipse.jetty.security.authentication.DeferredAuthentication; +import org.eclipse.jetty.security.authentication.LoginAuthenticator; +import org.eclipse.jetty.server.Authentication; +import org.eclipse.jetty.server.Authentication.User; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.UserIdentity; +import org.eclipse.jetty.util.B64Code; +import org.eclipse.jetty.util.QuotedStringTokenizer; +import org.eclipse.jetty.util.TypeUtil; +import org.eclipse.jetty.util.log.Log; +import org.eclipse.jetty.util.log.Logger; +import org.eclipse.jetty.util.security.Constraint; +import org.eclipse.jetty.util.security.Credential; + +import net.i2p.I2PAppContext; + +/** + * We'd like to extend DigestAuthenticator but the + * Digest inner class is private, so this is a wholesale copy + * to add multi-scheme support, and some other minor changes. + * + * This supports both Digest and Basic schemes, + * and both SHA-256 and MD5 Digest algorithms, + * all in one. + * Ref: RFC 7616, RFC 2617. + * + * Jetty does not support multiple auth at once + * https://github.com/jetty/jetty.project/issues/5442 + * but it's coming for Jetty 12.1.0 + * https://github.com/jetty/jetty.project/pull/12393 + * However, it's about different auth on different resources? + * Not multiple auth for the same resource. + * + * Any scheme must be compatible with what browsers support, see + * RFC 7616. We extend Jetty DigestAuthenticator to support + * SHA-256 while still supporting MD5. + * Firefox supports SHA-256 as of FF93 (2021) + * Chrome supports it as of Chrome 117 (2023) + * See chart at bottom of https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/WWW-Authenticate + * But Jetty still claiming there's no support in 2024 + * and refuses to implement it: https://github.com/jetty/jetty.project/issues/11489 + * SHA256 is still NOT supported by Safari / IOS. + * + * Some of this duplicates code we have in I2PTunnelHTTPClientBase, + * and could perhaps be consolidated. + * + * See also SHA256Credential and MultiCredential. + * + * @since 0.9.67 + */ +public class MultiAuthenticator extends LoginAuthenticator +{ + private static final Logger LOG = Log.getLogger(MultiAuthenticator.class); + + private final I2PAppContext _context; + private long _maxNonceAgeMs = 60 * 1000; + private int _maxNC = 1024; + private final ConcurrentMap _nonceMap = new ConcurrentHashMap<>(); + private final Queue _nonceQueue = new ConcurrentLinkedQueue<>(); + private final boolean _enableSHA256, _enableMD5, _enableBasic; + + public MultiAuthenticator() { + this(true, true, true); + } + + public MultiAuthenticator(boolean enableSHA256, boolean enableMD5, boolean enableBasic) + { + _context = I2PAppContext.getGlobalContext(); + _enableSHA256 = enableSHA256; + _enableMD5 = enableMD5; + _enableBasic = enableBasic; + } + + @Override + public void setConfiguration(AuthConfiguration configuration) + { + super.setConfiguration(configuration); + + String mna = configuration.getInitParameter("maxNonceAge"); + if (mna != null) + setMaxNonceAge(Long.valueOf(mna)); + String mnc = configuration.getInitParameter("maxNonceCount"); + if (mnc != null) + setMaxNonceCount(Integer.valueOf(mnc)); + } + + public int getMaxNonceCount() + { + return _maxNC; + } + + public void setMaxNonceCount(int maxNC) + { + _maxNC = maxNC; + } + + public long getMaxNonceAge() + { + return _maxNonceAgeMs; + } + + public void setMaxNonceAge(long maxNonceAgeInMillis) + { + _maxNonceAgeMs = maxNonceAgeInMillis; + } + + @Override + public String getAuthMethod() + { + return Constraint.__DIGEST_AUTH; + } + + @Override + public boolean secureResponse(ServletRequest req, ServletResponse res, boolean mandatory, User validatedUser) throws ServerAuthException + { + return true; + } + + @Override + public Authentication validateRequest(ServletRequest req, ServletResponse res, boolean mandatory) throws ServerAuthException + { + if (!mandatory) + return new DeferredAuthentication(this); + + HttpServletRequest request = (HttpServletRequest)req; + HttpServletResponse response = (HttpServletResponse)res; + String credentials = request.getHeader(HttpHeader.AUTHORIZATION.asString()); + + try + { + boolean stale = false; + if (credentials != null) + { + if (LOG.isDebugEnabled()) + LOG.debug("Credentials: " + credentials); + QuotedStringTokenizer tokenizer = new QuotedStringTokenizer(credentials, "=, ", true, false); + Digest digest = null; + String last = null; + String name = null; + String scheme = null; + String credential = null; + + while (tokenizer.hasMoreTokens()) + { + String tok = tokenizer.nextToken(); + + // setup based on scheme + if (scheme == null) { + scheme = tok.toLowerCase(); + continue; + } + if (scheme.equals("digest")) { + if (digest == null) { + digest = new Digest(_context, request.getMethod()); + } + } else if (scheme.equals("basic")) { + // collect the space, then the credential, then break + if (tok.equals(" ")) { + // skip the space + continue; + } + credential = tok; + // processed below + break; + } else { + // unknown scheme + break; + } + + char c = (tok.length() == 1) ? tok.charAt(0) : '\0'; + + switch (c) + { + case '=': + name = last; + last = tok; + break; + case ',': + name = null; + break; + case ' ': + break; + + default: + last = tok; + if (name != null) + { + if ("username".equalsIgnoreCase(name)) + digest.username = tok; + else if ("realm".equalsIgnoreCase(name)) + digest.realm = tok; + else if ("nonce".equalsIgnoreCase(name)) + digest.nonce = tok; + else if ("nc".equalsIgnoreCase(name)) + digest.nc = tok; + else if ("cnonce".equalsIgnoreCase(name)) + digest.cnonce = tok; + else if ("qop".equalsIgnoreCase(name)) + digest.qop = tok; + else if ("uri".equalsIgnoreCase(name)) + digest.uri = tok; + else if ("response".equalsIgnoreCase(name)) + digest.response = tok; + else if ("algorithm".equalsIgnoreCase(name)) + digest.algorithm = tok.toLowerCase(); + name = null; + } + } + } + + // Now validate based on scheme type + if ("digest".equals(scheme) && + ((_enableSHA256 && digest.algorithm.equals("sha-256") || + (_enableMD5 && digest.algorithm.equals("md5"))))) { + int n = checkNonce(digest, (Request)request); + + if (n > 0) + { + UserIdentity user = login(digest.username, digest, req); + if (user != null) + { + return new UserAuthentication(Constraint.__DIGEST_AUTH, user); + } + } + else if (n == 0) + stale = true; + } else if (_enableBasic && "basic".equals(scheme)) { + if (credential != null) { + credential = B64Code.decode(credential, StandardCharsets.ISO_8859_1); + int i = credential.indexOf(':'); + if (i>0) + { + String username = credential.substring(0,i); + String password = credential.substring(i+1); + + UserIdentity user = login(username, password, request); + if (user!=null) + { + return new UserAuthentication(Constraint.__BASIC_AUTH, user); + } + } + } + } else { + LOG.warn("unsupported auth scheme " + scheme); + } + } + + if (!DeferredAuthentication.isDeferred(response)) + { + String domain = request.getContextPath(); + if (domain == null) + domain = "/"; + String nonce = newNonce((Request) request); + String realm = _loginService.getName(); + // RFC 7616 preferred first + if (_enableSHA256) { + response.addHeader(HttpHeader.WWW_AUTHENTICATE.asString(), "Digest realm=\"" + realm + + "\", domain=\"" + + domain + + "\", nonce=\"" + + nonce + + "\", algorithm=SHA-256, qop=\"auth\"," + + " stale=" + stale); + } + if (_enableMD5) { + response.addHeader(HttpHeader.WWW_AUTHENTICATE.asString(), "Digest realm=\"" + realm + + "\", domain=\"" + + domain + + "\", nonce=\"" + + nonce + + "\", algorithm=MD5, qop=\"auth\"," + + " stale=" + stale); + } + if (_enableBasic) { + response.addHeader(HttpHeader.WWW_AUTHENTICATE.asString(), "Basic realm=\"" + realm + '"'); + } + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + + return Authentication.SEND_CONTINUE; + } + + return Authentication.UNAUTHENTICATED; + } + catch (IOException e) + { + throw new ServerAuthException(e); + } + } + + /** + * For digest + */ + @Override + public UserIdentity login(String username, Object credentials, ServletRequest request) + { + Digest digest = (Digest)credentials; + if (!Objects.equals(digest.realm, _loginService.getName())) + return null; + return super.login(username, credentials, request); + } + + /** + * For basic + */ + public UserIdentity login(String username, String credentials, ServletRequest request) + { + return super.login(username, credentials, request); + } + + public String newNonce(Request request) + { + Nonce nonce; + + do + { + byte[] nounce = new byte[24]; + _context.random().nextBytes(nounce); + + nonce = new Nonce(new String(B64Code.encode(nounce)), request.getTimeStamp(), getMaxNonceCount()); + } + while (_nonceMap.putIfAbsent(nonce._nonce, nonce) != null); + _nonceQueue.add(nonce); + + return nonce._nonce; + } + + /** + * @param digest the digest data to check + * @param request the request object + * @return -1 for a bad nonce, 0 for a stale none, 1 for a good nonce + */ + private int checkNonce(Digest digest, Request request) + { + // firstly let's expire old nonces + long expired = request.getTimeStamp() - getMaxNonceAge(); + Nonce nonce = _nonceQueue.peek(); + while (nonce != null && nonce._ts < expired) + { + _nonceQueue.remove(nonce); + _nonceMap.remove(nonce._nonce); + nonce = _nonceQueue.peek(); + } + + // Now check the requested nonce + try + { + nonce = _nonceMap.get(digest.nonce); + if (nonce == null) + return 0; + + long count = Long.parseLong(digest.nc, 16); + if (count >= _maxNC) + return 0; + + if (nonce.seen((int)count)) + return -1; + + return 1; + } + catch (Exception e) + { + LOG.ignore(e); + } + return -1; + } + + private static class Nonce + { + final String _nonce; + final long _ts; + final BitSet _seen; + + public Nonce(String nonce, long ts, int size) + { + _nonce = nonce; + _ts = ts; + _seen = new BitSet(size); + } + + public boolean seen(int count) + { + synchronized (this) + { + if (count >= _seen.size()) + return true; + boolean s = _seen.get(count); + _seen.set(count); + return s; + } + } + } + + private static class Digest extends Credential + { + private static final long serialVersionUID = -1111639019549527724L; + private final I2PAppContext _context; + final String method; + String username = ""; + String realm = ""; + String nonce = ""; + String nc = ""; + String cnonce = ""; + String qop = ""; + String uri = ""; + String response = ""; + // RFC 7616 default + String algorithm = "md5"; + + /* ------------------------------------------------------------ */ + Digest(I2PAppContext ctx, String m) + { + _context = ctx; + method = m; + } + + /* ------------------------------------------------------------ */ + @Override + public boolean check(Object credentials) + { + if (credentials instanceof char[]) + credentials = new String((char[])credentials); + String password = (credentials instanceof String) ? (String)credentials : credentials.toString(); + + try + { + MessageDigest md; + byte[] ha1 = null; + if (algorithm.equals("sha-256")) { + md = _context.sha().acquire(); + if (credentials instanceof SHA256Credential) { + ha1 = ((SHA256Credential)credentials).getDigest(); + } + } else if (algorithm.equals("md5")) { + md = MessageDigest.getInstance("MD5"); + if (credentials instanceof Credential.MD5) { + // Credentials are already a MD5 digest - assume it's in + // form user:realm:password (we have no way to know since + // it's a digest, alright?) + ha1 = ((Credential.MD5)credentials).getDigest(); + } + } else { + LOG.warn("unsupported algorithm " + algorithm); + return false; + } + if (ha1 == null) { + // calc A1 digest + md.update(username.getBytes(StandardCharsets.ISO_8859_1)); + md.update((byte)':'); + md.update(realm.getBytes(StandardCharsets.ISO_8859_1)); + md.update((byte)':'); + md.update(password.getBytes(StandardCharsets.ISO_8859_1)); + ha1 = md.digest(); + } + // calc A2 digest + md.reset(); + md.update(method.getBytes(StandardCharsets.ISO_8859_1)); + md.update((byte)':'); + md.update(uri.getBytes(StandardCharsets.ISO_8859_1)); + byte[] ha2 = md.digest(); + + // calc digest + // request-digest = <"> < KD ( H(A1), unq(nonce-value) ":" + // nc-value ":" unq(cnonce-value) ":" unq(qop-value) ":" H(A2) ) + // <"> + // request-digest = <"> < KD ( H(A1), unq(nonce-value) ":" H(A2) + // ) > <"> + + md.update(TypeUtil.toString(ha1, 16).getBytes(StandardCharsets.ISO_8859_1)); + md.update((byte)':'); + md.update(nonce.getBytes(StandardCharsets.ISO_8859_1)); + md.update((byte)':'); + md.update(nc.getBytes(StandardCharsets.ISO_8859_1)); + md.update((byte)':'); + md.update(cnonce.getBytes(StandardCharsets.ISO_8859_1)); + md.update((byte)':'); + md.update(qop.getBytes(StandardCharsets.ISO_8859_1)); + md.update((byte)':'); + md.update(TypeUtil.toString(ha2, 16).getBytes(StandardCharsets.ISO_8859_1)); + byte[] digest = md.digest(); + if (algorithm.equals("sha-256")) + _context.sha().release(md); + + // check digest + return stringEquals(TypeUtil.toString(digest, 16).toLowerCase(), response == null ? null : response.toLowerCase()); + } + catch (Exception e) + { + LOG.warn(e); + } + + return false; + } + + @Override + public String toString() + { + return username + "," + response; + } + } +} diff --git a/apps/jetty/java/src/net/i2p/jetty/MultiCredential.java b/apps/jetty/java/src/net/i2p/jetty/MultiCredential.java new file mode 100644 index 000000000..0b6559a62 --- /dev/null +++ b/apps/jetty/java/src/net/i2p/jetty/MultiCredential.java @@ -0,0 +1,58 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// ------------------------------------------------------------------------ +// All rights reserved. This program and the accompanying materials +// are made available under the terms of the Eclipse Public License v1.0 +// and Apache License v2.0 which accompanies this distribution. +// +// The Eclipse Public License is available at +// http://www.eclipse.org/legal/epl-v10.html +// +// The Apache License v2.0 is available at +// http://www.opensource.org/licenses/apache2.0.php +// +// You may elect to redistribute this code under either of these licenses. +// ======================================================================== +// + +package net.i2p.jetty; + +import java.util.List; + +import org.eclipse.jetty.util.security.Credential; + +/** + * Multiple Credentials + * + * @since 0.9.67 + */ +public class MultiCredential extends Credential +{ + private static final long serialVersionUID = 1133333330822684240L; + + private final List creds; + + /** + * @param credentials will be checked in-order + */ + public MultiCredential(List credentials) + { + creds = credentials; + } + + @Override + public boolean check(Object credentials) + { + for (Credential cred : creds) { + if (cred.check(credentials)) + return true; + } + return false; + } + + @Override + public String toString() { + return "MultiCredential: " + creds.toString(); + } +} diff --git a/apps/jetty/java/src/net/i2p/jetty/SHA256Credential.java b/apps/jetty/java/src/net/i2p/jetty/SHA256Credential.java new file mode 100644 index 000000000..5d576f8ba --- /dev/null +++ b/apps/jetty/java/src/net/i2p/jetty/SHA256Credential.java @@ -0,0 +1,100 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// ------------------------------------------------------------------------ +// All rights reserved. This program and the accompanying materials +// are made available under the terms of the Eclipse Public License v1.0 +// and Apache License v2.0 which accompanies this distribution. +// +// The Eclipse Public License is available at +// http://www.eclipse.org/legal/epl-v10.html +// +// The Apache License v2.0 is available at +// http://www.opensource.org/licenses/apache2.0.php +// +// You may elect to redistribute this code under either of these licenses. +// ======================================================================== +// + +package net.i2p.jetty; + +import java.nio.charset.StandardCharsets; + +import org.eclipse.jetty.util.TypeUtil; +import org.eclipse.jetty.util.log.Log; +import org.eclipse.jetty.util.security.Credential; +import org.eclipse.jetty.util.security.Password; + +import net.i2p.I2PAppContext; +import net.i2p.data.DataHelper; + +/** + * SHA256 Credentials + * + * @since 0.9.67 + */ +public class SHA256Credential extends Credential +{ + private static final long serialVersionUID = 1111996540822684240L; + private static final String __TYPE = "SHA256:"; + + private final byte[] _digest; + private final I2PAppContext _context; + + public SHA256Credential(I2PAppContext ctx, String digest) + { + digest = digest.startsWith(__TYPE) ? digest.substring(__TYPE.length()) : digest; + _digest = TypeUtil.parseBytes(digest, 16); + _context = ctx; + } + + public byte[] getDigest() + { + return _digest; + } + + @Override + public boolean check(Object credentials) + { + if (credentials instanceof char[]) + credentials=new String((char[])credentials); + if (credentials instanceof Password || credentials instanceof String) + { + byte[] b = credentials.toString().getBytes(StandardCharsets.ISO_8859_1); + byte[] digest = new byte[32]; + _context.sha().calculateHash(b, 0, b.length, digest, 0); + return byteEquals(_digest, digest); + } + else if (credentials instanceof SHA256Credential) + { + SHA256Credential sha256 = (SHA256Credential)credentials; + return byteEquals(_digest, sha256._digest); + } + else if (credentials instanceof Credential) + { + // Allow credential to attempt check - i.e. this'll work + // for DigestAuthModule$Digest credentials + return ((Credential)credentials).check(this); + } + else + { + //LOG.warn("Can't check " + credentials.getClass() + " against SHA256"); + return false; + } + } + +/* + public String digest(String password) + { + byte[] b = password.getBytes(StandardCharsets.ISO_8859_1); + byte[] digest = new byte[32]; + _context.sha().calculateHash(b, 0, b.length, digest, 0); + return __TYPE + TypeUtil.toString(digest, 16); + } +*/ + + @Override + public String toString() { + return "SHA256Credential: " + DataHelper.toString(_digest); + } +} diff --git a/apps/routerconsole/java/src/net/i2p/router/web/ConsolePasswordManager.java b/apps/routerconsole/java/src/net/i2p/router/web/ConsolePasswordManager.java index 6dc74e1ae..9688de8df 100644 --- a/apps/routerconsole/java/src/net/i2p/router/web/ConsolePasswordManager.java +++ b/apps/routerconsole/java/src/net/i2p/router/web/ConsolePasswordManager.java @@ -60,6 +60,7 @@ public class ConsolePasswordManager extends RouterPasswordManager { * @param pw plain text, already trimmed * @return if pw verified */ +/**** public boolean checkMD5(String realm, String subrealm, String user, String pw) { String pfx = realm; if (user != null && user.length() > 0) @@ -69,10 +70,11 @@ public class ConsolePasswordManager extends RouterPasswordManager { return false; return hex.equals(md5Hex(subrealm, user, pw)); } - +****/ + /** * Get all MD5 usernames and passwords. Compatible with Jetty. - * Any "null" user is NOT included.. + * Any "null" user is NOT included. * * @param realm e.g. i2cp, routerconsole, etc. * @return Map of usernames to passwords (hex with leading zeros, 32 characters) @@ -145,11 +147,13 @@ public class ConsolePasswordManager extends RouterPasswordManager { return _context.router().saveConfig(toAdd, toDel); } ****/ - + /** * Straight MD5, no salt * Compatible with Jetty and RFC 2617. * + * Any other passwords for this user, realm, and subrealm will be deleted. + * * @param realm The full realm, e.g. routerconsole.auth.i2prouter, etc. * @param subrealm to be used in creating the checksum * @param user non-null, non-empty, already trimmed @@ -169,9 +173,97 @@ public class ConsolePasswordManager extends RouterPasswordManager { toDel.add(pfx + PROP_B64); toDel.add(pfx + PROP_CRYPT); toDel.add(pfx + PROP_SHASH); + toDel.add(pfx + PROP_SHA256); return _context.router().saveConfig(toAdd, toDel); } - + + /** + * Get all SHA256 usernames and passwords. + * Compatible with our Jetty SHA256DigestAuthenticator and RFC 7616. + * Any "null" user is NOT included. + * + * @param realm e.g. i2cp, routerconsole, etc. + * @return Map of usernames to passwords (hex with leading zeros, 64 characters) + * @since 0.9.67 + */ + public Map getSHA256(String realm) { + String pfx = realm + '.'; + Map rv = new HashMap(4); + for (Map.Entry e : _context.router().getConfigMap().entrySet()) { + String prop = e.getKey(); + if (prop.startsWith(pfx) && prop.endsWith(PROP_SHA256)) { + String user = prop.substring(0, prop.length() - PROP_SHA256.length()).substring(pfx.length()); + String hex = e.getValue(); + if (user.length() > 0 && hex.length() == 64) + rv.put(user, hex); + } + } + return rv; + } + + /** + * Straight SHA256, no salt. + * Compatible with our Jetty SHA256DigestAuthenticator and RFC 7616. + * Any other passwords for this user, realm, and subrealm will be deleted. + * To support both, use saveMD5SHA256() + * + * @param realm The full realm, e.g. routerconsole.auth.i2prouter, etc. + * @param subrealm to be used in creating the checksum + * @param user non-null, non-empty, already trimmed + * @param pw plain text + * @return if pw verified + * @since 0.9.67 + */ + public boolean saveSHA256(String realm, String subrealm, String user, String pw) { + String pfx = realm; + if (user != null && user.length() > 0) + pfx += '.' + user; + String hex = sha256Hex(subrealm, user, pw); + if (hex == null) + return false; + Map toAdd = Collections.singletonMap(pfx + PROP_SHA256, hex); + List toDel = new ArrayList(4); + toDel.add(pfx + PROP_PW); + toDel.add(pfx + PROP_B64); + toDel.add(pfx + PROP_CRYPT); + toDel.add(pfx + PROP_SHASH); + toDel.add(pfx + PROP_MD5); + return _context.router().saveConfig(toAdd, toDel); + } + + /** + * Both MD5 and SHA256, no salt. + * Compatible with our Jetty SHA256DigestAuthenticator and RFC 7616. + * Any other passwords for this user, realm, and subrealm will be deleted. + * + * @param realm The full realm, e.g. routerconsole.auth.i2prouter, etc. + * @param subrealm to be used in creating the checksum + * @param user non-null, non-empty, already trimmed + * @param pw plain text + * @return if pw verified + * @since 0.9.67 + */ + public boolean saveMD5SHA256(String realm, String subrealm, String user, String pw) { + String pfx = realm; + if (user != null && user.length() > 0) + pfx += '.' + user; + Map toAdd = new HashMap(2); + String hex = sha256Hex(subrealm, user, pw); + if (hex == null) + return false; + toAdd.put(pfx + PROP_SHA256, hex); + hex = md5Hex(subrealm, user, pw); + if (hex == null) + return false; + toAdd.put(pfx + PROP_MD5, hex); + List toDel = new ArrayList(4); + toDel.add(pfx + PROP_PW); + toDel.add(pfx + PROP_B64); + toDel.add(pfx + PROP_CRYPT); + toDel.add(pfx + PROP_SHASH); + return _context.router().saveConfig(toAdd, toDel); + } + /**** public static void main(String args[]) { RouterContext ctx = (new Router()).getContext(); diff --git a/apps/routerconsole/java/src/net/i2p/router/web/RouterConsoleRunner.java b/apps/routerconsole/java/src/net/i2p/router/web/RouterConsoleRunner.java index 493756742..bc7b2bd5d 100644 --- a/apps/routerconsole/java/src/net/i2p/router/web/RouterConsoleRunner.java +++ b/apps/routerconsole/java/src/net/i2p/router/web/RouterConsoleRunner.java @@ -32,6 +32,9 @@ import static net.i2p.app.ClientAppState.*; import net.i2p.crypto.KeyStoreUtil; import net.i2p.data.DataHelper; import net.i2p.jetty.I2PLogger; +import net.i2p.jetty.MultiAuthenticator; +import net.i2p.jetty.MultiCredential; +import net.i2p.jetty.SHA256Credential; import net.i2p.router.RouterContext; import net.i2p.router.app.RouterApp; import net.i2p.router.news.NewsManager; @@ -49,8 +52,6 @@ import net.i2p.util.SystemVersion; import org.eclipse.jetty.security.HashLoginService; import org.eclipse.jetty.security.ConstraintMapping; import org.eclipse.jetty.security.ConstraintSecurityHandler; -import org.eclipse.jetty.security.authentication.BasicAuthenticator; -import org.eclipse.jetty.security.authentication.DigestAuthenticator; import org.eclipse.jetty.security.authentication.LoginAuthenticator; import org.eclipse.jetty.server.AbstractConnector; import org.eclipse.jetty.server.ConnectionFactory; @@ -76,6 +77,7 @@ import org.eclipse.jetty.util.resource.Resource; import org.eclipse.jetty.util.security.Constraint; import org.eclipse.jetty.util.security.Credential; import org.eclipse.jetty.util.security.Credential.MD5; +import org.eclipse.jetty.util.security.Password; import org.eclipse.jetty.util.ssl.SslContextFactory; import org.eclipse.jetty.util.thread.ExecutorThreadPool; import org.eclipse.jetty.util.thread.QueuedThreadPool; @@ -116,14 +118,17 @@ public class RouterConsoleRunner implements RouterApp { private static final String DEFAULT_WEBAPP_CONFIG_FILENAME = "webapps.config"; - // Jetty Auth - private static final DigestAuthenticator authenticator = new DigestAuthenticator(); - // only for prometheus plugin - private static final BasicAuthenticator basicAuthenticator = new BasicAuthenticator(); - public static final String PROMETHEUS_REALM = "prometheus"; + // Jetty Auth (MD5, basic) + // A MultiAuthenticator so we can support digest and basic simultaneously, + // using the MD5 hash as the "password", for prometheus plugin and + // other HTTP clients that don't support digest. + private static final MultiAuthenticator authenticator = new MultiAuthenticator(false, true, true); + // Jetty Auth (SHA-256, MD5, Basic) + private static final MultiAuthenticator multiAuthenticator = new MultiAuthenticator(true, true, true); static { // default changed from 0 (forever) in Jetty 6 to 60*1000 ms in Jetty 7 authenticator.setMaxNonceAge(7*24*60*60*1000L); + multiAuthenticator.setMaxNonceAge(7*24*60*60*1000L); } private static final String NAME = "console"; public static final String JETTY_REALM = "i2prouter"; @@ -985,30 +990,51 @@ public class RouterConsoleRunner implements RouterApp { ConsolePasswordManager mgr = new ConsolePasswordManager(ctx); boolean enable = ctx.getBooleanProperty(PROP_PW_ENABLE); if (enable) { + // The only schemes Jetty supports out of the box are straight password, + // hashed password, and the original UnixCrypt DES format (descrypt), + // which is so bad as to be no better than hashes. See man crypt(5). + // We should supply our own by extending Credential and use the + // BCrypt implemenation in i2pcontrol. + // Any scheme must be compatible with what browsers support, see + // RFC 7616. We can extend Jetty DigestAuthenticator to switch to + // SHA-256. FF supports SHA-256 as of FF93 (2021) + // Chrome supports it as of Chrome 117 (2023) + // See chart at bottom of https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/WWW-Authenticate + // But Jetty still claiming there's no support in 2024 + // and refusing to implement it: https://github.com/jetty/jetty.project/issues/11489 + // Still NOT supported by Safari / IOS. Map userpw = mgr.getMD5(PROP_CONSOLE_PW); + // todo sha256 only + Map userpw2 = mgr.getSHA256(PROP_CONSOLE_PW); + // todo sha256 only if (userpw.isEmpty()) { enable = false; ctx.router().saveConfig(PROP_PW_ENABLE, "false"); } else { - // Prometheus server only supports basic auth - // https://github.com/prometheus/common/issues/352 - // Jetty does not support multiple auth at once - // but it's coming for Jetty 12 - // https://github.com/jetty/jetty.project/issues/5442 - boolean isBasic = context.getContextPath().equals("/prometheus"); - // need separate realms so the browser doesn't get them mixed up - String rlm = isBasic ? PROMETHEUS_REALM : JETTY_REALM; + String rlm = JETTY_REALM; HashLoginService realm = new CustomHashLoginService(rlm, context.getContextPath(), ctx.logManager().getLog(RouterConsoleRunner.class)); sec.setLoginService(realm); - LoginAuthenticator auth = isBasic ? basicAuthenticator : authenticator; + // don't advertise sha256 unless we have one + // Only passwords stored as of 0.9.67 will have sha256, as set in ConfigUIHandler + LoginAuthenticator auth = userpw2.isEmpty() ? authenticator : multiAuthenticator; sec.setAuthenticator(auth); String[] role = new String[] {JETTY_ROLE}; for (Map.Entry e : userpw.entrySet()) { String user = e.getKey(); String pw = e.getValue(); + + List creds = new ArrayList(3); + String pw2 = userpw2.get(user); + if (pw2 != null) + creds.add(new SHA256Credential(ctx, pw2)); + creds.add(Credential.getCredential(MD5_CREDENTIAL_TYPE + pw)); // for basic, the password will be the md5 hash itself - Credential cred = Credential.getCredential(isBasic ? pw : MD5_CREDENTIAL_TYPE + pw); + // mainly for prometheus plugin, but also for testing + // or for any other HTTP clients not supporting digest + creds.add(new Password(pw)); + Credential cred = new MultiCredential(creds); + realm.putUser(user, cred, role); Constraint constraint = new Constraint(user, JETTY_ROLE); constraint.setAuthenticate(true); diff --git a/apps/routerconsole/java/src/net/i2p/router/web/helpers/ConfigUIHandler.java b/apps/routerconsole/java/src/net/i2p/router/web/helpers/ConfigUIHandler.java index 219bd3835..437cfa33a 100644 --- a/apps/routerconsole/java/src/net/i2p/router/web/helpers/ConfigUIHandler.java +++ b/apps/routerconsole/java/src/net/i2p/router/web/helpers/ConfigUIHandler.java @@ -111,8 +111,8 @@ public class ConfigUIHandler extends FormHandler { return; } ConsolePasswordManager mgr = new ConsolePasswordManager(_context); - // rfc 2617 - if (mgr.saveMD5(RouterConsoleRunner.PROP_CONSOLE_PW, RouterConsoleRunner.JETTY_REALM, name, pw)) { + // rfc 2617 AND rfc 7617 + if (mgr.saveMD5SHA256(RouterConsoleRunner.PROP_CONSOLE_PW, RouterConsoleRunner.JETTY_REALM, name, pw)) { if (!_context.getBooleanProperty(RouterConsoleRunner.PROP_PW_ENABLE)) _context.router().saveConfig(RouterConsoleRunner.PROP_PW_ENABLE, "true"); addFormNotice(_t("Added user {0}", name));