diff --git a/jetty-core/jetty-bom/pom.xml b/jetty-core/jetty-bom/pom.xml
index 8e20f8dde79..b66004c4e84 100644
--- a/jetty-core/jetty-bom/pom.xml
+++ b/jetty-core/jetty-bom/pom.xml
@@ -95,6 +95,11 @@
jetty-openid12.0.11-SNAPSHOT
+
+ org.eclipse.jetty
+ jetty-siwe
+ 12.0.11-SNAPSHOT
+ org.eclipse.jettyjetty-osgi
diff --git a/jetty-core/jetty-openid/pom.xml b/jetty-core/jetty-openid/pom.xml
index b1d90b1f73d..8e6a76c28c4 100644
--- a/jetty-core/jetty-openid/pom.xml
+++ b/jetty-core/jetty-openid/pom.xml
@@ -8,7 +8,7 @@
12.0.11-SNAPSHOTjetty-openid
- EE10 :: OpenID
+ Core :: OpenIDJetty OpenID Connect Infrastructure
diff --git a/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/Authenticator.java b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/Authenticator.java
index d992532b9dd..210187dd046 100644
--- a/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/Authenticator.java
+++ b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/Authenticator.java
@@ -42,6 +42,7 @@ public interface Authenticator
String SPNEGO_AUTH = "SPNEGO";
String NEGOTIATE_AUTH = "NEGOTIATE";
String OPENID_AUTH = "OPENID";
+ String SIWE_AUTH = "SIWE";
/**
* Configure the Authenticator
diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/FormFields.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/FormFields.java
index ed0ba41fcfc..a3558caf89c 100644
--- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/FormFields.java
+++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/FormFields.java
@@ -176,7 +176,7 @@ public class FormFields extends ContentSourceCompletableFuture
* @param maxLength The maximum total size of the fields
* @return A {@link CompletableFuture} that will provide the {@link Fields} or a failure.
*/
- static CompletableFuture from(Content.Source source, Attributes attributes, Charset charset, int maxFields, int maxLength)
+ public static CompletableFuture from(Content.Source source, Attributes attributes, Charset charset, int maxFields, int maxLength)
{
Object attr = attributes.getAttribute(FormFields.class.getName());
if (attr instanceof FormFields futureFormFields)
diff --git a/jetty-core/jetty-siwe/pom.xml b/jetty-core/jetty-siwe/pom.xml
new file mode 100644
index 00000000000..547736f102a
--- /dev/null
+++ b/jetty-core/jetty-siwe/pom.xml
@@ -0,0 +1,89 @@
+
+
+
+ 4.0.0
+
+ org.eclipse.jetty
+ jetty-core
+ 12.0.11-SNAPSHOT
+
+ jetty-siwe
+ Core :: Sign-In with Ethereum
+ Jetty Sign-In with Ethereum
+
+
+ 1.78.1
+ ${project.groupId}.siwe
+
+
+
+
+ org.bouncycastle
+ bcpkix-jdk15to18
+ ${bouncycastle.version}
+
+
+ org.bouncycastle
+ bcprov-jdk15to18
+ ${bouncycastle.version}
+
+
+ org.eclipse.jetty
+ jetty-client
+
+
+ org.eclipse.jetty
+ jetty-security
+
+
+ org.eclipse.jetty
+ jetty-server
+
+
+ org.eclipse.jetty
+ jetty-util-ajax
+
+
+ org.slf4j
+ slf4j-api
+
+
+ org.eclipse.jetty
+ jetty-session
+ test
+
+
+ org.eclipse.jetty
+ jetty-slf4j-impl
+ test
+
+
+ org.eclipse.jetty.toolchain
+ jetty-test-helper
+ test
+
+
+
+
+
+
+ org.apache.felix
+ maven-bundle-plugin
+ true
+
+
+
+ manifest
+
+
+
+ osgi.extender; filter:="(osgi.extender=osgi.serviceloader.registrar)"
+ osgi.serviceloader;osgi.serviceloader=org.eclipse.jetty.security.Authenticator$Factory
+
+
+
+
+
+
+
+
diff --git a/jetty-core/jetty-siwe/src/main/java/module-info.java b/jetty-core/jetty-siwe/src/main/java/module-info.java
new file mode 100644
index 00000000000..225d014e83c
--- /dev/null
+++ b/jetty-core/jetty-siwe/src/main/java/module-info.java
@@ -0,0 +1,20 @@
+//
+// ========================================================================
+// 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
+// ========================================================================
+//
+
+module org.eclipse.jetty.siwe
+{
+ requires transitive org.eclipse.jetty.security;
+ requires org.bouncycastle.provider;
+
+ exports org.eclipse.jetty.security.siwe;
+}
diff --git a/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/EthereumAuthenticator.java b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/EthereumAuthenticator.java
new file mode 100644
index 00000000000..72730757e3f
--- /dev/null
+++ b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/EthereumAuthenticator.java
@@ -0,0 +1,790 @@
+//
+// ========================================================================
+// 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.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.function.Function;
+
+import org.eclipse.jetty.http.BadMessageException;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.http.HttpStatus;
+import org.eclipse.jetty.http.HttpURI;
+import org.eclipse.jetty.http.HttpVersion;
+import org.eclipse.jetty.http.MimeTypes;
+import org.eclipse.jetty.http.MultiPartConfig;
+import org.eclipse.jetty.http.MultiPartFormData;
+import org.eclipse.jetty.io.Content;
+import org.eclipse.jetty.io.content.ByteBufferContentSource;
+import org.eclipse.jetty.security.AuthenticationState;
+import org.eclipse.jetty.security.Authenticator;
+import org.eclipse.jetty.security.Constraint;
+import org.eclipse.jetty.security.LoginService;
+import org.eclipse.jetty.security.ServerAuthException;
+import org.eclipse.jetty.security.UserIdentity;
+import org.eclipse.jetty.security.authentication.LoginAuthenticator;
+import org.eclipse.jetty.security.authentication.SessionAuthentication;
+import org.eclipse.jetty.security.siwe.internal.AnyUserLoginService;
+import org.eclipse.jetty.security.siwe.internal.EthereumUtil;
+import org.eclipse.jetty.security.siwe.internal.SignInWithEthereumParser;
+import org.eclipse.jetty.security.siwe.internal.SignInWithEthereumToken;
+import org.eclipse.jetty.server.FormFields;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Response;
+import org.eclipse.jetty.server.Session;
+import org.eclipse.jetty.util.Blocker;
+import org.eclipse.jetty.util.BufferUtil;
+import org.eclipse.jetty.util.Callback;
+import org.eclipse.jetty.util.CharsetStringBuilder.Iso88591StringBuilder;
+import org.eclipse.jetty.util.Fields;
+import org.eclipse.jetty.util.IncludeExcludeSet;
+import org.eclipse.jetty.util.URIUtil;
+import org.eclipse.jetty.util.UrlEncoded;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static org.eclipse.jetty.server.FormFields.getFormEncodedCharset;
+
+public class EthereumAuthenticator extends LoginAuthenticator
+{
+ private static final Logger LOG = LoggerFactory.getLogger(EthereumAuthenticator.class);
+
+ public static final String LOGIN_PATH_PARAM = "org.eclipse.jetty.security.siwe.login_path";
+ public static final String AUTHENTICATION_PATH_PARAM = "org.eclipse.jetty.security.siwe.authentication_path";
+ public static final String NONCE_PATH_PARAM = "org.eclipse.jetty.security.siwe.nonce_path";
+ public static final String MAX_MESSAGE_SIZE_PARAM = "org.eclipse.jetty.security.siwe.max_message_size";
+ public static final String LOGOUT_REDIRECT_PARAM = "org.eclipse.jetty.security.siwe.logout_redirect_path";
+ public static final String DISPATCH_PARAM = "org.eclipse.jetty.security.siwe.dispatch";
+ public static final String ERROR_PAGE = "org.eclipse.jetty.security.siwe.error_page";
+ public static final String J_URI = "org.eclipse.jetty.security.siwe.URI";
+ public static final String J_POST = "org.eclipse.jetty.security.siwe.POST";
+ public static final String J_METHOD = "org.eclipse.jetty.security.siwe.METHOD";
+ public static final String ERROR_PARAMETER = "error_description_jetty";
+ private static final String DEFAULT_AUTHENTICATION_PATH = "/auth/login";
+ private static final String DEFAULT_NONCE_PATH = "/auth/nonce";
+ private static final String NONCE_SET_ATTR = "org.eclipse.jetty.security.siwe.nonce";
+
+ private final IncludeExcludeSet _chainIds = new IncludeExcludeSet<>();
+ private final IncludeExcludeSet _schemes = new IncludeExcludeSet<>();
+ private final IncludeExcludeSet _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 _errorPage;
+ private String _errorPath;
+ private String _errorQuery;
+ private boolean _dispatch;
+ private boolean authenticateNewUsers = true;
+
+ public EthereumAuthenticator()
+ {
+ LOG.warn("Sign-In With Ethereum support is experimental and not suited for production use.");
+ }
+
+ public void includeDomains(String... domains)
+ {
+ _domains.include(domains);
+ }
+
+ public void includeSchemes(String... schemes)
+ {
+ _schemes.include(schemes);
+ }
+
+ 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_PAGE);
+ if (error != null)
+ setErrorPage(error);
+
+ String dispatch = authConfig.getParameter(DISPATCH_PARAM);
+ if (dispatch != null)
+ setDispatch(Boolean.parseBoolean(dispatch));
+
+ if (authenticateNewUsers)
+ {
+ LoginService loginService = new AnyUserLoginService(authConfig.getRealmName(), authConfig.getLoginService());
+ authConfig = new Configuration.Wrapper(authConfig)
+ {
+ @Override
+ public LoginService getLoginService()
+ {
+ return loginService;
+ }
+ };
+ }
+
+ super.setConfiguration(authConfig);
+ }
+
+ @Override
+ public String getAuthenticationType()
+ {
+ return Authenticator.SIWE_AUTH;
+ }
+
+ public boolean isAuthenticateNewUsers()
+ {
+ return authenticateNewUsers;
+ }
+
+ /**
+ * This setting is only meaningful if a non-null {@link LoginService} has been set.
+ *
+ * If set to true, any users not found by the {@link LoginService} will still
+ * be authenticated but with no roles, if set to false users will not be
+ * authenticated unless they are discovered by the wrapped {@link LoginService}.
+ *
+ * @param authenticateNewUsers whether to authenticate users not found by a wrapping 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;
+ _errorPage = null;
+ }
+ else
+ {
+ if (!path.startsWith("/"))
+ {
+ LOG.warn("error-page must start with /");
+ path = "/" + path;
+ }
+ _errorPage = path;
+ _errorPath = path;
+ _errorQuery = "";
+
+ int queryIndex = _errorPath.indexOf('?');
+ if (queryIndex > 0)
+ {
+ _errorPath = _errorPage.substring(0, queryIndex);
+ _errorQuery = _errorPage.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;
+ 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 (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));
+ 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), _schemes, _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;
+ }
+ }
+
+ // TODO: verify the sessionID is obtained from a cookie.
+
+ 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 = SignInWithEthereumParser.parse(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;
+ }
+
+ // not authenticated
+ if (LOG.isDebugEnabled())
+ LOG.debug("auth failed {}=={}", address, _errorPage);
+ 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("OpenId authentication FAILED: {}", message);
+
+ if (_errorPage == 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 {}", _errorPage);
+
+ String contextPath = Request.getContextPath(request);
+ String redirectUri = URIUtil.addPaths(contextPath, _errorPage);
+ 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)
+ {
+ 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);
+ }
+ }
+
+ 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);
+ }
+ }
+}
diff --git a/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/SignedMessage.java b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/SignedMessage.java
new file mode 100644
index 00000000000..f7448370e60
--- /dev/null
+++ b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/SignedMessage.java
@@ -0,0 +1,24 @@
+//
+// ========================================================================
+// 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.internal.EthereumSignatureVerifier;
+
+public record SignedMessage(String message, String signature)
+{
+ public String recoverAddress()
+ {
+ return EthereumSignatureVerifier.recoverAddress(message, signature);
+ }
+}
diff --git a/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/internal/AnyUserLoginService.java b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/internal/AnyUserLoginService.java
new file mode 100644
index 00000000000..e5db43912d1
--- /dev/null
+++ b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/internal/AnyUserLoginService.java
@@ -0,0 +1,109 @@
+//
+// ========================================================================
+// 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;
+
+public class AnyUserLoginService implements LoginService
+{
+ private final String _realm;
+ private final LoginService _loginService;
+ private IdentityService _identityService;
+ private boolean _authenticateNewUsers;
+
+ public AnyUserLoginService(String realm)
+ {
+ this(realm, null);
+ }
+
+ public AnyUserLoginService(String realm, LoginService loginService)
+ {
+ _realm = realm;
+ _loginService = loginService;
+ _identityService = (loginService == null) ? new DefaultIdentityService() : null;
+ }
+
+ /**
+ * This setting is only meaningful if a wrapped {@link LoginService} has been set.
+ *
+ * If set to true, any users not found by the wrapped {@link LoginService} will still
+ * be authenticated but with no roles, if set to false users will not be
+ * authenticated unless they are discovered by the wrapped {@link LoginService}.
+ *
+ * @param authenticateNewUsers whether to authenticate users not found by a wrapping LoginService
+ */
+ public void setAuthenticateNewUsers(boolean authenticateNewUsers)
+ {
+ this._authenticateNewUsers = authenticateNewUsers;
+ }
+
+ @Override
+ public String getName()
+ {
+ return _realm;
+ }
+
+ @Override
+ public UserIdentity login(String username, Object credentials, Request request, Function getOrCreateSession)
+ {
+ UserPrincipal userPrincipal = new UserPrincipal(username, null);
+ Subject subject = new Subject();
+ subject.getPrincipals().add(userPrincipal);
+ subject.getPrivateCredentials().add(credentials);
+ subject.setReadOnly();
+
+ if (_loginService != null)
+ return _loginService.getUserIdentity(subject, userPrincipal, _authenticateNewUsers);
+ 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-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/internal/EthereumSignatureVerifier.java b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/internal/EthereumSignatureVerifier.java
new file mode 100644
index 00000000000..3420612cdf5
--- /dev/null
+++ b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/internal/EthereumSignatureVerifier.java
@@ -0,0 +1,111 @@
+//
+// ========================================================================
+// 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.StandardCharsets;
+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.util.StringUtil;
+
+public class EthereumSignatureVerifier
+{
+ public static final String PREFIX = "\u0019Ethereum Signed Message:\n";
+
+ 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 EthereumSignatureVerifier()
+ {
+ }
+
+ public static String recoverAddress(String siweMessage, String signatureHex)
+ {
+ if (StringUtil.asciiStartsWithIgnoreCase(signatureHex, "0x"))
+ signatureHex = signatureHex.substring(2);
+
+ byte[] bytes = siweMessage.getBytes(StandardCharsets.ISO_8859_1);
+ int messageLength = bytes.length;
+ String signedMessage = PREFIX + messageLength + siweMessage;
+ byte[] messageHash = keccak256(signedMessage.getBytes(StandardCharsets.ISO_8859_1));
+ 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);
+ return ecRecover(messageHash, v, r, s);
+ }
+
+ private static String 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;
+ byte[] compressedPoint = INT_CONVERTER.integerToBytes(x, 1 + INT_CONVERTER.getByteLength(DOMAIN_PARAMS.getCurve()));
+ compressedPoint[0] = (byte)((v % 2) == 0 ? 0x02 : 0x03);
+ ECPoint R = DOMAIN_PARAMS.getCurve().decodePoint(compressedPoint);
+ if (!R.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);
+ ECPoint Q = ECAlgorithms.sumOfTwoMultiplies(DOMAIN_PARAMS.getG(), u1, R, u2);
+
+ // Remove the 1-byte prefix and return the public key as an ethereum address.
+ byte[] qBytes = Q.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 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();
+ }
+}
diff --git a/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/internal/EthereumUtil.java b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/internal/EthereumUtil.java
new file mode 100644
index 00000000000..e75b0370167
--- /dev/null
+++ b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/internal/EthereumUtil.java
@@ -0,0 +1,38 @@
+//
+// ========================================================================
+// 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.security.SecureRandom;
+
+public class EthereumUtil
+{
+ private static final String NONCE_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ private static final SecureRandom RANDOM = new SecureRandom();
+
+ private EthereumUtil()
+ {
+ }
+
+ 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-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/internal/SignInWithEthereumParser.java b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/internal/SignInWithEthereumParser.java
new file mode 100644
index 00000000000..842a902596a
--- /dev/null
+++ b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/internal/SignInWithEthereumParser.java
@@ -0,0 +1,65 @@
+//
+// ========================================================================
+// 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.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class SignInWithEthereumParser
+{
+ 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
+ );
+
+ private SignInWithEthereumParser()
+ {
+ }
+
+ public static SignInWithEthereumToken parse(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"));
+ }
+}
diff --git a/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/internal/SignInWithEthereumToken.java b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/internal/SignInWithEthereumToken.java
new file mode 100644
index 00000000000..61b9800bd78
--- /dev/null
+++ b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/internal/SignInWithEthereumToken.java
@@ -0,0 +1,76 @@
+//
+// ========================================================================
+// 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 org.eclipse.jetty.security.ServerAuthException;
+import org.eclipse.jetty.security.siwe.SignedMessage;
+import org.eclipse.jetty.util.IncludeExcludeSet;
+import org.eclipse.jetty.util.StringUtil;
+
+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)
+{
+
+ public void validate(SignedMessage signedMessage, Predicate validateNonce,
+ IncludeExcludeSet schemes,
+ IncludeExcludeSet domains,
+ IncludeExcludeSet chainIds) throws ServerAuthException
+ {
+ if (validateNonce != null && !validateNonce.test(nonce()))
+ throw new ServerAuthException("invalid nonce");
+
+ if (!StringUtil.asciiEqualsIgnoreCase(signedMessage.recoverAddress(), address()))
+ throw new ServerAuthException("signature verification failed");
+
+ if (!"1".equals(version()))
+ throw new ServerAuthException("unsupported 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 (schemes != null && !schemes.test(scheme()))
+ throw new ServerAuthException("unregistered scheme");
+ if (domains != null && !domains.test(domain()))
+ throw new ServerAuthException("unregistered domain");
+ if (chainIds != null && !chainIds.test(chainId()))
+ throw new ServerAuthException("unregistered chainId");
+ }
+}
diff --git a/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumParserTest.java b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumParserTest.java
new file mode 100644
index 00000000000..63fb1152d1b
--- /dev/null
+++ b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumParserTest.java
@@ -0,0 +1,158 @@
+//
+// ========================================================================
+// 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.SignInWithEthereumParser;
+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 = SignInWithEthereumParser.parse(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 = SignInWithEthereumParser.parse(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-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTest.java b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTest.java
new file mode 100644
index 00000000000..afa69e3d9cb
--- /dev/null
+++ b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTest.java
@@ -0,0 +1,292 @@
+//
+// ========================================================================
+// 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 ("/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();
+ _authenticator.setLoginPath("/login");
+
+ SecurityHandler.PathMapped securityHandler = new SecurityHandler.PathMapped();
+ securityHandler.setAuthenticator(_authenticator);
+ securityHandler.setHandler(handler);
+ 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);
+ 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);
+ 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 testEnforceScheme() throws Exception
+ {
+ _authenticator.includeSchemes("https");
+
+ // Test login with invalid scheme.
+ String nonce = getNonce();
+ String siweMessage = SignInWithEthereumGenerator.generateMessage("http", "localhost", _credentials.getAddress(), nonce);
+ ContentResponse response = sendAuthRequest(_credentials.signMessage(siweMessage));
+ assertThat(response.getStatus(), equalTo(HttpStatus.FORBIDDEN_403));
+ assertThat(response.getContentAsString(), containsString("unregistered scheme"));
+
+ // Test login with valid scheme.
+ nonce = getNonce();
+ siweMessage = SignInWithEthereumGenerator.generateMessage("https", "localhost", _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(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-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTokenTest.java b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTokenTest.java
new file mode 100644
index 00000000000..b7018010ead
--- /dev/null
+++ b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTokenTest.java
@@ -0,0 +1,265 @@
+//
+// ========================================================================
+// 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.SignInWithEthereumParser;
+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
+ );
+
+ SignedMessage signedMessage = credentials.signMessage(message);
+ SignInWithEthereumToken siwe = SignInWithEthereumParser.parse(message);
+ assertNotNull(siwe);
+
+ Throwable error = assertThrows(Throwable.class, () ->
+ siwe.validate(signedMessage, null, 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
+ );
+
+ SignedMessage signedMessage = credentials.signMessage(message);
+ SignInWithEthereumToken siwe = SignInWithEthereumParser.parse(message);
+ assertNotNull(siwe);
+
+ Throwable error = assertThrows(Throwable.class, () ->
+ siwe.validate(signedMessage, null, 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
+ );
+
+ SignedMessage signedMessage = credentials.signMessage(message);
+ SignInWithEthereumToken siwe = SignInWithEthereumParser.parse(message);
+ assertNotNull(siwe);
+
+ Throwable error = assertThrows(Throwable.class, () ->
+ siwe.validate(signedMessage, null, 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
+ );
+
+ SignedMessage signedMessage = credentials.signMessage(message);
+ SignInWithEthereumToken siwe = SignInWithEthereumParser.parse(message);
+ assertNotNull(siwe);
+
+ IncludeExcludeSet domains = new IncludeExcludeSet<>();
+ domains.include("example.org");
+
+ Throwable error = assertThrows(Throwable.class, () ->
+ siwe.validate(signedMessage, null, null, domains, null));
+ assertThat(error.getMessage(), containsString("unregistered domain"));
+ }
+
+ @Test
+ public void testInvalidScheme() 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
+ );
+
+ SignedMessage signedMessage = credentials.signMessage(message);
+ SignInWithEthereumToken siwe = SignInWithEthereumParser.parse(message);
+ assertNotNull(siwe);
+
+ IncludeExcludeSet schemes = new IncludeExcludeSet<>();
+ schemes.include("wss");
+
+ Throwable error = assertThrows(Throwable.class, () ->
+ siwe.validate(signedMessage, null, schemes, null, null));
+ assertThat(error.getMessage(), containsString("unregistered scheme"));
+ }
+
+ @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
+ );
+
+ SignedMessage signedMessage = credentials.signMessage(message);
+ SignInWithEthereumToken siwe = SignInWithEthereumParser.parse(message);
+ assertNotNull(siwe);
+
+ IncludeExcludeSet chainIds = new IncludeExcludeSet<>();
+ chainIds.include("1337");
+
+ Throwable error = assertThrows(Throwable.class, () ->
+ siwe.validate(signedMessage, null, 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
+ );
+
+ SignedMessage signedMessage = credentials.signMessage(message);
+ SignInWithEthereumToken siwe = SignInWithEthereumParser.parse(message);
+ assertNotNull(siwe);
+
+ Predicate nonceValidation = nonce -> false;
+ Throwable error = assertThrows(Throwable.class, () ->
+ siwe.validate(signedMessage, nonceValidation, null, 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
+ );
+
+ SignedMessage signedMessage = credentials.signMessage(message);
+ SignInWithEthereumToken siwe = SignInWithEthereumParser.parse(message);
+ assertNotNull(siwe);
+
+ Predicate nonceValidation = nonce -> true;
+ assertDoesNotThrow(() ->
+ siwe.validate(signedMessage, nonceValidation, null, null, null));
+ }
+}
diff --git a/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignatureVerificationTest.java b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignatureVerificationTest.java
new file mode 100644
index 00000000000..2bde11d58f7
--- /dev/null
+++ b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignatureVerificationTest.java
@@ -0,0 +1,45 @@
+//
+// ========================================================================
+// 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.internal.EthereumSignatureVerifier;
+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";
+// SignedMessage signedMessage = credentials.signMessage(siweMessage);
+// String address = credentials.getAddress();
+// System.err.println(signedMessage);
+// System.err.println("address: " + credentials.getAddress());
+
+ String address = "0x6ea456494436e225335e34dd9ffd53b98109afe3";
+ System.err.println("address: " + address);
+ SignedMessage signedMessage = new SignedMessage("hello world",
+ "0x5e4659c34d3d672ef2840a63b7cca475b223d0cbf78eac1666964f6f16663f7d836bb3fda043173256867f6e9c29be1e401a03be52fd4df227e6d73320201f901b");
+
+ String recoveredAddress = EthereumSignatureVerifier.recoverAddress(signedMessage.message(), signedMessage.signature());
+ System.err.println("recoveredAddress: " + recoveredAddress);
+ assertThat(recoveredAddress, equalToIgnoringCase(address));
+ }
+}
diff --git a/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/example/SignInWithEthereumEmbeddedExample.java b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/example/SignInWithEthereumEmbeddedExample.java
new file mode 100644
index 00000000000..3ee3ad35704
--- /dev/null
+++ b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/example/SignInWithEthereumEmbeddedExample.java
@@ -0,0 +1,101 @@
+//
+// ========================================================================
+// 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.example;
+
+import java.io.PrintWriter;
+import java.nio.file.Paths;
+import java.util.Objects;
+
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.io.Content;
+import org.eclipse.jetty.security.AuthenticationState;
+import org.eclipse.jetty.security.Constraint;
+import org.eclipse.jetty.security.SecurityHandler;
+import org.eclipse.jetty.security.siwe.EthereumAuthenticator;
+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.server.handler.ResourceHandler;
+import org.eclipse.jetty.session.SessionHandler;
+import org.eclipse.jetty.util.Callback;
+
+public class SignInWithEthereumEmbeddedExample
+{
+ public static void main(String[] args) throws Exception
+ {
+ Server server = new Server();
+ ServerConnector connector = new ServerConnector(server);
+ connector.setPort(8080);
+ server.addConnector(connector);
+
+ String resourcePath = Paths.get(Objects.requireNonNull(SignInWithEthereumEmbeddedExample.class.getClassLoader().getResource("")).toURI())
+ .resolve("../../src/test/resources/")
+ .normalize().toString();
+ ResourceHandler resourceHandler = new ResourceHandler();
+ resourceHandler.setDirAllowed(false);
+ resourceHandler.setBaseResourceAsString(resourcePath);
+
+ Handler.Abstract handler = new Handler.Wrapper(resourceHandler)
+ {
+ @Override
+ public boolean handle(Request request, Response response, Callback callback) throws Exception
+ {
+ String pathInContext = Request.getPathInContext(request);
+ if ("/login.html".equals(pathInContext))
+ {
+ return super.handle(request, response, callback);
+ }
+ else if ("/logout".equals(pathInContext))
+ {
+ AuthenticationState.logout(request, response);
+ Response.sendRedirect(request, response, callback, "/");
+ callback.succeeded();
+ return true;
+ }
+
+ AuthenticationState authState = Objects.requireNonNull(AuthenticationState.getAuthenticationState(request));
+ response.getHeaders().add(HttpHeader.CONTENT_TYPE, "text/html");
+ try (PrintWriter writer = new PrintWriter(Content.Sink.asOutputStream(response)))
+ {
+ writer.write("UserPrincipal: " + authState.getUserPrincipal());
+ writer.write(" Logout");
+ }
+ callback.succeeded();
+ return true;
+ }
+ };
+
+ EthereumAuthenticator authenticator = new EthereumAuthenticator();
+ authenticator.setLoginPath("/login.html");
+ SecurityHandler.PathMapped securityHandler = new SecurityHandler.PathMapped();
+ securityHandler.setAuthenticator(authenticator);
+ securityHandler.setHandler(handler);
+ 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();
+ System.err.println(resourceHandler.getBaseResource());
+ server.join();
+ }
+}
\ No newline at end of file
diff --git a/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/util/EthereumCredentials.java b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/util/EthereumCredentials.java
new file mode 100644
index 00000000000..a2f353d58ad
--- /dev/null
+++ b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/util/EthereumCredentials.java
@@ -0,0 +1,209 @@
+//
+// ========================================================================
+// 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.Security;
+import java.security.Signature;
+import java.security.spec.ECGenParameterSpec;
+import java.util.Arrays;
+
+import org.bouncycastle.crypto.digests.KeccakDigest;
+import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey;
+import org.bouncycastle.jce.ECNamedCurveTable;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.jce.spec.ECParameterSpec;
+import org.bouncycastle.math.ec.ECAlgorithms;
+import org.bouncycastle.math.ec.ECFieldElement;
+import org.bouncycastle.math.ec.ECPoint;
+import org.bouncycastle.util.encoders.Hex;
+import org.eclipse.jetty.security.siwe.SignedMessage;
+
+public class EthereumCredentials {
+ private final PrivateKey privateKey;
+ private final PublicKey publicKey;
+ private final String address;
+ private static final ECParameterSpec ecSpec;
+
+ static {
+ Security.addProvider(new BouncyCastleProvider());
+ ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1");
+ }
+
+ public EthereumCredentials() {
+ try {
+ KeyPair keyPair = generateECKeyPair();
+ this.privateKey = keyPair.getPrivate();
+ this.publicKey = keyPair.getPublic();
+ this.address = computeAddress(publicKey);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private KeyPair generateECKeyPair() throws Exception {
+ KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME);
+ ECGenParameterSpec ecGenParameterSpec = new ECGenParameterSpec("secp256k1");
+ keyPairGenerator.initialize(ecGenParameterSpec, new SecureRandom());
+ return keyPairGenerator.generateKeyPair();
+ }
+
+ public String getAddress() {
+ return address;
+ }
+
+ public 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", BouncyCastleProvider.PROVIDER_NAME);
+ ecdsaSign.initSign(privateKey);
+ ecdsaSign.update(messageHash);
+ byte[] derSignature = ecdsaSign.sign();
+
+ // Decode the DER signature to get r and s
+ int rLength = derSignature[3];
+ byte[] r = Arrays.copyOfRange(derSignature, 4, 4 + rLength);
+ int sLength = derSignature[5 + rLength];
+ byte[] s = Arrays.copyOfRange(derSignature, 6 + rLength, 6 + rLength + sLength);
+
+ // Ensure r and s are exactly 32 bytes
+ r = ensure32Bytes(r);
+ s = ensure32Bytes(s);
+
+ // Calculate v
+ BigInteger bigR = new BigInteger(1, r);
+ BigInteger bigS = new BigInteger(1, s);
+ byte v = calculateV(r, s, messageHash);
+
+ System.err.println("r: " + bigR);
+ System.err.println("s: " + bigS);
+ System.err.println("v: " + v);
+ ECPoint q = ((BCECPublicKey)publicKey).getQ();
+ for (int i = 0; i < 4; i++)
+ {
+ ECPoint ecPoint = recoverFromSignature(i, bigR, bigS, new BigInteger(1, messageHash));
+ System.err.println(q.equals(ecPoint) + " " + ecPoint);
+ }
+
+ // Concatenate r, s, and v to get the final 65-byte signature
+ byte[] signature = new byte[65];
+ System.arraycopy(r, 0, signature, 0, 32);
+ System.arraycopy(s, 0, signature, 32, 32);
+ signature[64] = v;
+
+ System.err.println("generated: ");
+
+ String signatureHex = Hex.toHexString(signature);
+ return new SignedMessage(message, signatureHex);
+ }
+
+ private byte[] ensure32Bytes(byte[] bytes) {
+ if (bytes.length == 32) {
+ return bytes;
+ } else if (bytes.length < 32) {
+ byte[] padded = new byte[32];
+ System.arraycopy(bytes, 0, padded, 32 - bytes.length, bytes.length);
+ return padded;
+ } else {
+ return Arrays.copyOfRange(bytes, bytes.length - 32, bytes.length);
+ }
+ }
+
+ private byte calculateV(byte[] r, byte[] s, byte[] messageHash) {
+ BigInteger R = new BigInteger(1, r);
+ BigInteger S = new BigInteger(1, s);
+ ECPoint publicKeyPoint = ((BCECPublicKey) publicKey).getQ();
+ for (int i = 0; i < 4; i++) {
+ ECPoint Q = recoverFromSignature(i, R, S, new BigInteger(1, messageHash));
+ if (Q != null && Q.equals(publicKeyPoint)) {
+ return (byte) (i + 27);
+ }
+ }
+ throw new RuntimeException("Could not recover public key from signature");
+ }
+
+ private ECPoint recoverFromSignature(int recId, BigInteger r, BigInteger s, BigInteger messageHash) {
+ BigInteger n = ecSpec.getN();
+ BigInteger i = BigInteger.valueOf((long) recId / 2);
+ BigInteger x = r.add(i.multiply(n));
+
+ if (x.compareTo(ecSpec.getCurve().getField().getCharacteristic()) >= 0) {
+ return null;
+ }
+
+ ECPoint R = decompressKey(x, (recId & 1) == 1);
+ if (!R.multiply(n).isInfinity()) {
+ return null;
+ }
+
+ BigInteger e = messageHash;
+ BigInteger eInv = e.negate().mod(n);
+ BigInteger rInv = r.modInverse(n);
+ BigInteger srInv = rInv.multiply(s).mod(n);
+ BigInteger eInvrInv = rInv.multiply(eInv).mod(n);
+
+ ECPoint q = ECAlgorithms.sumOfTwoMultiplies(ecSpec.getG(), eInvrInv, R, srInv);
+ return q;
+ }
+
+ private ECPoint decompressKey(BigInteger xBN, boolean yBit) {
+ org.bouncycastle.math.ec.ECCurve curve = ecSpec.getCurve();
+ ECFieldElement x = curve.fromBigInteger(xBN);
+ ECFieldElement alpha = x.square().add(curve.getA()).multiply(x).add(curve.getB());
+ ECFieldElement beta = alpha.sqrt();
+ if (beta == null) {
+ throw new IllegalArgumentException("Invalid point compression");
+ }
+ if (beta.testBitZero() != yBit) {
+ beta = beta.negate();
+ }
+ return curve.createPoint(x.toBigInteger(), beta.toBigInteger());
+ }
+
+ private byte[] keccak256(byte[] input) {
+ KeccakDigest digest = new KeccakDigest(256);
+ digest.update(input, 0, input.length);
+ byte[] hash = new byte[digest.getDigestSize()];
+ digest.doFinal(hash, 0);
+ return hash;
+ }
+
+ private String computeAddress(PublicKey publicKey) {
+ ECPoint q = ((BCECPublicKey) publicKey).getQ();
+ byte[] pubKeyBytes = q.getEncoded(false); // false for uncompressed point
+ byte[] hash = keccak256(Arrays.copyOfRange(pubKeyBytes, 1, pubKeyBytes.length));
+ return "0x" + Hex.toHexString(Arrays.copyOfRange(hash, hash.length - 20, hash.length));
+ }
+
+ public static void main(String[] args) {
+ try {
+ EthereumCredentials credentials = new EthereumCredentials();
+ String address = credentials.getAddress();
+ SignedMessage signedMessage = credentials.signMessage("Hello, Ethereum!");
+
+ System.out.println("Address: " + address);
+ System.out.println("Signed Message: " + signedMessage);
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/util/SignInWithEthereumGenerator.java b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/util/SignInWithEthereumGenerator.java
new file mode 100644
index 00000000000..3f8bf816261
--- /dev/null
+++ b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/util/SignInWithEthereumGenerator.java
@@ -0,0 +1,111 @@
+//
+// ========================================================================
+// 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;
+
+import org.eclipse.jetty.security.siwe.internal.EthereumUtil;
+
+public class SignInWithEthereumGenerator
+{
+ private SignInWithEthereumGenerator()
+ {
+ }
+
+ public static String generateMessage(int port, String address)
+ {
+ return generateMessage(port, address, EthereumUtil.createNonce());
+ }
+
+ 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-core/jetty-siwe/src/test/resources/jetty-logging.properties b/jetty-core/jetty-siwe/src/test/resources/jetty-logging.properties
new file mode 100755
index 00000000000..a5c0825874c
--- /dev/null
+++ b/jetty-core/jetty-siwe/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-core/jetty-siwe/src/test/resources/login.html b/jetty-core/jetty-siwe/src/test/resources/login.html
new file mode 100644
index 00000000000..6bd85e68968
--- /dev/null
+++ b/jetty-core/jetty-siwe/src/test/resources/login.html
@@ -0,0 +1,58 @@
+
+
+
+
+
+ Sign-In with Ethereum
+
+
+
+