diff --git a/application.example.yaml b/application.example.yaml index 9538ebe..c57656c 100644 --- a/application.example.yaml +++ b/application.example.yaml @@ -12,6 +12,12 @@ server: # e.g. domain name in e-mails. name: 'example.org' + # Public URL to reach this identity server + # + # This is used with 3PID invites in room and other Homeserver key verification workflow. + # If left unconfigured, it will be generated from the server name + # publicUrl: 'https://example.org' + key: diff --git a/src/main/groovy/io/kamax/mxisd/config/ServerConfig.groovy b/src/main/groovy/io/kamax/mxisd/config/ServerConfig.groovy index 3b14e96..d29b4ce 100644 --- a/src/main/groovy/io/kamax/mxisd/config/ServerConfig.groovy +++ b/src/main/groovy/io/kamax/mxisd/config/ServerConfig.groovy @@ -22,6 +22,8 @@ package io.kamax.mxisd.config import io.kamax.mxisd.exception.ConfigurationException import org.apache.commons.lang.StringUtils +import org.slf4j.Logger +import org.slf4j.LoggerFactory import org.springframework.beans.factory.InitializingBean import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.Configuration @@ -30,7 +32,11 @@ import org.springframework.context.annotation.Configuration @ConfigurationProperties(prefix = "server") class ServerConfig implements InitializingBean { + private Logger log = LoggerFactory.getLogger(ServerConfig.class); + private String name + private int port + private String publicUrl String getName() { return name @@ -40,11 +46,43 @@ class ServerConfig implements InitializingBean { this.name = name } + int getPort() { + return port + } + + void setPort(int port) { + this.port = port + } + + String getPublicUrl() { + return publicUrl + } + + void setPublicUrl(String publicUrl) { + this.publicUrl = publicUrl + } + @Override void afterPropertiesSet() throws Exception { if (StringUtils.isBlank(getName())) { throw new ConfigurationException("server.name") } + + if (StringUtils.isBlank(getPublicUrl())) { + log.warn("Public URL is empty, generating from name {}", getName()) + publicUrl = "https://${getName()}" + } + + try { + new URL(getPublicUrl()) + } catch (MalformedURLException e) { + log.warn("Public URL is not valid: {}", StringUtils.defaultIfBlank(e.getMessage(), "")) + } + + log.info("--- Server config ---") + log.info("Name: {}", getName()) + log.info("Port: {}", getPort()) + log.info("Public URL: {}", getPublicUrl()) } } diff --git a/src/main/groovy/io/kamax/mxisd/controller/v1/InvitationController.groovy b/src/main/groovy/io/kamax/mxisd/controller/v1/InvitationController.groovy index d2ccc34..ee40c95 100644 --- a/src/main/groovy/io/kamax/mxisd/controller/v1/InvitationController.groovy +++ b/src/main/groovy/io/kamax/mxisd/controller/v1/InvitationController.groovy @@ -22,6 +22,7 @@ package io.kamax.mxisd.controller.v1 import com.google.gson.Gson import io.kamax.matrix.MatrixID +import io.kamax.mxisd.config.ServerConfig import io.kamax.mxisd.controller.v1.io.ThreePidInviteReplyIO import io.kamax.mxisd.invitation.IThreePidInvite import io.kamax.mxisd.invitation.IThreePidInviteReply @@ -50,6 +51,9 @@ class InvitationController { @Autowired private KeyManager keyMgr + @Autowired + private ServerConfig srvCfg + private Gson gson = new Gson() @RequestMapping(value = "/_matrix/identity/api/v1/store-invite", method = POST) @@ -62,7 +66,7 @@ class InvitationController { IThreePidInvite invite = new ThreePidInvite(new MatrixID(sender), medium, address, roomId) IThreePidInviteReply reply = mgr.storeInvite(invite) - return gson.toJson(new ThreePidInviteReplyIO(reply, keyMgr.getPublicKeyBase64(keyMgr.getCurrentIndex()))) + return gson.toJson(new ThreePidInviteReplyIO(reply, keyMgr.getPublicKeyBase64(keyMgr.getCurrentIndex()), srvCfg.getPublicUrl())) } } diff --git a/src/main/groovy/io/kamax/mxisd/controller/v1/KeyController.groovy b/src/main/groovy/io/kamax/mxisd/controller/v1/KeyController.groovy index f2e0a9e..f74b5bf 100644 --- a/src/main/groovy/io/kamax/mxisd/controller/v1/KeyController.groovy +++ b/src/main/groovy/io/kamax/mxisd/controller/v1/KeyController.groovy @@ -24,11 +24,13 @@ import groovy.json.JsonOutput import io.kamax.mxisd.exception.BadRequestException import io.kamax.mxisd.exception.NotImplementedException import io.kamax.mxisd.key.KeyManager +import org.apache.commons.lang.StringUtils import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import javax.servlet.http.HttpServletRequest @@ -49,6 +51,7 @@ class KeyController { throw new BadRequestException("Invalid algorithm: " + keyType) } + log.info("Key {}:{} was requested", keyType, keyId) return JsonOutput.toJson([ public_key: keyMgr.getPublicKeyBase64(keyId) ]) @@ -62,10 +65,14 @@ class KeyController { } @RequestMapping(value = "/_matrix/identity/api/v1/pubkey/isvalid", method = GET) - String checkKeyValidity(HttpServletRequest request) { - log.error("{} was requested but not implemented", request.getRequestURL()) + String checkKeyValidity(HttpServletRequest request, @RequestParam("public_key") String pubKey) { + log.info("Validating public key {}", pubKey) - throw new NotImplementedException() + // TODO do in manager + boolean valid = StringUtils.equals(pubKey, keyMgr.getPublicKeyBase64(keyMgr.getCurrentIndex())) + return JsonOutput.toJson( + valid: valid + ) } } diff --git a/src/main/groovy/io/kamax/mxisd/controller/v1/io/ThreePidInviteReplyIO.java b/src/main/groovy/io/kamax/mxisd/controller/v1/io/ThreePidInviteReplyIO.java index 8967ecb..53c6a34 100644 --- a/src/main/groovy/io/kamax/mxisd/controller/v1/io/ThreePidInviteReplyIO.java +++ b/src/main/groovy/io/kamax/mxisd/controller/v1/io/ThreePidInviteReplyIO.java @@ -2,8 +2,7 @@ package io.kamax.mxisd.controller.v1.io; import io.kamax.mxisd.invitation.IThreePidInviteReply; -import java.util.ArrayList; -import java.util.Arrays; +import java.util.Collections; import java.util.List; public class ThreePidInviteReplyIO { @@ -12,20 +11,40 @@ public class ThreePidInviteReplyIO { private List public_keys; private String display_name; - public ThreePidInviteReplyIO(IThreePidInviteReply reply, String pubKey) { + public ThreePidInviteReplyIO(IThreePidInviteReply reply, String pubKey, String publicUrl) { this.token = reply.getToken(); - this.public_keys = new ArrayList<>(Arrays.asList(new Key(pubKey))); + this.public_keys = Collections.singletonList(new Key(pubKey, publicUrl)); this.display_name = reply.getDisplayName(); } + public String getToken() { + return token; + } + + public List getPublic_keys() { + return public_keys; + } + + public String getDisplay_name() { + return display_name; + } + public class Key { private String key_validity_url; private String public_key; - public Key(String key) { - this.key_validity_url = "https://example.org/_matrix/fixme"; // FIXME have a proper URL even if synapse does not check + public Key(String key, String publicUrl) { + this.key_validity_url = publicUrl + "/_matrix/identity/api/v1/pubkey/isvalid"; this.public_key = key; } + + public String getKey_validity_url() { + return key_validity_url; + } + + public String getPublic_key() { + return public_key; + } } } diff --git a/src/main/groovy/io/kamax/mxisd/invitation/InvitationManager.java b/src/main/groovy/io/kamax/mxisd/invitation/InvitationManager.java index 7f994d0..3d54558 100644 --- a/src/main/groovy/io/kamax/mxisd/invitation/InvitationManager.java +++ b/src/main/groovy/io/kamax/mxisd/invitation/InvitationManager.java @@ -12,17 +12,14 @@ import io.kamax.mxisd.signature.SignatureManager; import org.apache.commons.lang.RandomStringUtils; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpPost; -import org.apache.http.config.Registry; -import org.apache.http.config.RegistryBuilder; -import org.apache.http.conn.socket.ConnectionSocketFactory; -import org.apache.http.conn.socket.PlainConnectionSocketFactory; +import org.apache.http.conn.ssl.NoopHostnameVerifier; import org.apache.http.conn.ssl.SSLConnectionSocketFactory; -import org.apache.http.conn.ssl.TrustStrategy; +import org.apache.http.conn.ssl.TrustSelfSignedStrategy; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClientBuilder; -import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import org.apache.http.impl.client.HttpClients; import org.apache.http.ssl.SSLContextBuilder; +import org.json.JSONArray; import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,8 +35,6 @@ import javax.net.ssl.HostnameVerifier; import javax.net.ssl.SSLContext; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; import java.util.*; import java.util.concurrent.ConcurrentHashMap; @@ -65,40 +60,12 @@ public class InvitationManager { private void postConstruct() { gson = new Gson(); + // FIXME export such madness into matrix-java-sdk with a nice wrapper to talk to a homeserver try { - HttpClientBuilder b = HttpClientBuilder.create(); - - // setup a Trust Strategy that allows all certificates. - // - SSLContext sslContext = new SSLContextBuilder().loadTrustMaterial(null, new TrustStrategy() { - public boolean isTrusted(X509Certificate[] arg0, String arg1) throws CertificateException { - return true; - } - }).build(); - b.setSslcontext(sslContext); - - // don't check Hostnames, either. - // -- use SSLConnectionSocketFactory.getDefaultHostnameVerifier(), if you don't want to weaken - HostnameVerifier hostnameVerifier = SSLConnectionSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER; - - // here's the special part: - // -- need to create an SSL Socket Factory, to use our weakened "trust strategy"; - // -- and create a Registry, to register it. - // + SSLContext sslContext = SSLContextBuilder.create().loadTrustMaterial(new TrustSelfSignedStrategy()).build(); + HostnameVerifier hostnameVerifier = new NoopHostnameVerifier(); SSLConnectionSocketFactory sslSocketFactory = new SSLConnectionSocketFactory(sslContext, hostnameVerifier); - Registry socketFactoryRegistry = RegistryBuilder.create() - .register("http", PlainConnectionSocketFactory.getSocketFactory()) - .register("https", sslSocketFactory) - .build(); - - // now, we create connection-manager using our Registry. - // -- allows multi-threaded use - PoolingHttpClientConnectionManager connMgr = new PoolingHttpClientConnectionManager(socketFactoryRegistry); - b.setConnectionManager(connMgr); - - // finally, build the HttpClient; - // -- done! - client = b.build(); + client = HttpClients.custom().setSSLSocketFactory(sslSocketFactory).build(); } catch (Exception e) { // FIXME do better... throw new RuntimeException(e); @@ -196,31 +163,52 @@ public class InvitationManager { if (!hsUrlOpt.isPresent()) { log.warn("No HS found for domain {} - ignoring publishing", domain); } else { - HttpPost req = new HttpPost(hsUrlOpt.get() + "/_matrix/federation/v1/3pid/onbind"); - JSONObject obj = new JSONObject(); // TODO use Gson instead - obj.put("mxisd", threePid.getMxid()); - obj.put("token", reply.getToken()); - String mapping = gson.toJson(signMgr.signMessage(obj.toString())); // FIXME we shouldn't need to be doign this + // TODO this is needed as this will block if called during authentication cycle due to synapse implementation + new Thread(() -> { // FIXME need to make this retryable and within a general background working pool + HttpPost req = new HttpPost(hsUrlOpt.get() + "/_matrix/federation/v1/3pid/onbind"); + // Expected body: https://matrix.to/#/!HUeDbmFUsWAhxHHvFG:matrix.org/$150469846739DCLWc:matrix.trancendances.fr + JSONObject obj = new JSONObject(); // TODO use Gson instead + obj.put("mxid", threePid.getMxid()); + obj.put("token", reply.getToken()); + obj.put("signatures", signMgr.signMessageJson(obj.toString())); - JSONObject content = new JSONObject(); // TODO use Gson instead - content.put("invites", Collections.singletonList(mapping)); - content.put("medium", threePid.getMedium()); - content.put("address", threePid.getValue()); - content.put("mxid", threePid.getMxid()); + JSONObject objUp = new JSONObject(); + objUp.put("mxid", threePid.getMxid()); + objUp.put("medium", threePid.getMedium()); + objUp.put("address", threePid.getValue()); + objUp.put("sender", reply.getInvite().getSender().getId()); + objUp.put("room_id", reply.getInvite().getRoomId()); + objUp.put("signed", obj); - StringEntity entity = new StringEntity(content.toString(), StandardCharsets.UTF_8); - entity.setContentType("application/json"); - req.setEntity(entity); - try { - log.info("Posting onBind event to {}", req.getURI()); - CloseableHttpResponse response = client.execute(req); - response.close(); - } catch (IOException e) { - log.warn("Unable to tell HS {} about invite being mapped", domain, e); - } + String mapping = gson.toJson(objUp); // FIXME we shouldn't need to be doign this + + JSONObject content = new JSONObject(); // TODO use Gson instead + JSONArray invites = new JSONArray(); + invites.put(objUp); + content.put("invites", invites); + content.put("medium", threePid.getMedium()); + content.put("address", threePid.getValue()); + content.put("mxid", threePid.getMxid()); + + content.put("signatures", signMgr.signMessageJson(content.toString())); + + log.info("Will send following JSON to {}: {}", domain, content.toString()); + StringEntity entity = new StringEntity(content.toString(), StandardCharsets.UTF_8); + entity.setContentType("application/json"); + req.setEntity(entity); + try { + log.info("Posting onBind event to {}", req.getURI()); + CloseableHttpResponse response = client.execute(req); + log.info("Answer code: {}", response.getStatusLine().getStatusCode()); + response.close(); + } catch (IOException e) { + log.warn("Unable to tell HS {} about invite being mapped", domain, e); + } + invitations.remove(key); + }).start(); } - invitations.remove(key); + } } diff --git a/src/main/groovy/io/kamax/mxisd/signature/SignatureManager.groovy b/src/main/groovy/io/kamax/mxisd/signature/SignatureManager.groovy index 8eece94..72dede2 100644 --- a/src/main/groovy/io/kamax/mxisd/signature/SignatureManager.groovy +++ b/src/main/groovy/io/kamax/mxisd/signature/SignatureManager.groovy @@ -23,6 +23,7 @@ package io.kamax.mxisd.signature import io.kamax.mxisd.config.ServerConfig import io.kamax.mxisd.key.KeyManager import net.i2p.crypto.eddsa.EdDSAEngine +import org.json.JSONObject import org.springframework.beans.factory.InitializingBean import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Component @@ -51,6 +52,18 @@ class SignatureManager implements InitializingBean { ] } + JSONObject signMessageJson(String message) { + byte[] signRaw = signEngine.signOneShot(message.getBytes()) + String sign = Base64.getEncoder().encodeToString(signRaw) + + JSONObject keySignature = new JSONObject() + keySignature.put("ed25519:${keyMgr.getCurrentIndex()}", sign) + JSONObject signature = new JSONObject() + signature.put("${srvCfg.getName()}", keySignature) + + return signature + } + @Override void afterPropertiesSet() throws Exception { signEngine = new EdDSAEngine(MessageDigest.getInstance(keyMgr.getSpecs().getHashAlgorithm()))