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-openid 12.0.11-SNAPSHOT + + org.eclipse.jetty + jetty-siwe + 12.0.11-SNAPSHOT + org.eclipse.jetty jetty-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-SNAPSHOT jetty-openid - EE10 :: OpenID + Core :: OpenID Jetty 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 + + + +

Sign-In with Ethereum

+ + + + + + + diff --git a/jetty-core/pom.xml b/jetty-core/pom.xml index fedb1b37479..886eaa84b60 100644 --- a/jetty-core/pom.xml +++ b/jetty-core/pom.xml @@ -38,6 +38,7 @@ jetty-security jetty-server jetty-session + jetty-siwe jetty-slf4j-impl jetty-start jetty-tests diff --git a/pom.xml b/pom.xml index 9f54960feb9..19c20b1b81e 100644 --- a/pom.xml +++ b/pom.xml @@ -731,6 +731,11 @@ jetty-openid ${project.version} + + org.eclipse.jetty + jetty-siwe + ${project.version} + org.eclipse.jetty jetty-osgi