Issue #11560 - Implement EIP-4361 Sign-In With Ethereum

Signed-off-by: Lachlan Roberts <lachlan@webtide.com>
This commit is contained in:
Lachlan Roberts 2024-07-05 15:47:17 +10:00
parent 05a0498627
commit 436ca41c81
24 changed files with 2579 additions and 2 deletions

View File

@ -95,6 +95,11 @@
<artifactId>jetty-openid</artifactId>
<version>12.0.11-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-siwe</artifactId>
<version>12.0.11-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-osgi</artifactId>

View File

@ -8,7 +8,7 @@
<version>12.0.11-SNAPSHOT</version>
</parent>
<artifactId>jetty-openid</artifactId>
<name>EE10 :: OpenID</name>
<name>Core :: OpenID</name>
<description>Jetty OpenID Connect Infrastructure</description>
<properties>

View File

@ -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

View File

@ -176,7 +176,7 @@ public class FormFields extends ContentSourceCompletableFuture<Fields>
* @param maxLength The maximum total size of the fields
* @return A {@link CompletableFuture} that will provide the {@link Fields} or a failure.
*/
static CompletableFuture<Fields> from(Content.Source source, Attributes attributes, Charset charset, int maxFields, int maxLength)
public static CompletableFuture<Fields> from(Content.Source source, Attributes attributes, Charset charset, int maxFields, int maxLength)
{
Object attr = attributes.getAttribute(FormFields.class.getName());
if (attr instanceof FormFields futureFormFields)

View File

@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-core</artifactId>
<version>12.0.11-SNAPSHOT</version>
</parent>
<artifactId>jetty-siwe</artifactId>
<name>Core :: Sign-In with Ethereum</name>
<description>Jetty Sign-In with Ethereum</description>
<properties>
<bouncycastle.version>1.78.1</bouncycastle.version>
<bundle-symbolic-name>${project.groupId}.siwe</bundle-symbolic-name>
</properties>
<dependencies>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk15to18</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15to18</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-client</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-security</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-util-ajax</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-session</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-slf4j-impl</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.toolchain</groupId>
<artifactId>jetty-test-helper</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.felix</groupId>
<artifactId>maven-bundle-plugin</artifactId>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>manifest</goal>
</goals>
<configuration>
<instructions>
<Require-Capability>osgi.extender; filter:="(osgi.extender=osgi.serviceloader.registrar)"</Require-Capability>
<Provide-Capability>osgi.serviceloader;osgi.serviceloader=org.eclipse.jetty.security.Authenticator$Factory</Provide-Capability>
</instructions>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -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;
}

View File

@ -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<String, String> _chainIds = new IncludeExcludeSet<>();
private final IncludeExcludeSet<String, String> _schemes = new IncludeExcludeSet<>();
private final IncludeExcludeSet<String, String> _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.
* <p>
* 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}.
* </p>
* @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);
}
}
/**
* <p>This will attempt to redirect the request to the {@link #_logoutRedirectPath}.</p>
*
* @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<Boolean, Session> 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<String> attribute = (Set<String>)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<String> attribute = (Set<String>)session.getAttribute(NONCE_SET_ATTR);
if (attribute == null)
return false;
return attribute.remove(nonce);
}
}
public static class FixedSizeSet<T> extends LinkedHashSet<T>
{
private final int maxSize;
public FixedSizeSet(int maxSize)
{
super(maxSize);
this.maxSize = maxSize;
}
@Override
public boolean add(T element)
{
if (size() >= maxSize)
{
Iterator<T> it = iterator();
if (it.hasNext())
{
it.next();
it.remove();
}
}
return super.add(element);
}
}
}

View File

@ -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);
}
}

View File

@ -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.
* <p>
* 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}.
* </p>
* @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<Boolean, Session> 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);
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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>" + SCHEME_PATTERN + ")://)?(?<domain>" + DOMAIN_PATTERN + ") wants you to sign in with your Ethereum account:\n" +
"(?<address>" + ADDRESS_PATTERN + ")\n\n" +
"(?<statement>" + STATEMENT_PATTERN + ")?\n\n" +
"URI: (?<uri>" + URI_PATTERN + ")\n" +
"Version: (?<version>" + VERSION_PATTERN + ")\n" +
"Chain ID: (?<chainId>" + CHAIN_ID_PATTERN + ")\n" +
"Nonce: (?<nonce>" + NONCE_PATTERN + ")\n" +
"Issued At: (?<issuedAt>" + DATE_TIME_PATTERN + ")" +
"(?:\nExpiration Time: (?<expirationTime>" + DATE_TIME_PATTERN + "))?" +
"(?:\nNot Before: (?<notBefore>" + DATE_TIME_PATTERN + "))?" +
"(?:\nRequest ID: (?<requestId>" + REQUEST_ID_PATTERN + "))?" +
"(?:\nResources:(?<resources>" + 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"));
}
}

View File

@ -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<String> validateNonce,
IncludeExcludeSet<String, String> schemes,
IncludeExcludeSet<String, String> domains,
IncludeExcludeSet<String, String> 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");
}
}

View File

@ -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<Arguments> specExamples()
{
List<Arguments> 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));
}
}

View File

@ -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<String, Object> parsed = (Map<String, Object>)new JSON().parse(new JSON.StringSource(response.getContentAsString()));
String nonce = (String)parsed.get("nonce");
assertThat(nonce.length(), equalTo(8));
return nonce;
}
}

View File

@ -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<String, String> 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<String, String> 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<String, String> 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<String> 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<String> nonceValidation = nonce -> true;
assertDoesNotThrow(() ->
siwe.validate(signedMessage, nonceValidation, null, null, null));
}
}

View File

@ -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));
}
}

View File

@ -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("<br><a href=\"/logout\">Logout</a>");
}
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();
}
}

View File

@ -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();
}
}
}

View File

@ -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();
}
}

View File

@ -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

View File

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign-In with Ethereum</title>
<script src="https://cdn.jsdelivr.net/npm/web3@1.6.1/dist/web3.min.js"></script>
</head>
<body>
<h4>Sign-In with Ethereum</h4>
<button id="siwe">Sign-In with Ethereum</button>
<form id="loginForm" action="/auth/login" method="POST" style="display: none;">
<input type="hidden" id="signatureField" name="signature">
<input type="hidden" id="messageField" name="message">
</form>
<p class="alert" style="display: none;">Result: <span id="siweResult"></span></p>
<script>
let provider = window.ethereum;
let accounts;
if (!provider) {
document.getElementById('siweResult').innerText = 'MetaMask is not installed. Please install MetaMask to use this feature.';
} else {
document.getElementById('siwe').addEventListener('click', async () => {
try {
// Request account access if needed
accounts = await provider.request({ method: 'eth_requestAccounts' });
const domain = window.location.host;
const from = accounts[0];
// Fetch nonce from the server
const nonceResponse = await fetch('/auth/nonce');
const nonceData = await nonceResponse.json();
const nonce = nonceData.nonce;
const siweMessage = `${domain} wants you to sign in with your Ethereum account:\n${from}\n\nI accept the MetaMask Terms of Service: https://community.metamask.io/tos\n\nURI: https://${domain}\nVersion: 1\nChain ID: 1\nNonce: ${nonce}\nIssued At: ${new Date().toISOString()}`;
const signature = await provider.request({
method: 'personal_sign',
params: [siweMessage, from]
});
console.log("signature: " + signature)
console.log("nonce: " + nonce)
console.log("length: " + length)
document.getElementById('signatureField').value = signature;
document.getElementById('messageField').value = siweMessage;
document.getElementById('loginForm').submit();
} catch (error) {
console.error('Error during login:', error);
document.getElementById('siweResult').innerText = `Error: ${error.message}`;
document.getElementById('siweResult').parentElement.style.display = 'block';
}
});
}
</script>
</body>
</html>

View File

@ -38,6 +38,7 @@
<module>jetty-security</module>
<module>jetty-server</module>
<module>jetty-session</module>
<module>jetty-siwe</module>
<module>jetty-slf4j-impl</module>
<module>jetty-start</module>
<module>jetty-tests</module>

View File

@ -731,6 +731,11 @@
<artifactId>jetty-openid</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-siwe</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-osgi</artifactId>