_domains = new IncludeExcludeSet<>();
+
+ private String _loginPath;
+ private String _authenticationPath = DEFAULT_AUTHENTICATION_PATH;
+ private String _noncePath = DEFAULT_NONCE_PATH;
+ private int _maxMessageSize = 4 * 1024;
+ private String _logoutRedirectPath;
+ private String _errorPath;
+ private String _errorQuery;
+ private boolean _dispatch;
+ private boolean _authenticateNewUsers = true;
+
+ public EthereumAuthenticator()
+ {
+ }
+
+ public void includeDomains(String... domains)
+ {
+ _domains.include(domains);
+ }
+
+ public void includeChainIds(String... chainIds)
+ {
+ _chainIds.include(chainIds);
+ }
+
+ @Override
+ public void setConfiguration(Authenticator.Configuration authConfig)
+ {
+ String loginPath = authConfig.getParameter(LOGIN_PATH_PARAM);
+ if (loginPath != null)
+ setLoginPath(loginPath);
+
+ String authenticationPath = authConfig.getParameter(AUTHENTICATION_PATH_PARAM);
+ if (authenticationPath != null)
+ setAuthenticationPath(authenticationPath);
+
+ String noncePath = authConfig.getParameter(NONCE_PATH_PARAM);
+ if (noncePath != null)
+ setNoncePath(noncePath);
+
+ String maxMessageSize = authConfig.getParameter(MAX_MESSAGE_SIZE_PARAM);
+ if (maxMessageSize != null)
+ setMaxMessageSize(Integer.parseInt(maxMessageSize));
+
+ String logout = authConfig.getParameter(LOGOUT_REDIRECT_PARAM);
+ if (logout != null)
+ setLogoutRedirectPath(logout);
+
+ String error = authConfig.getParameter(ERROR_PATH_PARAM);
+ if (error != null)
+ setErrorPage(error);
+
+ String dispatch = authConfig.getParameter(DISPATCH_PARAM);
+ if (dispatch != null)
+ setDispatch(Boolean.parseBoolean(dispatch));
+
+ String authenticateNewUsers = authConfig.getParameter(AUTHENTICATE_NEW_USERS_PARAM);
+ if (authenticateNewUsers != null)
+ setAuthenticateNewUsers(Boolean.parseBoolean(authenticateNewUsers));
+
+ String chainIds = authConfig.getParameter(CHAIN_IDS_PARAM);
+ if (chainIds != null)
+ includeChainIds(StringUtil.csvSplit(chainIds));
+
+ String domains = authConfig.getParameter(DOMAINS_PARAM);
+ if (domains != null)
+ includeDomains(StringUtil.csvSplit(domains));
+
+ if (isAuthenticateNewUsers())
+ {
+ LoginService loginService = new AnyUserLoginService(authConfig.getRealmName(), authConfig.getLoginService());
+ authConfig = new Configuration.Wrapper(authConfig)
+ {
+ @Override
+ public LoginService getLoginService()
+ {
+ return loginService;
+ }
+ };
+ }
+
+ if (_loginPath == null)
+ throw new IllegalStateException("No loginPath");
+ super.setConfiguration(authConfig);
+ }
+
+ @Override
+ public String getAuthenticationType()
+ {
+ return Authenticator.SIWE_AUTH;
+ }
+
+ public boolean isAuthenticateNewUsers()
+ {
+ return _authenticateNewUsers;
+ }
+
+ /**
+ * Configures the behavior for authenticating users not found by a wrapped {@link LoginService}.
+ *
+ * This setting is only meaningful if a wrapped {@link LoginService} has been set.
+ *
+ *
+ * If set to {@code true}, users not found by a wrapped {@link LoginService} will authenticated with no roles.
+ * If set to {@code false}, only users found by a wrapped {@link LoginService} will be authenticated.
+ *
+ *
+ * @param authenticateNewUsers whether to authenticate users not found by the wrapped {@link LoginService}
+ **/
+ public void setAuthenticateNewUsers(boolean authenticateNewUsers)
+ {
+ this._authenticateNewUsers = authenticateNewUsers;
+ }
+
+ public void setLoginPath(String loginPath)
+ {
+ if (loginPath == null)
+ {
+ LOG.warn("login path must not be null, defaulting to {}", _loginPath);
+ loginPath = _loginPath;
+ }
+ else if (!loginPath.startsWith("/"))
+ {
+ LOG.warn("login path must start with /");
+ loginPath = "/" + loginPath;
+ }
+
+ _loginPath = loginPath;
+ }
+
+ public void setAuthenticationPath(String authenticationPath)
+ {
+ if (authenticationPath == null)
+ {
+ authenticationPath = _authenticationPath;
+ LOG.warn("authentication path must not be null, defaulting to {}", authenticationPath);
+ }
+ else if (!authenticationPath.startsWith("/"))
+ {
+ authenticationPath = "/" + authenticationPath;
+ LOG.warn("authentication path must start with /");
+ }
+
+ _authenticationPath = authenticationPath;
+ }
+
+ public void setNoncePath(String noncePath)
+ {
+ if (noncePath == null)
+ {
+ noncePath = _noncePath;
+ LOG.warn("nonce path must not be null, defaulting to {}", noncePath);
+ }
+ else if (!noncePath.startsWith("/"))
+ {
+ noncePath = "/" + noncePath;
+ LOG.warn("nonce path must start with /");
+ }
+
+ _noncePath = noncePath;
+ }
+
+ public void setMaxMessageSize(int maxMessageSize)
+ {
+ _maxMessageSize = maxMessageSize;
+ }
+
+ public void setDispatch(boolean dispatch)
+ {
+ _dispatch = dispatch;
+ }
+
+ public void setLogoutRedirectPath(String logoutRedirectPath)
+ {
+ if (logoutRedirectPath != null && !logoutRedirectPath.startsWith("/"))
+ {
+ LOG.warn("logout redirect path must start with /");
+ logoutRedirectPath = "/" + logoutRedirectPath;
+ }
+
+ _logoutRedirectPath = logoutRedirectPath;
+ }
+
+ public void setErrorPage(String path)
+ {
+ if (path == null || path.trim().isEmpty())
+ {
+ _errorPath = null;
+ }
+ else
+ {
+ if (!path.startsWith("/"))
+ {
+ LOG.warn("error-page must start with /");
+ path = "/" + path;
+ }
+ _errorPath = path;
+ _errorQuery = "";
+
+ int queryIndex = _errorPath.indexOf('?');
+ if (queryIndex > 0)
+ {
+ _errorPath = path.substring(0, queryIndex);
+ _errorQuery = path.substring(queryIndex + 1);
+ }
+ }
+ }
+
+ @Override
+ public UserIdentity login(String username, Object credentials, Request request, Response response)
+ {
+ if (LOG.isDebugEnabled())
+ LOG.debug("login {} {} {}", username, credentials, request);
+
+ UserIdentity user = super.login(username, credentials, request, response);
+ if (user != null)
+ {
+ Session session = request.getSession(true);
+ AuthenticationState cached = new SessionAuthentication(getAuthenticationType(), user, credentials);
+ synchronized (session)
+ {
+ session.setAttribute(SessionAuthentication.AUTHENTICATED_ATTRIBUTE, cached);
+ }
+ }
+ return user;
+ }
+
+ @Override
+ public void logout(Request request, Response response)
+ {
+ attemptLogoutRedirect(request, response);
+ logoutWithoutRedirect(request, response);
+ }
+
+ private void logoutWithoutRedirect(Request request, Response response)
+ {
+ super.logout(request, response);
+ Session session = request.getSession(false);
+ if (session == null)
+ return;
+ synchronized (session)
+ {
+ session.removeAttribute(SessionAuthentication.AUTHENTICATED_ATTRIBUTE);
+ }
+ }
+
+ /**
+ * This will attempt to redirect the request to the {@link #_logoutRedirectPath}.
+ *
+ * @param request the request to redirect.
+ */
+ private void attemptLogoutRedirect(Request request, Response response)
+ {
+ try
+ {
+ String redirectUri = null;
+ if (_logoutRedirectPath != null)
+ {
+ HttpURI.Mutable httpURI = HttpURI.build()
+ .scheme(request.getHttpURI().getScheme())
+ .host(Request.getServerName(request))
+ .port(Request.getServerPort(request))
+ .path(URIUtil.compactPath(Request.getContextPath(request) + _logoutRedirectPath));
+ redirectUri = httpURI.toString();
+ }
+
+ Session session = request.getSession(false);
+ if (session == null)
+ {
+ if (redirectUri != null)
+ sendRedirect(request, response, redirectUri);
+ }
+ }
+ catch (Throwable t)
+ {
+ LOG.warn("failed to redirect to end_session_endpoint", t);
+ }
+ }
+
+ private void sendRedirect(Request request, Response response, String location) throws IOException
+ {
+ try (Blocker.Callback callback = Blocker.callback())
+ {
+ Response.sendRedirect(request, response, callback, location);
+ callback.block();
+ }
+ }
+
+ @Override
+ public Request prepareRequest(Request request, AuthenticationState authenticationState)
+ {
+ // if this is a request resulting from a redirect after auth is complete
+ // (ie its from a redirect to the original request uri) then due to
+ // browser handling of 302 redirects, the method may not be the same as
+ // that of the original request. Replace the method and original post
+ // params (if it was a post).
+ if (authenticationState instanceof AuthenticationState.Succeeded)
+ {
+ Session session = request.getSession(false);
+ if (session == null)
+ return request; //not authenticated yet
+
+ // Remove the nonce set used for authentication.
+ session.removeAttribute(NONCE_SET_ATTR);
+
+ HttpURI juri = (HttpURI)session.getAttribute(J_URI);
+ HttpURI uri = request.getHttpURI();
+ if ((uri.equals(juri)))
+ {
+ session.removeAttribute(J_URI);
+
+ Fields fields = (Fields)session.removeAttribute(J_POST);
+ if (fields != null)
+ request.setAttribute(FormFields.class.getName(), fields);
+
+ String method = (String)session.removeAttribute(J_METHOD);
+ if (method != null && request.getMethod().equals(method))
+ {
+ return new Request.Wrapper(request)
+ {
+ @Override
+ public String getMethod()
+ {
+ return method;
+ }
+ };
+ }
+ }
+ }
+
+ return request;
+ }
+
+ @Override
+ public Constraint.Authorization getConstraintAuthentication(String pathInContext, Constraint.Authorization existing, Function getSession)
+ {
+ if (isAuthenticationRequest(pathInContext))
+ return Constraint.Authorization.ANY_USER;
+ if (isLoginPage(pathInContext) || isErrorPage(pathInContext))
+ return Constraint.Authorization.ALLOWED;
+ if (isNonceRequest(pathInContext))
+ return Constraint.Authorization.ANY_USER;
+ return existing;
+ }
+
+ protected String readMessage(InputStream in) throws IOException
+ {
+ Iso88591StringBuilder out = new Iso88591StringBuilder();
+ byte[] buffer = new byte[1024];
+ int totalRead = 0;
+
+ while (true)
+ {
+ int len = in.read(buffer, 0, buffer.length);
+ if (len < 0)
+ break;
+
+ totalRead += len;
+ if (_maxMessageSize >= 0 && totalRead > _maxMessageSize)
+ throw new BadMessageException("SIWE Message Too Large");
+ out.append(buffer, 0, len);
+ }
+
+ return out.build();
+ }
+
+ protected SignedMessage parseMessage(Request request, Response response, Callback callback)
+ {
+ try
+ {
+ InputStream inputStream = Content.Source.asInputStream(request);
+ String requestContent = readMessage(inputStream);
+ ByteBufferContentSource contentSource = new ByteBufferContentSource(BufferUtil.toBuffer(requestContent));
+
+ String contentType = request.getHeaders().get(HttpHeader.CONTENT_TYPE);
+ MimeTypes.Type mimeType = MimeTypes.getBaseType(contentType);
+ if (mimeType == null)
+ throw new ServerAuthException("Unsupported content type: " + contentType);
+
+ String signature;
+ String message;
+ switch (mimeType)
+ {
+ case FORM_ENCODED ->
+ {
+ Fields fields = FormFields.from(contentSource, request, getFormEncodedCharset(request), 10, _maxMessageSize).get();
+ signature = fields.get("signature").getValue();
+ message = fields.get("message").getValue();
+ }
+ case MULTIPART_FORM_DATA ->
+ {
+ MultiPartConfig config = Request.getMultiPartConfig(request, null)
+ .maxSize(_maxMessageSize)
+ .maxParts(10)
+ .build();
+
+ MultiPartFormData.Parts parts = MultiPartFormData.from(contentSource, request, contentType, config).get();
+ signature = parts.getFirst("signature").getContentAsString(StandardCharsets.ISO_8859_1);
+ message = parts.getFirst("message").getContentAsString(StandardCharsets.ISO_8859_1);
+ }
+ default -> throw new ServerAuthException("Unsupported mime type: " + mimeType);
+ };
+
+ // The browser may convert LF to CRLF, EIP4361 specifies to only use LF.
+ message = message.replace("\r\n", "\n");
+ return new SignedMessage(message, signature);
+ }
+ catch (Throwable t)
+ {
+ if (LOG.isDebugEnabled())
+ LOG.debug("error reading SIWE message and signature", t);
+ sendError(request, response, callback, t.getMessage());
+ return null;
+ }
+ }
+
+ protected AuthenticationState handleNonceRequest(Request request, Response response, Callback callback)
+ {
+ String nonce = createNonce(request.getSession(false));
+ response.getHeaders().put(HttpHeader.CONTENT_TYPE, "application/json");
+ ByteBuffer content = BufferUtil.toBuffer("{ \"nonce\": \"" + nonce + "\" }");
+ response.write(true, content, callback);
+ return AuthenticationState.CHALLENGE;
+ }
+
+ private boolean validateSignInWithEthereumToken(SignInWithEthereumToken siwe, SignedMessage signedMessage, Request request, Response response, Callback callback)
+ {
+ Session session = request.getSession(false);
+ if (siwe == null)
+ {
+ sendError(request, response, callback, "failed to parse SIWE message");
+ return false;
+ }
+
+ try
+ {
+ siwe.validate(signedMessage, nonce -> redeemNonce(session, nonce), _domains, _chainIds);
+ }
+ catch (Throwable t)
+ {
+ sendError(request, response, callback, t.getMessage());
+ return false;
+ }
+
+ return true;
+ }
+
+ @Override
+ public AuthenticationState validateRequest(Request request, Response response, Callback callback) throws ServerAuthException
+ {
+ if (LOG.isDebugEnabled())
+ LOG.debug("validateRequest({},{})", request, response);
+
+ String uri = request.getHttpURI().toString();
+ if (uri == null)
+ uri = "/";
+
+ try
+ {
+ Session session = request.getSession(false);
+ if (session == null)
+ {
+ session = request.getSession(true);
+ if (session == null)
+ {
+ sendError(request, response, callback, "session could not be created");
+ return AuthenticationState.SEND_FAILURE;
+ }
+ }
+
+ if (isNonceRequest(uri))
+ return handleNonceRequest(request, response, callback);
+ if (isAuthenticationRequest(uri))
+ {
+ if (LOG.isDebugEnabled())
+ LOG.debug("authentication request");
+
+ // Parse and validate SIWE Message.
+ SignedMessage signedMessage = parseMessage(request, response, callback);
+ if (signedMessage == null)
+ return AuthenticationState.SEND_FAILURE;
+ SignInWithEthereumToken siwe = SignInWithEthereumToken.from(signedMessage.message());
+ if (siwe == null || !validateSignInWithEthereumToken(siwe, signedMessage, request, response, callback))
+ return AuthenticationState.SEND_FAILURE;
+
+ String address = siwe.address();
+ UserIdentity user = login(address, null, request, response);
+ if (LOG.isDebugEnabled())
+ LOG.debug("user identity: {}", user);
+ if (user != null)
+ {
+ // Redirect to original request
+ HttpURI savedURI = (HttpURI)session.getAttribute(J_URI);
+ String originalURI = savedURI != null
+ ? savedURI.getPathQuery()
+ : Request.getContextPath(request);
+ if (originalURI == null)
+ originalURI = "/";
+ UserAuthenticationSent formAuth = new UserAuthenticationSent(getAuthenticationType(), user);
+ String redirectUrl = session.encodeURI(request, originalURI, true);
+ Response.sendRedirect(request, response, callback, redirectUrl, true);
+ return formAuth;
+ }
+
+ sendError(request, response, callback, "auth failed");
+ return AuthenticationState.SEND_FAILURE;
+ }
+
+ // Look for cached authentication in the Session.
+ AuthenticationState authenticationState = (AuthenticationState)session.getAttribute(SessionAuthentication.AUTHENTICATED_ATTRIBUTE);
+ if (authenticationState != null)
+ {
+ // Has authentication been revoked?
+ if (authenticationState instanceof AuthenticationState.Succeeded && _loginService != null &&
+ !_loginService.validate(((AuthenticationState.Succeeded)authenticationState).getUserIdentity()))
+ {
+ if (LOG.isDebugEnabled())
+ LOG.debug("auth revoked {}", authenticationState);
+ logoutWithoutRedirect(request, response);
+ return AuthenticationState.SEND_FAILURE;
+ }
+
+ if (LOG.isDebugEnabled())
+ LOG.debug("auth {}", authenticationState);
+ return authenticationState;
+ }
+
+ // If we can't send challenge.
+ if (AuthenticationState.Deferred.isDeferred(response))
+ {
+ if (LOG.isDebugEnabled())
+ LOG.debug("auth deferred {}", session.getId());
+ return null;
+ }
+
+ // Save the current URI
+ synchronized (session)
+ {
+ // But only if it is not set already, or we save every uri that leads to a login form redirect
+ if (session.getAttribute(J_URI) == null)
+ {
+ HttpURI juri = request.getHttpURI();
+ session.setAttribute(J_URI, juri.asImmutable());
+ if (!HttpMethod.GET.is(request.getMethod()))
+ session.setAttribute(J_METHOD, request.getMethod());
+ if (HttpMethod.POST.is(request.getMethod()))
+ session.setAttribute(J_POST, getParameters(request));
+ }
+ }
+
+ // Send the challenge.
+ String loginPath = URIUtil.addPaths(request.getContext().getContextPath(), _loginPath);
+ if (_dispatch)
+ {
+ HttpURI.Mutable newUri = HttpURI.build(request.getHttpURI()).pathQuery(loginPath);
+ return new AuthenticationState.ServeAs(newUri);
+ }
+ else
+ {
+ String redirectUri = session.encodeURI(request, loginPath, true);
+ Response.sendRedirect(request, response, callback, redirectUri, true);
+ return AuthenticationState.CHALLENGE;
+ }
+ }
+ catch (Throwable t)
+ {
+ throw new ServerAuthException(t);
+ }
+ }
+
+ /**
+ * Report an error case either by redirecting to the error page if it is defined, otherwise sending a 403 response.
+ * If the message parameter is not null, a query parameter with a key of {@link #ERROR_PARAMETER} and value of the error
+ * message will be logged and added to the error redirect URI if the error page is defined.
+ * @param request the request.
+ * @param response the response.
+ * @param callback the callback.
+ * @param message the reason for the error or null.
+ */
+ private void sendError(Request request, Response response, Callback callback, String message)
+ {
+ if (LOG.isDebugEnabled())
+ LOG.debug("Authentication FAILED: {}", message);
+
+ if (_errorPath == null)
+ {
+ if (LOG.isDebugEnabled())
+ LOG.debug("auth failed 403");
+ if (response != null)
+ Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, message);
+ }
+ else
+ {
+ if (LOG.isDebugEnabled())
+ LOG.debug("auth failed {}", _errorPath);
+
+ String contextPath = Request.getContextPath(request);
+ String redirectUri = URIUtil.addPaths(contextPath, _errorPath);
+ if (message != null)
+ {
+ String query = URIUtil.addQueries(ERROR_PARAMETER + "=" + UrlEncoded.encodeString(message), _errorQuery);
+ redirectUri = URIUtil.addPathQuery(URIUtil.addPaths(contextPath, _errorPath), query);
+ }
+
+ int redirectCode = request.getConnectionMetaData().getHttpVersion().getVersion() < HttpVersion.HTTP_1_1.getVersion()
+ ? HttpStatus.MOVED_TEMPORARILY_302 : HttpStatus.SEE_OTHER_303;
+ Response.sendRedirect(request, response, callback, redirectCode, redirectUri, true);
+ }
+ }
+
+ protected Fields getParameters(Request request)
+ {
+ try
+ {
+ Fields queryFields = Request.extractQueryParameters(request);
+ Fields formFields = FormFields.from(request).get();
+ return Fields.combine(queryFields, formFields);
+ }
+ catch (InterruptedException | ExecutionException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public boolean isLoginPage(String uri)
+ {
+ return matchURI(uri, _loginPath);
+ }
+
+ public boolean isAuthenticationRequest(String uri)
+ {
+ return matchURI(uri, _authenticationPath);
+ }
+
+ public boolean isNonceRequest(String uri)
+ {
+ return matchURI(uri, _noncePath);
+ }
+
+ private boolean matchURI(String uri, String path)
+ {
+ int jsc = uri.indexOf(path);
+ if (jsc < 0)
+ return false;
+ int e = jsc + path.length();
+ if (e == uri.length())
+ return true;
+ char c = uri.charAt(e);
+ return c == ';' || c == '#' || c == '/' || c == '?';
+ }
+
+ public boolean isErrorPage(String pathInContext)
+ {
+ if (_errorPath == null)
+ return false;
+ return pathInContext != null && (pathInContext.equals(_errorPath));
+ }
+
+ protected String createNonce(Session session)
+ {
+ String nonce = EthereumUtil.createNonce();
+ synchronized (session)
+ {
+ @SuppressWarnings("unchecked")
+ Set attribute = (Set)session.getAttribute(NONCE_SET_ATTR);
+ if (attribute == null)
+ session.setAttribute(NONCE_SET_ATTR, attribute = new FixedSizeSet<>(5));
+ if (!attribute.add(nonce))
+ throw new IllegalStateException("Nonce already in use");
+ }
+ return nonce;
+ }
+
+ protected boolean redeemNonce(Session session, String nonce)
+ {
+ synchronized (session)
+ {
+ @SuppressWarnings("unchecked")
+ Set attribute = (Set)session.getAttribute(NONCE_SET_ATTR);
+ if (attribute == null)
+ return false;
+ return attribute.remove(nonce);
+ }
+ }
+
+ @Override
+ public void dump(Appendable out, String indent) throws IOException
+ {
+ Dumpable.dumpObjects(out, indent, this,
+ "loginPath=" + _loginPath,
+ "authenticationPath=" + _authenticationPath,
+ "noncePath=" + _noncePath,
+ "errorPath=" + _errorPath,
+ "errorQuery=" + _errorQuery,
+ "dispatch=" + _dispatch,
+ "authenticateNewUsers=" + _authenticateNewUsers,
+ "logoutRedirectPath=" + _logoutRedirectPath,
+ "maxMessageSize=" + _maxMessageSize,
+ "chainIds=" + _chainIds,
+ "domains=" + _domains
+ );
+ }
+
+ public static class FixedSizeSet extends LinkedHashSet
+ {
+ private final int maxSize;
+
+ public FixedSizeSet(int maxSize)
+ {
+ super(maxSize);
+ this.maxSize = maxSize;
+ }
+
+ @Override
+ public boolean add(T element)
+ {
+ if (size() >= maxSize)
+ {
+ Iterator it = iterator();
+ if (it.hasNext())
+ {
+ it.next();
+ it.remove();
+ }
+ }
+ return super.add(element);
+ }
+ }
+
+ public record SignedMessage(String message, String signature)
+ {
+ public String recoverAddress()
+ {
+ return EthereumUtil.recoverAddress(this);
+ }
+ }
+}
diff --git a/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/EthereumAuthenticatorFactory.java b/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/EthereumAuthenticatorFactory.java
new file mode 100644
index 00000000000..da2d5266516
--- /dev/null
+++ b/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/EthereumAuthenticatorFactory.java
@@ -0,0 +1,30 @@
+//
+// ========================================================================
+// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
+// which is available at https://www.apache.org/licenses/LICENSE-2.0.
+//
+// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
+// ========================================================================
+//
+
+package org.eclipse.jetty.security.siwe;
+
+import org.eclipse.jetty.security.Authenticator;
+import org.eclipse.jetty.server.Context;
+import org.eclipse.jetty.server.Server;
+
+public class EthereumAuthenticatorFactory implements Authenticator.Factory
+{
+ @Override
+ public Authenticator getAuthenticator(Server server, Context context, Authenticator.Configuration configuration)
+ {
+ String auth = configuration.getAuthenticationType();
+ if (Authenticator.SIWE_AUTH.equalsIgnoreCase(auth))
+ return new EthereumAuthenticator();
+ return null;
+ }
+}
diff --git a/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/AnyUserLoginService.java b/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/AnyUserLoginService.java
new file mode 100644
index 00000000000..e1f099cda87
--- /dev/null
+++ b/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/AnyUserLoginService.java
@@ -0,0 +1,114 @@
+//
+// ========================================================================
+// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
+// which is available at https://www.apache.org/licenses/LICENSE-2.0.
+//
+// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
+// ========================================================================
+//
+
+package org.eclipse.jetty.security.siwe.internal;
+
+import java.util.function.Function;
+import javax.security.auth.Subject;
+
+import org.eclipse.jetty.security.DefaultIdentityService;
+import org.eclipse.jetty.security.IdentityService;
+import org.eclipse.jetty.security.LoginService;
+import org.eclipse.jetty.security.UserIdentity;
+import org.eclipse.jetty.security.UserPrincipal;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Session;
+
+/**
+ * A {@link LoginService} which allows unknown users to be authenticated.
+ *
+ * This can delegate to a nested {@link LoginService} if it is supplied to the constructor, it will first attempt to log in
+ * with the nested {@link LoginService} and only create a new {@link UserIdentity} if none was found with
+ * {@link LoginService#login(String, Object, Request, Function)}.
+ *
+ */
+public class AnyUserLoginService implements LoginService
+{
+ private final String _realm;
+ private final LoginService _loginService;
+ private IdentityService _identityService;
+
+ /**
+ * @param realm the realm name.
+ * @param loginService optional {@link LoginService} which can be used to assign roles to known users.
+ */
+ public AnyUserLoginService(String realm, LoginService loginService)
+ {
+ _realm = realm;
+ _loginService = loginService;
+ _identityService = (loginService == null) ? new DefaultIdentityService() : null;
+ }
+
+ @Override
+ public String getName()
+ {
+ return _realm;
+ }
+
+ @Override
+ public UserIdentity login(String username, Object credentials, Request request, Function getOrCreateSession)
+ {
+ if (_loginService != null)
+ {
+ UserIdentity login = _loginService.login(username, credentials, request, getOrCreateSession);
+ if (login != null)
+ return login;
+
+ UserPrincipal userPrincipal = new UserPrincipal(username, null);
+ Subject subject = new Subject();
+ subject.getPrincipals().add(userPrincipal);
+ if (credentials != null)
+ subject.getPrivateCredentials().add(credentials);
+ subject.setReadOnly();
+ return _loginService.getUserIdentity(subject, userPrincipal, true);
+ }
+
+ UserPrincipal userPrincipal = new UserPrincipal(username, null);
+ Subject subject = new Subject();
+ subject.getPrincipals().add(userPrincipal);
+ if (credentials != null)
+ subject.getPrivateCredentials().add(credentials);
+ subject.setReadOnly();
+ return _identityService.newUserIdentity(subject, userPrincipal, new String[0]);
+ }
+
+ @Override
+ public boolean validate(UserIdentity user)
+ {
+ if (_loginService == null)
+ return user != null;
+ return _loginService.validate(user);
+ }
+
+ @Override
+ public IdentityService getIdentityService()
+ {
+ return _loginService == null ? _identityService : _loginService.getIdentityService();
+ }
+
+ @Override
+ public void setIdentityService(IdentityService service)
+ {
+ if (_loginService != null)
+ _loginService.setIdentityService(service);
+ else
+ _identityService = service;
+ }
+
+ @Override
+ public void logout(UserIdentity user)
+ {
+ if (_loginService != null)
+ _loginService.logout(user);
+ }
+}
diff --git a/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/EthereumUtil.java b/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/EthereumUtil.java
new file mode 100644
index 00000000000..9e915f107fe
--- /dev/null
+++ b/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/EthereumUtil.java
@@ -0,0 +1,150 @@
+//
+// ========================================================================
+// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
+// which is available at https://www.apache.org/licenses/LICENSE-2.0.
+//
+// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
+// ========================================================================
+//
+
+package org.eclipse.jetty.security.siwe.internal;
+
+import java.math.BigInteger;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.util.Arrays;
+
+import org.bouncycastle.asn1.x9.X9ECParameters;
+import org.bouncycastle.asn1.x9.X9IntegerConverter;
+import org.bouncycastle.crypto.ec.CustomNamedCurves;
+import org.bouncycastle.crypto.params.ECDomainParameters;
+import org.bouncycastle.jcajce.provider.digest.Keccak;
+import org.bouncycastle.math.ec.ECAlgorithms;
+import org.bouncycastle.math.ec.ECPoint;
+import org.eclipse.jetty.security.siwe.EthereumAuthenticator;
+import org.eclipse.jetty.util.StringUtil;
+
+public class EthereumUtil
+{
+ public static final String PREFIX = "\u0019Ethereum Signed Message:\n";
+ private static final String NONCE_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ private static final SecureRandom RANDOM = new SecureRandom();
+ private static final int ADDRESS_LENGTH_BYTES = 20;
+ private static final X9ECParameters SEC_P256K1_PARAMS = CustomNamedCurves.getByName("secp256k1");
+ private static final ECDomainParameters DOMAIN_PARAMS = new ECDomainParameters(
+ SEC_P256K1_PARAMS.getCurve(), SEC_P256K1_PARAMS.getG(), SEC_P256K1_PARAMS.getN(), SEC_P256K1_PARAMS.getH());
+ private static final BigInteger PRIME = SEC_P256K1_PARAMS.getCurve().getField().getCharacteristic();
+ private static final X9IntegerConverter INT_CONVERTER = new X9IntegerConverter();
+ private static final Charset CHARSET = StandardCharsets.UTF_8;
+
+ private EthereumUtil()
+ {
+ }
+
+ /**
+ * Recover the Ethereum Address from the {@link EthereumAuthenticator.SignedMessage}.
+ *
+ * This uses algorithms and terminology defined in EIP-191 and
+ * ECDSA.
+ *
+ * @param signedMessage the signed message used to recover the address.
+ * @return the ethereum address recovered from the signature.
+ */
+ public static String recoverAddress(EthereumAuthenticator.SignedMessage signedMessage)
+ {
+ String siweMessage = signedMessage.message();
+ String signatureHex = signedMessage.signature();
+ if (StringUtil.asciiStartsWithIgnoreCase(signatureHex, "0x"))
+ signatureHex = signatureHex.substring(2);
+
+ int messageLength = siweMessage.getBytes(CHARSET).length;
+ String prefixedMessage = PREFIX + messageLength + siweMessage;
+ byte[] messageHash = keccak256(prefixedMessage.getBytes(CHARSET));
+ byte[] signatureBytes = StringUtil.fromHexString(signatureHex);
+
+ BigInteger r = new BigInteger(1, Arrays.copyOfRange(signatureBytes, 0, 32));
+ BigInteger s = new BigInteger(1, Arrays.copyOfRange(signatureBytes, 32, 64));
+ byte v = (byte)(signatureBytes[64] < 27 ? signatureBytes[64] : signatureBytes[64] - 27);
+
+ ECPoint qPoint = ecRecover(messageHash, v, r, s);
+ if (qPoint == null)
+ return null;
+ return toAddress(qPoint);
+ }
+
+ public static ECPoint ecRecover(byte[] hash, int v, BigInteger r, BigInteger s)
+ {
+ if (v < 0 || v >= 4)
+ throw new IllegalArgumentException("Invalid v value: " + v);
+
+ // Verify that r and s are integers in [1, n-1]. If not, the signature is invalid.
+ BigInteger n = DOMAIN_PARAMS.getN();
+ if (r.compareTo(BigInteger.ONE) < 0 || r.compareTo(n.subtract(BigInteger.ONE)) > 0)
+ return null;
+ if (s.compareTo(BigInteger.ONE) < 0 || s.compareTo(n.subtract(BigInteger.ONE)) > 0)
+ return null;
+
+ // Calculate the curve point R.
+ BigInteger x = r.add(BigInteger.valueOf(v / 2).multiply(n));
+ if (x.compareTo(PRIME) >= 0)
+ return null;
+ ECPoint rPoint = decodePoint(x, v);
+ if (!rPoint.multiply(n).isInfinity())
+ return null;
+
+ // Calculate the curve point Q = u1 * G + u2 * R, where u1=-zr^(-1)%n and u2=sr^(-1)%n.
+ // Note: for secp256k1 z=e as the hash is 256 bits and z is defined as the Ln leftmost bits of e.
+ BigInteger e = new BigInteger(1, hash);
+ BigInteger rInv = r.modInverse(n);
+ BigInteger u1 = e.negate().multiply(rInv).mod(n);
+ BigInteger u2 = s.multiply(rInv).mod(n);
+ return ECAlgorithms.sumOfTwoMultiplies(DOMAIN_PARAMS.getG(), u1, rPoint, u2);
+ }
+
+ public static String toAddress(ECPoint point)
+ {
+ // Remove the 1-byte prefix and return the public key as an ethereum address.
+ byte[] qBytes = point.getEncoded(false);
+ byte[] qHash = keccak256(qBytes, 1, qBytes.length - 1);
+ byte[] address = new byte[ADDRESS_LENGTH_BYTES];
+ System.arraycopy(qHash, qHash.length - ADDRESS_LENGTH_BYTES, address, 0, ADDRESS_LENGTH_BYTES);
+ return "0x" + StringUtil.toHexString(address);
+ }
+
+ public static ECPoint decodePoint(BigInteger p, int v)
+ {
+ byte[] encodedPoint = INT_CONVERTER.integerToBytes(p, 1 + INT_CONVERTER.getByteLength(DOMAIN_PARAMS.getCurve()));
+ encodedPoint[0] = (byte)((v % 2) == 0 ? 0x02 : 0x03);
+ return DOMAIN_PARAMS.getCurve().decodePoint(encodedPoint);
+ }
+
+ public static byte[] keccak256(byte[] bytes)
+ {
+ Keccak.Digest256 digest256 = new Keccak.Digest256();
+ return digest256.digest(bytes);
+ }
+
+ public static byte[] keccak256(byte[] buf, int offset, int len)
+ {
+ Keccak.Digest256 digest256 = new Keccak.Digest256();
+ digest256.update(buf, offset, len);
+ return digest256.digest();
+ }
+
+ public static String createNonce()
+ {
+ StringBuilder builder = new StringBuilder(8);
+ for (int i = 0; i < 8; i++)
+ {
+ int character = RANDOM.nextInt(NONCE_CHARACTERS.length());
+ builder.append(NONCE_CHARACTERS.charAt(character));
+ }
+
+ return builder.toString();
+ }
+}
diff --git a/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/SignInWithEthereumToken.java b/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/SignInWithEthereumToken.java
new file mode 100644
index 00000000000..a4e2a2780e1
--- /dev/null
+++ b/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/SignInWithEthereumToken.java
@@ -0,0 +1,144 @@
+//
+// ========================================================================
+// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
+// which is available at https://www.apache.org/licenses/LICENSE-2.0.
+//
+// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
+// ========================================================================
+//
+
+package org.eclipse.jetty.security.siwe.internal;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.function.Predicate;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.eclipse.jetty.security.ServerAuthException;
+import org.eclipse.jetty.security.siwe.EthereumAuthenticator;
+import org.eclipse.jetty.util.IncludeExcludeSet;
+import org.eclipse.jetty.util.StringUtil;
+
+/**
+ * Record representing a parsed SIWE message defined by EIP4361.
+ * @param scheme the URI scheme of the origin of the request.
+ * @param domain the domain that is requesting the signing.
+ * @param address the Ethereum address performing the signing.
+ * @param statement a human-readable ASCII assertion that the user will sign.
+ * @param uri an RFC 3986 URI referring to the resource that is the subject of the signing.
+ * @param version the version of the SIWE Message.
+ * @param chainId the Chain ID to which the session is bound.
+ * @param nonce a random string used to prevent replay attacks.
+ * @param issuedAt time when the message was generated.
+ * @param expirationTime time when the signed authentication message is no longer valid.
+ * @param notBefore time when the signed authentication message will become valid.
+ * @param requestId a system-specific request identifier.
+ * @param resources list of resources the user wishes to have resolved as part of authentication.
+ */
+public record SignInWithEthereumToken(String scheme,
+ String domain,
+ String address,
+ String statement,
+ String uri,
+ String version,
+ String chainId,
+ String nonce,
+ String issuedAt,
+ String expirationTime,
+ String notBefore,
+ String requestId,
+ String resources)
+{
+ private static final String SCHEME_PATTERN = "[a-zA-Z][a-zA-Z0-9+\\-.]*";
+ private static final String DOMAIN_PATTERN = "(?:[a-zA-Z0-9\\-._~%]+@)?[a-zA-Z0-9\\-._~%]+(?:\\:[0-9]+)?";
+ private static final String ADDRESS_PATTERN = "0x[0-9a-fA-F]{40}";
+ private static final String STATEMENT_PATTERN = "[^\\n]*";
+ private static final String URI_PATTERN = "[^\\n]+";
+ private static final String VERSION_PATTERN = "[0-9]+";
+ private static final String CHAIN_ID_PATTERN = "[0-9]+";
+ private static final String NONCE_PATTERN = "[a-zA-Z0-9]{8}";
+ private static final String DATE_TIME_PATTERN = "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})?";
+ private static final String REQUEST_ID_PATTERN = "[^\\n]*";
+ private static final String RESOURCE_PATTERN = "- " + URI_PATTERN;
+ private static final String RESOURCES_PATTERN = "(?:\n" + RESOURCE_PATTERN + ")*";
+ private static final Pattern SIGN_IN_WITH_ETHEREUM_PATTERN = Pattern.compile(
+ "^(?:(?" + SCHEME_PATTERN + ")://)?(?" + DOMAIN_PATTERN + ") wants you to sign in with your Ethereum account:\n" +
+ "(?" + ADDRESS_PATTERN + ")\n\n" +
+ "(?" + STATEMENT_PATTERN + ")?\n\n" +
+ "URI: (?" + URI_PATTERN + ")\n" +
+ "Version: (?" + VERSION_PATTERN + ")\n" +
+ "Chain ID: (?" + CHAIN_ID_PATTERN + ")\n" +
+ "Nonce: (?" + NONCE_PATTERN + ")\n" +
+ "Issued At: (?" + DATE_TIME_PATTERN + ")" +
+ "(?:\nExpiration Time: (?" + DATE_TIME_PATTERN + "))?" +
+ "(?:\nNot Before: (?" + DATE_TIME_PATTERN + "))?" +
+ "(?:\nRequest ID: (?" + REQUEST_ID_PATTERN + "))?" +
+ "(?:\nResources:(?" + RESOURCES_PATTERN + "))?$",
+ Pattern.DOTALL
+ );
+
+ /**
+ * Parses a SIWE Message into a {@link SignInWithEthereumToken},
+ * based off the ABNF Message Format from EIP-4361.
+ * @param message the SIWE message to parse.
+ * @return the {@link SignInWithEthereumToken} or null if it was not a valid SIWE message.
+ */
+ public static SignInWithEthereumToken from(String message)
+ {
+ Matcher matcher = SIGN_IN_WITH_ETHEREUM_PATTERN.matcher(message);
+ if (!matcher.matches())
+ return null;
+
+ return new SignInWithEthereumToken(matcher.group("scheme"), matcher.group("domain"),
+ matcher.group("address"), matcher.group("statement"), matcher.group("uri"),
+ matcher.group("version"), matcher.group("chainId"), matcher.group("nonce"),
+ matcher.group("issuedAt"), matcher.group("expirationTime"), matcher.group("notBefore"),
+ matcher.group("requestId"), matcher.group("resources"));
+ }
+
+ /**
+ * @param signedMessage the {@link EthereumAuthenticator.SignedMessage}.
+ * @param validateNonce a {@link Predicate} used to validate the nonce.
+ * @param domains the {@link IncludeExcludeSet} used to validate the domain.
+ * @param chainIds the {@link IncludeExcludeSet} used to validate the chainId.
+ * @throws ServerAuthException if the {@link EthereumAuthenticator.SignedMessage} fails validation.
+ */
+ public void validate(EthereumAuthenticator.SignedMessage signedMessage, Predicate validateNonce,
+ IncludeExcludeSet domains,
+ IncludeExcludeSet chainIds) throws ServerAuthException
+ {
+ if (validateNonce != null && !validateNonce.test(nonce()))
+ throw new ServerAuthException("invalid nonce " + nonce);
+
+ if (!StringUtil.asciiEqualsIgnoreCase(signedMessage.recoverAddress(), address()))
+ throw new ServerAuthException("signature verification failed");
+
+ if (!"1".equals(version()))
+ throw new ServerAuthException("unsupported version " + version);
+
+ LocalDateTime now = LocalDateTime.now();
+ if (StringUtil.isNotBlank(expirationTime()))
+ {
+ LocalDateTime expirationTime = LocalDateTime.parse(expirationTime(), DateTimeFormatter.ISO_DATE_TIME);
+ if (now.isAfter(expirationTime))
+ throw new ServerAuthException("expired SIWE message");
+ }
+
+ if (StringUtil.isNotBlank(notBefore()))
+ {
+ LocalDateTime notBefore = LocalDateTime.parse(notBefore(), DateTimeFormatter.ISO_DATE_TIME);
+ if (now.isBefore(notBefore))
+ throw new ServerAuthException("SIWE message not yet valid");
+ }
+
+ if (domains != null && !domains.test(domain()))
+ throw new ServerAuthException("unregistered domain: " + domain());
+ if (chainIds != null && !chainIds.test(chainId()))
+ throw new ServerAuthException("unregistered chainId: " + chainId());
+ }
+}
diff --git a/jetty-integrations/jetty-ethereum/src/main/resources/META-INF/services/org.eclipse.jetty.security.Authenticator$Factory b/jetty-integrations/jetty-ethereum/src/main/resources/META-INF/services/org.eclipse.jetty.security.Authenticator$Factory
new file mode 100644
index 00000000000..5e568c70188
--- /dev/null
+++ b/jetty-integrations/jetty-ethereum/src/main/resources/META-INF/services/org.eclipse.jetty.security.Authenticator$Factory
@@ -0,0 +1 @@
+org.eclipse.jetty.security.siwe.EthereumAuthenticatorFactory
diff --git a/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumParserTest.java b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumParserTest.java
new file mode 100644
index 00000000000..1b24651f642
--- /dev/null
+++ b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumParserTest.java
@@ -0,0 +1,157 @@
+//
+// ========================================================================
+// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
+// which is available at https://www.apache.org/licenses/LICENSE-2.0.
+//
+// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
+// ========================================================================
+//
+
+package org.eclipse.jetty.security.siwe;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.eclipse.jetty.security.siwe.internal.EthereumUtil;
+import org.eclipse.jetty.security.siwe.internal.SignInWithEthereumToken;
+import org.eclipse.jetty.security.siwe.util.SignInWithEthereumGenerator;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+public class SignInWithEthereumParserTest
+{
+ public static Stream specExamples()
+ {
+ List data = new ArrayList<>();
+
+ data.add(Arguments.of("""
+ example.com wants you to sign in with your Ethereum account:
+ 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
+
+ I accept the ExampleOrg Terms of Service: https://example.com/tos
+
+ URI: https://example.com/login
+ Version: 1
+ Chain ID: 1
+ Nonce: 32891756
+ Issued At: 2021-09-30T16:25:24Z
+ Resources:
+ - ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/
+ - https://example.com/my-web2-claim.json""",
+ null, "example.com"
+ ));
+
+
+ data.add(Arguments.of("""
+ example.com:3388 wants you to sign in with your Ethereum account:
+ 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
+
+ I accept the ExampleOrg Terms of Service: https://example.com/tos
+
+ URI: https://example.com/login
+ Version: 1
+ Chain ID: 1
+ Nonce: 32891756
+ Issued At: 2021-09-30T16:25:24Z
+ Resources:
+ - ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/
+ - https://example.com/my-web2-claim.json""",
+ null, "example.com:3388"
+ ));
+
+ data.add(Arguments.of("""
+ https://example.com wants you to sign in with your Ethereum account:
+ 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
+
+ I accept the ExampleOrg Terms of Service: https://example.com/tos
+
+ URI: https://example.com/login
+ Version: 1
+ Chain ID: 1
+ Nonce: 32891756
+ Issued At: 2021-09-30T16:25:24Z
+ Resources:
+ - ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/
+ - https://example.com/my-web2-claim.json""",
+ "https", "example.com"
+ ));
+
+ return data.stream();
+ }
+
+ @ParameterizedTest
+ @MethodSource("specExamples")
+ public void testSpecExamples(String message, String scheme, String domain)
+ {
+ SignInWithEthereumToken siwe = SignInWithEthereumToken.from(message);
+ assertNotNull(siwe);
+ assertThat(siwe.address(), equalTo("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"));
+ assertThat(siwe.issuedAt(), equalTo("2021-09-30T16:25:24Z"));
+ assertThat(siwe.uri(), equalTo("https://example.com/login"));
+ assertThat(siwe.version(), equalTo("1"));
+ assertThat(siwe.chainId(), equalTo("1"));
+ assertThat(siwe.nonce(), equalTo("32891756"));
+ assertThat(siwe.statement(), equalTo("I accept the ExampleOrg Terms of Service: https://example.com/tos"));
+ assertThat(siwe.scheme(), equalTo(scheme));
+ assertThat(siwe.domain(), equalTo(domain));
+
+ String resources = """
+
+ - ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/
+ - https://example.com/my-web2-claim.json""";
+ assertThat(siwe.resources(), equalTo(resources));
+ }
+
+ @Test
+ public void testFullMessage()
+ {
+ String scheme = "http";
+ String domain = "example.com";
+ String address = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
+ String statement = "This is the statement asking you to sign in.";
+ String uri = "https://example.com/login";
+ String version = "1";
+ String chainId = "1";
+ String nonce = EthereumUtil.createNonce();
+ LocalDateTime issuedAt = LocalDateTime.now();
+ LocalDateTime expirationTime = LocalDateTime.now().plusDays(1);
+ LocalDateTime notBefore = LocalDateTime.now().minusDays(1);
+ String requestId = "123456789";
+ String resources = """
+
+ - ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/
+ - https://example.com/my-web2-claim.json""";
+
+ String message = SignInWithEthereumGenerator.generateMessage(scheme, domain, address, statement, uri, version, chainId, nonce, issuedAt,
+ expirationTime, notBefore, requestId, resources);
+
+ SignInWithEthereumToken siwe = SignInWithEthereumToken.from(message);
+ assertNotNull(siwe);
+ assertThat(siwe.scheme(), equalTo(scheme));
+ assertThat(siwe.domain(), equalTo(domain));
+ assertThat(siwe.address(), equalTo(address));
+ assertThat(siwe.statement(), equalTo(statement));
+ assertThat(siwe.uri(), equalTo(uri));
+ assertThat(siwe.version(), equalTo(version));
+ assertThat(siwe.chainId(), equalTo(chainId));
+ assertThat(siwe.nonce(), equalTo(nonce));
+ assertThat(siwe.issuedAt(), equalTo(issuedAt.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)));
+ assertThat(siwe.expirationTime(), equalTo(expirationTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)));
+ assertThat(siwe.notBefore(), equalTo(notBefore.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)));
+ assertThat(siwe.requestId(), equalTo(requestId));
+ assertThat(siwe.resources(), equalTo(resources));
+ }
+}
diff --git a/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTest.java b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTest.java
new file mode 100644
index 00000000000..9707c51d0e3
--- /dev/null
+++ b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTest.java
@@ -0,0 +1,277 @@
+//
+// ========================================================================
+// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
+// which is available at https://www.apache.org/licenses/LICENSE-2.0.
+//
+// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
+// ========================================================================
+//
+
+package org.eclipse.jetty.security.siwe;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+import org.eclipse.jetty.client.ContentResponse;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.MultiPartRequestContent;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.http.HttpStatus;
+import org.eclipse.jetty.http.MultiPart;
+import org.eclipse.jetty.security.AuthenticationState;
+import org.eclipse.jetty.security.Constraint;
+import org.eclipse.jetty.security.SecurityHandler;
+import org.eclipse.jetty.security.siwe.util.EthereumCredentials;
+import org.eclipse.jetty.security.siwe.util.SignInWithEthereumGenerator;
+import org.eclipse.jetty.server.Handler;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Response;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.server.handler.ContextHandler;
+import org.eclipse.jetty.session.SessionHandler;
+import org.eclipse.jetty.util.BufferUtil;
+import org.eclipse.jetty.util.Callback;
+import org.eclipse.jetty.util.ajax.JSON;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class SignInWithEthereumTest
+{
+ private final EthereumCredentials _credentials = new EthereumCredentials();
+ private Server _server;
+ private ServerConnector _connector;
+ private EthereumAuthenticator _authenticator;
+ private HttpClient _client;
+
+ @BeforeEach
+ public void before() throws Exception
+ {
+ _server = new Server();
+ _connector = new ServerConnector(_server);
+ _server.addConnector(_connector);
+
+ Handler.Abstract handler = new Handler.Abstract()
+ {
+ @Override
+ public boolean handle(Request request, Response response, Callback callback) throws Exception
+ {
+ String pathInContext = Request.getPathInContext(request);
+ if ("/error".equals(pathInContext))
+ {
+ response.write(true, BufferUtil.toBuffer("ERROR"), callback);
+ return true;
+ }
+ if ("/login".equals(pathInContext))
+ {
+ response.write(true, BufferUtil.toBuffer("Please Login"), callback);
+ return true;
+ }
+ else if ("/logout".equals(pathInContext))
+ {
+ AuthenticationState.logout(request, response);
+ callback.succeeded();
+ return true;
+ }
+
+ AuthenticationState authState = Objects.requireNonNull(AuthenticationState.getAuthenticationState(request));
+ response.write(true, BufferUtil.toBuffer("UserPrincipal: " + authState.getUserPrincipal()), callback);
+ return true;
+ }
+ };
+
+ _authenticator = new EthereumAuthenticator();
+
+ SecurityHandler.PathMapped securityHandler = new SecurityHandler.PathMapped();
+ securityHandler.setAuthenticator(_authenticator);
+ securityHandler.setHandler(handler);
+ securityHandler.setParameter(EthereumAuthenticator.LOGIN_PATH_PARAM, "/login");
+ securityHandler.put("/*", Constraint.ANY_USER);
+
+ SessionHandler sessionHandler = new SessionHandler();
+ sessionHandler.setHandler(securityHandler);
+
+ ContextHandler contextHandler = new ContextHandler();
+ contextHandler.setContextPath("/");
+ contextHandler.setHandler(sessionHandler);
+
+ _server.setHandler(contextHandler);
+ _server.start();
+
+ _client = new HttpClient();
+ _client.start();
+ }
+
+ @AfterEach
+ public void after() throws Exception
+ {
+ _client.stop();
+ _server.stop();
+ }
+
+ @Test
+ public void testLoginLogoutSequence() throws Exception
+ {
+ _client.setFollowRedirects(false);
+
+ // Initial request redirects to /login.html
+ ContentResponse response = _client.GET("http://localhost:" + _connector.getLocalPort() + "/admin");
+ assertTrue(HttpStatus.isRedirection(response.getStatus()), "HttpStatus was not redirect: " + response.getStatus());
+ assertThat(response.getHeaders().get(HttpHeader.LOCATION), equalTo("/login"));
+
+ // Request to Login page bypasses security constraints.
+ response = _client.GET("http://localhost:" + _connector.getLocalPort() + "/login");
+ assertThat(response.getStatus(), equalTo(HttpStatus.OK_200));
+ assertThat(response.getContentAsString(), equalTo("Please Login"));
+
+ // We can get a nonce from the server without being logged in.
+ String nonce = getNonce();
+
+ // Create ethereum credentials to login, and sign a login message.
+ String siweMessage = SignInWithEthereumGenerator.generateMessage(_connector.getLocalPort(), _credentials.getAddress(), nonce);
+ EthereumAuthenticator.SignedMessage signedMessage = _credentials.signMessage(siweMessage);
+
+ // Send an Authentication request with the signed SIWE message, this should redirect back to initial request.
+ response = sendAuthRequest(signedMessage);
+ assertTrue(HttpStatus.isRedirection(response.getStatus()), "HttpStatus was not redirect: " + response.getStatus());
+ assertThat(response.getHeaders().get(HttpHeader.LOCATION), equalTo("/admin"));
+
+ // Now we are logged in a request to /admin succeeds.
+ response = _client.GET("http://localhost:" + _connector.getLocalPort() + "/admin");
+ assertThat(response.getStatus(), equalTo(HttpStatus.OK_200));
+ assertThat(response.getContentAsString(), equalTo("UserPrincipal: " + _credentials.getAddress()));
+
+ // We are unauthenticated after logging out.
+ response = _client.GET("http://localhost:" + _connector.getLocalPort() + "/logout");
+ assertThat(response.getStatus(), equalTo(HttpStatus.OK_200));
+ response = _client.GET("http://localhost:" + _connector.getLocalPort() + "/admin");
+ assertTrue(HttpStatus.isRedirection(response.getStatus()), "HttpStatus was not redirect: " + response.getStatus());
+ assertThat(response.getHeaders().get(HttpHeader.LOCATION), equalTo("/login"));
+ }
+
+ @Test
+ public void testAuthRequestTooLarge() throws Exception
+ {
+ int maxMessageSize = 1024 * 4;
+ _authenticator.setMaxMessageSize(maxMessageSize);
+
+ MultiPartRequestContent content = new MultiPartRequestContent();
+ String message = "x".repeat(maxMessageSize + 1);
+ content.addPart(new MultiPart.ByteBufferPart("message", null, null, BufferUtil.toBuffer(message)));
+ content.close();
+ ContentResponse response = _client.newRequest("localhost", _connector.getLocalPort())
+ .path("/auth/login")
+ .method(HttpMethod.POST)
+ .body(content)
+ .send();
+
+ assertThat(response.getStatus(), equalTo(HttpStatus.FORBIDDEN_403));
+ assertThat(response.getContentAsString(), containsString("SIWE Message Too Large"));
+ }
+
+ @Test
+ public void testInvalidNonce() throws Exception
+ {
+ ContentResponse response;
+ String nonce = getNonce();
+
+ // Create ethereum credentials to login, and sign a login message.
+ String siweMessage = SignInWithEthereumGenerator.generateMessage(_connector.getLocalPort(), _credentials.getAddress(), nonce);
+ EthereumAuthenticator.SignedMessage signedMessage = _credentials.signMessage(siweMessage);
+
+ // Initial authentication should succeed because it has a valid nonce.
+ response = sendAuthRequest(signedMessage);
+ assertThat(response.getStatus(), equalTo(HttpStatus.OK_200));
+ assertThat(response.getContentAsString(), equalTo("UserPrincipal: " + _credentials.getAddress()));
+
+ // Ensure we are logged out.
+ response = _client.GET("http://localhost:" + _connector.getLocalPort() + "/logout");
+ assertThat(response.getStatus(), equalTo(HttpStatus.OK_200));
+ response = _client.GET("http://localhost:" + _connector.getLocalPort() + "/admin");
+ assertThat(response.getContentAsString(), equalTo("Please Login"));
+
+ // Replay the exact same request, and it should now fail because the nonce is invalid.
+ response = sendAuthRequest(signedMessage);
+ assertThat(response.getStatus(), equalTo(HttpStatus.FORBIDDEN_403));
+ assertThat(response.getContentAsString(), containsString("invalid nonce"));
+ }
+
+ @Test
+ public void testEnforceDomain() throws Exception
+ {
+ _authenticator.includeDomains("example.com");
+
+ // Test login with invalid domain.
+ String nonce = getNonce();
+ String siweMessage = SignInWithEthereumGenerator.generateMessage(null, "localhost", _credentials.getAddress(), nonce);
+ ContentResponse response = sendAuthRequest(_credentials.signMessage(siweMessage));
+ assertThat(response.getStatus(), equalTo(HttpStatus.FORBIDDEN_403));
+ assertThat(response.getContentAsString(), containsString("unregistered domain"));
+
+ // Test login with valid domain.
+ nonce = getNonce();
+ siweMessage = SignInWithEthereumGenerator.generateMessage(null, "example.com", _credentials.getAddress(), nonce);
+ response = sendAuthRequest(_credentials.signMessage(siweMessage));
+ assertThat(response.getStatus(), equalTo(HttpStatus.OK_200));
+ assertThat(response.getContentAsString(), containsString("UserPrincipal: " + _credentials.getAddress()));
+ }
+
+ @Test
+ public void testEnforceChainId() throws Exception
+ {
+ _authenticator.includeChainIds("1");
+
+ // Test login with invalid chainId.
+ String nonce = getNonce();
+ String siweMessage = SignInWithEthereumGenerator.generateMessage(null, "localhost", _credentials.getAddress(), nonce, "2");
+ ContentResponse response = sendAuthRequest(_credentials.signMessage(siweMessage));
+ assertThat(response.getStatus(), equalTo(HttpStatus.FORBIDDEN_403));
+ assertThat(response.getContentAsString(), containsString("unregistered chainId"));
+
+ // Test login with valid chainId.
+ nonce = getNonce();
+ siweMessage = SignInWithEthereumGenerator.generateMessage(null, "localhost", _credentials.getAddress(), nonce, "1");
+ response = sendAuthRequest(_credentials.signMessage(siweMessage));
+ assertThat(response.getStatus(), equalTo(HttpStatus.OK_200));
+ assertThat(response.getContentAsString(), containsString("UserPrincipal: " + _credentials.getAddress()));
+ }
+
+ private ContentResponse sendAuthRequest(EthereumAuthenticator.SignedMessage signedMessage) throws ExecutionException, InterruptedException, TimeoutException
+ {
+ MultiPartRequestContent content = new MultiPartRequestContent();
+ content.addPart(new MultiPart.ByteBufferPart("signature", null, null, BufferUtil.toBuffer(signedMessage.signature())));
+ content.addPart(new MultiPart.ByteBufferPart("message", null, null, BufferUtil.toBuffer(signedMessage.message())));
+ content.close();
+ return _client.newRequest("localhost", _connector.getLocalPort())
+ .path("/auth/login")
+ .method(HttpMethod.POST)
+ .body(content)
+ .send();
+ }
+
+ private String getNonce() throws ExecutionException, InterruptedException, TimeoutException
+ {
+ ContentResponse response = _client.GET("http://localhost:" + _connector.getLocalPort() + "/auth/nonce");
+ assertThat(response.getStatus(), equalTo(HttpStatus.OK_200));
+
+ @SuppressWarnings("unchecked")
+ Map parsed = (Map)new JSON().parse(new JSON.StringSource(response.getContentAsString()));
+ String nonce = (String)parsed.get("nonce");
+ assertThat(nonce.length(), equalTo(8));
+
+ return nonce;
+ }
+}
diff --git a/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTokenTest.java b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTokenTest.java
new file mode 100644
index 00000000000..a04a6d5b3c6
--- /dev/null
+++ b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTokenTest.java
@@ -0,0 +1,234 @@
+//
+// ========================================================================
+// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
+// which is available at https://www.apache.org/licenses/LICENSE-2.0.
+//
+// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
+// ========================================================================
+//
+
+package org.eclipse.jetty.security.siwe;
+
+import java.time.LocalDateTime;
+import java.util.function.Predicate;
+
+import org.eclipse.jetty.security.siwe.internal.EthereumUtil;
+import org.eclipse.jetty.security.siwe.internal.SignInWithEthereumToken;
+import org.eclipse.jetty.security.siwe.util.EthereumCredentials;
+import org.eclipse.jetty.security.siwe.util.SignInWithEthereumGenerator;
+import org.eclipse.jetty.util.IncludeExcludeSet;
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class SignInWithEthereumTokenTest
+{
+ @Test
+ public void testInvalidVersion() throws Exception
+ {
+ EthereumCredentials credentials = new EthereumCredentials();
+ LocalDateTime issuedAt = LocalDateTime.now();
+ String message = SignInWithEthereumGenerator.generateMessage(
+ null,
+ "example.com",
+ credentials.getAddress(),
+ "hello this is the statement",
+ "https://example.com",
+ "2",
+ "1",
+ EthereumUtil.createNonce(),
+ issuedAt,
+ null, null, null, null
+ );
+
+ EthereumAuthenticator.SignedMessage signedMessage = credentials.signMessage(message);
+ SignInWithEthereumToken siwe = SignInWithEthereumToken.from(message);
+ assertNotNull(siwe);
+
+ Throwable error = assertThrows(Throwable.class, () ->
+ siwe.validate(signedMessage, null, null, null));
+ assertThat(error.getMessage(), containsString("unsupported version"));
+ }
+
+ @Test
+ public void testExpirationTime() throws Exception
+ {
+ EthereumCredentials credentials = new EthereumCredentials();
+ LocalDateTime issuedAt = LocalDateTime.now().minusSeconds(10);
+ LocalDateTime expiry = LocalDateTime.now();
+ String message = SignInWithEthereumGenerator.generateMessage(
+ null,
+ "example.com",
+ credentials.getAddress(),
+ "hello this is the statement",
+ "https://example.com",
+ "1",
+ "1",
+ EthereumUtil.createNonce(),
+ issuedAt,
+ expiry,
+ null, null, null
+ );
+
+ EthereumAuthenticator.SignedMessage signedMessage = credentials.signMessage(message);
+ SignInWithEthereumToken siwe = SignInWithEthereumToken.from(message);
+ assertNotNull(siwe);
+
+ Throwable error = assertThrows(Throwable.class, () ->
+ siwe.validate(signedMessage, null, null, null));
+ assertThat(error.getMessage(), containsString("expired SIWE message"));
+ }
+
+ @Test
+ public void testNotBefore() throws Exception
+ {
+ EthereumCredentials credentials = new EthereumCredentials();
+ LocalDateTime issuedAt = LocalDateTime.now();
+ LocalDateTime notBefore = issuedAt.plusMinutes(10);
+ String message = SignInWithEthereumGenerator.generateMessage(
+ null,
+ "example.com",
+ credentials.getAddress(),
+ "hello this is the statement",
+ "https://example.com",
+ "1",
+ "1",
+ EthereumUtil.createNonce(),
+ issuedAt,
+ null,
+ notBefore,
+ null, null
+ );
+
+ EthereumAuthenticator.SignedMessage signedMessage = credentials.signMessage(message);
+ SignInWithEthereumToken siwe = SignInWithEthereumToken.from(message);
+ assertNotNull(siwe);
+
+ Throwable error = assertThrows(Throwable.class, () ->
+ siwe.validate(signedMessage, null, null, null));
+ assertThat(error.getMessage(), containsString("SIWE message not yet valid"));
+ }
+
+ @Test
+ public void testInvalidDomain() throws Exception
+ {
+ EthereumCredentials credentials = new EthereumCredentials();
+ LocalDateTime issuedAt = LocalDateTime.now();
+ String message = SignInWithEthereumGenerator.generateMessage(
+ null,
+ "example.com",
+ credentials.getAddress(),
+ "hello this is the statement",
+ "https://example.com",
+ "1",
+ "1",
+ EthereumUtil.createNonce(),
+ issuedAt,
+ null, null, null, null
+ );
+
+ EthereumAuthenticator.SignedMessage signedMessage = credentials.signMessage(message);
+ SignInWithEthereumToken siwe = SignInWithEthereumToken.from(message);
+ assertNotNull(siwe);
+
+ IncludeExcludeSet domains = new IncludeExcludeSet<>();
+ domains.include("example.org");
+
+ Throwable error = assertThrows(Throwable.class, () ->
+ siwe.validate(signedMessage, null, domains, null));
+ assertThat(error.getMessage(), containsString("unregistered domain"));
+ }
+
+ @Test
+ public void testInvalidChainId() throws Exception
+ {
+ EthereumCredentials credentials = new EthereumCredentials();
+ LocalDateTime issuedAt = LocalDateTime.now();
+ String message = SignInWithEthereumGenerator.generateMessage(
+ "https",
+ "example.com",
+ credentials.getAddress(),
+ "hello this is the statement",
+ "https://example.com",
+ "1",
+ "1",
+ EthereumUtil.createNonce(),
+ issuedAt,
+ null, null, null, null
+ );
+
+ EthereumAuthenticator.SignedMessage signedMessage = credentials.signMessage(message);
+ SignInWithEthereumToken siwe = SignInWithEthereumToken.from(message);
+ assertNotNull(siwe);
+
+ IncludeExcludeSet chainIds = new IncludeExcludeSet<>();
+ chainIds.include("1337");
+
+ Throwable error = assertThrows(Throwable.class, () ->
+ siwe.validate(signedMessage, null, null, chainIds));
+ assertThat(error.getMessage(), containsString("unregistered chainId"));
+ }
+
+ @Test
+ public void testInvalidNonce() throws Exception
+ {
+ EthereumCredentials credentials = new EthereumCredentials();
+ LocalDateTime issuedAt = LocalDateTime.now();
+ String message = SignInWithEthereumGenerator.generateMessage(
+ "https",
+ "example.com",
+ credentials.getAddress(),
+ "hello this is the statement",
+ "https://example.com",
+ "1",
+ "1",
+ EthereumUtil.createNonce(),
+ issuedAt,
+ null, null, null, null
+ );
+
+ EthereumAuthenticator.SignedMessage signedMessage = credentials.signMessage(message);
+ SignInWithEthereumToken siwe = SignInWithEthereumToken.from(message);
+ assertNotNull(siwe);
+
+ Predicate nonceValidation = nonce -> false;
+ Throwable error = assertThrows(Throwable.class, () ->
+ siwe.validate(signedMessage, nonceValidation, null, null));
+ assertThat(error.getMessage(), containsString("invalid nonce"));
+ }
+
+ @Test
+ public void testValidToken() throws Exception
+ {
+ EthereumCredentials credentials = new EthereumCredentials();
+ LocalDateTime issuedAt = LocalDateTime.now();
+ String message = SignInWithEthereumGenerator.generateMessage(
+ "https",
+ "example.com",
+ credentials.getAddress(),
+ "hello this is the statement",
+ "https://example.com",
+ "1",
+ "1",
+ EthereumUtil.createNonce(),
+ issuedAt,
+ null, null, null, null
+ );
+
+ EthereumAuthenticator.SignedMessage signedMessage = credentials.signMessage(message);
+ SignInWithEthereumToken siwe = SignInWithEthereumToken.from(message);
+ assertNotNull(siwe);
+
+ Predicate nonceValidation = nonce -> true;
+ assertDoesNotThrow(() ->
+ siwe.validate(signedMessage, nonceValidation, null, null));
+ }
+}
diff --git a/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignatureVerificationTest.java b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignatureVerificationTest.java
new file mode 100644
index 00000000000..6a2d3811915
--- /dev/null
+++ b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignatureVerificationTest.java
@@ -0,0 +1,35 @@
+//
+// ========================================================================
+// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
+// which is available at https://www.apache.org/licenses/LICENSE-2.0.
+//
+// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
+// ========================================================================
+//
+
+package org.eclipse.jetty.security.siwe;
+
+import org.eclipse.jetty.security.siwe.util.EthereumCredentials;
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalToIgnoringCase;
+
+public class SignatureVerificationTest
+{
+ private final EthereumCredentials credentials = new EthereumCredentials();
+
+ @Test
+ public void testSignatureVerification() throws Exception
+ {
+ String siweMessage = "hello world";
+ EthereumAuthenticator.SignedMessage signedMessage = credentials.signMessage(siweMessage);
+ String address = credentials.getAddress();
+ String recoveredAddress = signedMessage.recoverAddress();
+ assertThat(recoveredAddress, equalToIgnoringCase(address));
+ }
+}
diff --git a/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/util/EthereumCredentials.java b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/util/EthereumCredentials.java
new file mode 100644
index 00000000000..b2b07b91e64
--- /dev/null
+++ b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/util/EthereumCredentials.java
@@ -0,0 +1,129 @@
+//
+// ========================================================================
+// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
+// which is available at https://www.apache.org/licenses/LICENSE-2.0.
+//
+// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
+// ========================================================================
+//
+
+package org.eclipse.jetty.security.siwe.util;
+
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.SecureRandom;
+import java.security.Signature;
+import java.security.spec.ECGenParameterSpec;
+import java.util.Arrays;
+
+import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.math.ec.ECPoint;
+import org.bouncycastle.util.encoders.Hex;
+import org.eclipse.jetty.security.siwe.EthereumAuthenticator;
+import org.eclipse.jetty.security.siwe.internal.EthereumUtil;
+
+import static org.eclipse.jetty.security.siwe.internal.EthereumUtil.keccak256;
+
+/**
+ * Test utility to generate an ethereum address and use it to sign messages.
+ */
+public class EthereumCredentials
+{
+ private final PrivateKey privateKey;
+ private final PublicKey publicKey;
+ private final String address;
+ private final BouncyCastleProvider provider = new BouncyCastleProvider();
+
+ public EthereumCredentials()
+ {
+ try
+ {
+ KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC", provider);
+ ECGenParameterSpec ecGenParameterSpec = new ECGenParameterSpec("secp256k1");
+ keyPairGenerator.initialize(ecGenParameterSpec, new SecureRandom());
+ KeyPair keyPair = keyPairGenerator.generateKeyPair();
+ this.privateKey = keyPair.getPrivate();
+ this.publicKey = keyPair.getPublic();
+ this.address = EthereumUtil.toAddress(((BCECPublicKey)publicKey).getQ());
+ }
+ catch (Exception e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public String getAddress()
+ {
+ return address;
+ }
+
+ public EthereumAuthenticator.SignedMessage signMessage(String message) throws Exception
+ {
+ byte[] messageBytes = message.getBytes(StandardCharsets.ISO_8859_1);
+ String prefix = "\u0019Ethereum Signed Message:\n" + messageBytes.length + message;
+ byte[] messageHash = keccak256(prefix.getBytes(StandardCharsets.ISO_8859_1));
+
+ Signature ecdsaSign = Signature.getInstance("NONEwithECDSA", provider);
+ ecdsaSign.initSign(privateKey);
+ ecdsaSign.update(messageHash);
+ byte[] encodedSignature = ecdsaSign.sign();
+ byte[] r = getR(encodedSignature);
+ byte[] s = getS(encodedSignature);
+
+ byte[] signature = new byte[65];
+ System.arraycopy(r, 0, signature, 0, 32);
+ System.arraycopy(s, 0, signature, 32, 32);
+ signature[64] = (byte)(calculateV(messageHash, r, s) + 27);
+ return new EthereumAuthenticator.SignedMessage(message, Hex.toHexString(signature));
+ }
+
+ private byte[] getR(byte[] encodedSignature)
+ {
+ int rLength = encodedSignature[3];
+ byte[] r = Arrays.copyOfRange(encodedSignature, 4, 4 + rLength);
+ return ensure32Bytes(r);
+ }
+
+ private byte[] getS(byte[] encodedSignature)
+ {
+ int rLength = encodedSignature[3];
+ int sLength = encodedSignature[5 + rLength];
+ byte[] s = Arrays.copyOfRange(encodedSignature, 6 + rLength, 6 + rLength + sLength);
+ return ensure32Bytes(s);
+ }
+
+ private byte[] ensure32Bytes(byte[] bytes)
+ {
+ if (bytes.length == 32)
+ return bytes;
+ if (bytes.length > 32)
+ return Arrays.copyOfRange(bytes, bytes.length - 32, bytes.length);
+ else
+ {
+ byte[] padded = new byte[32];
+ System.arraycopy(bytes, 0, padded, 32 - bytes.length, bytes.length);
+ return padded;
+ }
+ }
+
+ private byte calculateV(byte[] hash, byte[] r, byte[] s)
+ {
+ ECPoint publicKeyPoint = ((BCECPublicKey)publicKey).getQ();
+ for (int v = 0; v < 4; v++)
+ {
+ ECPoint qPoint = EthereumUtil.ecRecover(hash, v, new BigInteger(1, r), new BigInteger(1, s));
+ if (qPoint != null && qPoint.equals(publicKeyPoint))
+ return (byte)v;
+ }
+ throw new RuntimeException("Could not recover public key from signature");
+ }
+}
diff --git a/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/util/SignInWithEthereumGenerator.java b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/util/SignInWithEthereumGenerator.java
new file mode 100644
index 00000000000..dc52f9121de
--- /dev/null
+++ b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/util/SignInWithEthereumGenerator.java
@@ -0,0 +1,107 @@
+//
+// ========================================================================
+// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
+// which is available at https://www.apache.org/licenses/LICENSE-2.0.
+//
+// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
+// ========================================================================
+//
+
+package org.eclipse.jetty.security.siwe.util;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * A utility to generate Sign-In with Ethereum message to be used for testing.
+ */
+public class SignInWithEthereumGenerator
+{
+ private SignInWithEthereumGenerator()
+ {
+ }
+
+ public static String generateMessage(int port, String address, String nonce)
+ {
+ return generateMessage(null, "localhost:" + port, address, nonce, null, null);
+ }
+
+ public static String generateMessage(String scheme, String domain, String address, String nonce)
+ {
+ return generateMessage(scheme, domain, address, nonce, null, null);
+ }
+
+ public static String generateMessage(String scheme, String domain, String address, String nonce, String chainId)
+ {
+ return generateMessage(scheme,
+ domain,
+ address,
+ "I accept the MetaMask Terms of Service: https://community.metamask.io/tos",
+ "http://" + domain,
+ "1",
+ chainId,
+ nonce,
+ LocalDateTime.now(),
+ null,
+ null,
+ null,
+ null);
+ }
+
+ public static String generateMessage(String scheme, String domain, String address, String nonce, LocalDateTime expiresAt, LocalDateTime notBefore)
+ {
+ return generateMessage(scheme,
+ domain,
+ address,
+ "I accept the MetaMask Terms of Service: https://community.metamask.io/tos",
+ "http://" + domain,
+ "1",
+ "1",
+ nonce,
+ LocalDateTime.now(),
+ expiresAt,
+ notBefore,
+ null,
+ null);
+ }
+
+ public static String generateMessage(String scheme,
+ String domain,
+ String address,
+ String statement,
+ String uri,
+ String version,
+ String chainId,
+ String nonce,
+ LocalDateTime issuedAt,
+ LocalDateTime expirationTime,
+ LocalDateTime notBefore,
+ String requestId,
+ String resources)
+ {
+ StringBuilder sb = new StringBuilder();
+ if (scheme != null)
+ sb.append(scheme).append("://");
+ sb.append(domain).append(" wants you to sign in with your Ethereum account:\n");
+ sb.append(address).append("\n\n");
+ sb.append(statement).append("\n\n");
+ sb.append("URI: ").append(uri).append("\n");
+ sb.append("Version: ").append(version).append("\n");
+ sb.append("Chain ID: ").append(chainId).append("\n");
+ sb.append("Nonce: ").append(nonce).append("\n");
+ sb.append("Issued At: ").append(issuedAt.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
+ if (expirationTime != null)
+ sb.append("\nExpiration Time: ").append(expirationTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
+ if (notBefore != null)
+ sb.append("\nNot Before: ").append(notBefore.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
+ if (requestId != null)
+ sb.append("\nRequest ID: ").append(requestId);
+ if (resources != null)
+ sb.append("\nResources:").append(resources);
+ return sb.toString();
+ }
+}
diff --git a/jetty-integrations/jetty-ethereum/src/test/resources/jetty-logging.properties b/jetty-integrations/jetty-ethereum/src/test/resources/jetty-logging.properties
new file mode 100755
index 00000000000..a5c0825874c
--- /dev/null
+++ b/jetty-integrations/jetty-ethereum/src/test/resources/jetty-logging.properties
@@ -0,0 +1,4 @@
+# Jetty Logging using jetty-slf4j-impl
+# org.eclipse.jetty.LEVEL=DEBUG
+# org.eclipse.jetty.security.siwe.LEVEL=DEBUG
+# org.eclipse.jetty.session.LEVEL=DEBUG
\ No newline at end of file
diff --git a/jetty-integrations/pom.xml b/jetty-integrations/pom.xml
index 2761a5fa8b1..c1785dba714 100644
--- a/jetty-integrations/pom.xml
+++ b/jetty-integrations/pom.xml
@@ -12,6 +12,7 @@
Integrations
+ jetty-ethereum
jetty-gcloud
jetty-hazelcast
jetty-infinispan
diff --git a/pom.xml b/pom.xml
index 79fbcf407e7..69bc91da4f7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -169,6 +169,7 @@
9.7
4.2.1
7.0.0
+ 1.78.1
3.6.0
1.5
3.2.0
@@ -700,6 +701,22 @@
awaitility
${awaitility.version}