diff --git a/.idea/modules.xml b/.idea/modules.xml index ceb764a238..4f2556bdc3 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -24,9 +24,15 @@ + + + + + + @@ -40,4 +46,4 @@ - + \ No newline at end of file diff --git a/netpermission/build.gradle b/netpermission/build.gradle new file mode 100644 index 0000000000..c712f3ece4 --- /dev/null +++ b/netpermission/build.gradle @@ -0,0 +1,53 @@ +apply plugin: 'us.kirchmeier.capsule' +apply plugin: 'kotlin' + +repositories { + mavenLocal() + mavenCentral() + maven { + url 'http://oss.sonatype.org/content/repositories/snapshots' + } + jcenter() + maven { + url 'https://dl.bintray.com/kotlin/exposed' + } +} + +task buildCertSignerJAR(type: FatCapsule, dependsOn: 'jar') { + applicationClass 'com.r3corda.netpermission.MainKt' + archiveName 'certSigner.jar' + + capsuleManifest { + systemProperties['log4j.configuration'] = 'log4j2.xml' + minJavaVersion = '1.8.0' + } +} + +dependencies { + compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + + compile project(":core") + + // Log4J: logging framework (with SLF4J bindings) + compile "org.apache.logging.log4j:log4j-slf4j-impl:${log4j_version}" + compile "org.apache.logging.log4j:log4j-core:${log4j_version}" + compile "org.apache.logging.log4j:log4j-web:${log4j_version}" + + // Web stuff: for HTTP[S] servlets + compile "org.eclipse.jetty:jetty-servlet:${jetty_version}" + compile "org.eclipse.jetty:jetty-webapp:${jetty_version}" + compile "javax.servlet:javax.servlet-api:3.1.0" + + // Jersey for JAX-RS implementation for use in Jetty + compile "org.glassfish.jersey.core:jersey-server:${jersey_version}" + compile "org.glassfish.jersey.containers:jersey-container-servlet-core:${jersey_version}" + compile "org.glassfish.jersey.containers:jersey-container-jetty-http:${jersey_version}" + + // JOpt: for command line flags. + compile "net.sf.jopt-simple:jopt-simple:5.0.2" + + // Unit testing helpers. + testCompile 'junit:junit:4.12' + testCompile "org.assertj:assertj-core:${assertj_version}" + testCompile "com.nhaarman:mockito-kotlin:0.6.1" +} diff --git a/netpermission/src/main/kotlin/com/r3corda/netpermission/Main.kt b/netpermission/src/main/kotlin/com/r3corda/netpermission/Main.kt new file mode 100644 index 0000000000..22308cc464 --- /dev/null +++ b/netpermission/src/main/kotlin/com/r3corda/netpermission/Main.kt @@ -0,0 +1,108 @@ +package com.r3corda.netpermission + +import com.google.common.net.HostAndPort +import com.r3corda.core.crypto.X509Utilities +import com.r3corda.core.utilities.LogHelper +import com.r3corda.core.utilities.loggerFor +import com.r3corda.netpermission.internal.CertificateSigningService +import com.r3corda.netpermission.internal.persistence.InMemoryCertificationRequestStorage +import joptsimple.OptionParser +import org.eclipse.jetty.server.Server +import org.eclipse.jetty.server.ServerConnector +import org.eclipse.jetty.server.handler.HandlerCollection +import org.eclipse.jetty.servlet.ServletContextHandler +import org.eclipse.jetty.servlet.ServletHolder +import org.glassfish.jersey.server.ResourceConfig +import org.glassfish.jersey.servlet.ServletContainer +import java.io.Closeable +import java.net.InetSocketAddress +import java.nio.file.Paths +import kotlin.system.exitProcess + +/** + * CertificateSigningServer runs on Jetty server and provide certificate signing service via http. + * The server will require keystorePath, keystore password and key password via command line input. + * The Intermediate CA certificate,Intermediate CA private key and Root CA Certificate should use alias name specified in [X509Utilities] + */ +class CertificateSigningServer(val webServerAddr: HostAndPort, val certSigningService: CertificateSigningService) : Closeable { + companion object { + val log = loggerFor() + fun Server.hostAndPort(): HostAndPort { + val connector = server.connectors.first() as ServerConnector + return HostAndPort.fromParts(connector.host, connector.localPort) + } + } + + val server: Server = initWebServer() + + override fun close() { + log.info("Shutting down CertificateSigningService...") + server.stop() + } + + private fun initWebServer(): Server { + return Server(InetSocketAddress(webServerAddr.hostText, webServerAddr.port)).apply { + log.info("Starting CertificateSigningService...") + handler = HandlerCollection().apply { + addHandler(buildServletContextHandler()) + } + start() + log.info("CertificateSigningService started on ${server.hostAndPort()}") + } + } + + private fun buildServletContextHandler(): ServletContextHandler { + return ServletContextHandler().apply { + contextPath = "/" + val resourceConfig = ResourceConfig().apply { + // Add your API provider classes (annotated for JAX-RS) here + register(certSigningService) + } + val jerseyServlet = ServletHolder(ServletContainer(resourceConfig)).apply { + initOrder = 0 // Initialise at server start + } + addServlet(jerseyServlet, "/api/*") + } + } +} + +object ParamsSpec { + val parser = OptionParser() + val host = parser.accepts("host", "The hostname permissioning server will be running on.") + .withRequiredArg().defaultsTo("localhost") + val port = parser.accepts("port", "The port number permissioning server will be running on.") + .withRequiredArg().ofType(Int::class.java).defaultsTo(0) + val keystorePath = parser.accepts("keystore", "The path to the keyStore containing and root certificate, intermediate CA certificate and private key.") + .withRequiredArg().required() + val storePassword = parser.accepts("storePassword", "Keystore's password.") + .withRequiredArg().required() + val caKeyPassword = parser.accepts("caKeyPassword", "Intermediate CA private key password.") + .withRequiredArg().required() +} + +fun main(args: Array) { + LogHelper.setLevel(CertificateSigningServer::class) + val log = CertificateSigningServer.log + log.info("Starting certificate signing server.") + try { + ParamsSpec.parser.parse(*args) + } catch (ex: Exception) { + log.error("Unable to parse args", ex) + ParamsSpec.parser.printHelpOn(System.out) + exitProcess(1) + }.run { + // Load keystore from input path, default to Dev keystore from jar resource if path not defined. + val storePassword = valueOf(ParamsSpec.storePassword) + val keyPassword = valueOf(ParamsSpec.caKeyPassword) + val keystore = X509Utilities.loadKeyStore(Paths.get(valueOf(ParamsSpec.keystorePath)).normalize(), storePassword) + val intermediateCACertAndKey = X509Utilities.loadCertificateAndKey(keystore, keyPassword, X509Utilities.CORDA_INTERMEDIATE_CA_PRIVATE_KEY) + val rootCA = keystore.getCertificate(X509Utilities.CORDA_ROOT_CA_PRIVATE_KEY) + + // TODO: Create a proper request storage using database or other storage technology. + val service = CertificateSigningService(intermediateCACertAndKey, rootCA, InMemoryCertificationRequestStorage()) + + CertificateSigningServer(HostAndPort.fromParts(valueOf(ParamsSpec.host), valueOf(ParamsSpec.port)), service).use { + it.server.join() + } + } +} \ No newline at end of file diff --git a/netpermission/src/main/kotlin/com/r3corda/netpermission/internal/CertificateSigningService.kt b/netpermission/src/main/kotlin/com/r3corda/netpermission/internal/CertificateSigningService.kt new file mode 100644 index 0000000000..d83b86ae93 --- /dev/null +++ b/netpermission/src/main/kotlin/com/r3corda/netpermission/internal/CertificateSigningService.kt @@ -0,0 +1,86 @@ +package com.r3corda.netpermission.internal + +import com.r3corda.core.crypto.X509Utilities +import com.r3corda.core.crypto.X509Utilities.CORDA_CLIENT_CA +import com.r3corda.core.crypto.X509Utilities.CORDA_INTERMEDIATE_CA +import com.r3corda.core.crypto.X509Utilities.CORDA_ROOT_CA +import com.r3corda.netpermission.internal.persistence.CertificationData +import com.r3corda.netpermission.internal.persistence.CertificationRequestStorage +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequest +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.security.cert.Certificate +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import javax.servlet.http.HttpServletRequest +import javax.ws.rs.* +import javax.ws.rs.core.Context +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response +import javax.ws.rs.core.Response.noContent +import javax.ws.rs.core.Response.ok + +/** + * Provides functionality for asynchronous submission of certificate signing requests and retrieval of the results. + */ +@Path("") +class CertificateSigningService(val intermediateCACertAndKey: X509Utilities.CACertAndKey, val rootCert: Certificate, val storage: CertificationRequestStorage) { + @Context lateinit var request: HttpServletRequest + /** + * Accept stream of [PKCS10CertificationRequest] from user and persists in [CertificationRequestStorage] for approval. + * Server returns HTTP 200 response with random generated request Id after request has been persisted. + */ + @POST + @Path("certificate") + @Consumes(MediaType.APPLICATION_OCTET_STREAM) + @Produces(MediaType.TEXT_PLAIN) + fun submitRequest(input: InputStream): Response { + val certificationRequest = input.use { + JcaPKCS10CertificationRequest(it.readBytes()) + } + // TODO: Certificate signing request verifications. + // TODO: Use jira api / slack bot to semi automate the approval process? + // TODO: Acknowledge to user we have received the request via email? + val requestId = storage.saveRequest(CertificationData(request.remoteHost, request.remoteAddr, certificationRequest)) + return ok(requestId).build() + } + + /** + * Retrieve Certificate signing request from storage using the [requestId] and create a signed certificate if request has been approved. + * Returns HTTP 200 with DER encoded signed certificates if request has been approved else HTTP 204 No content + */ + @GET + @Path("certificate/{var}") + @Produces(MediaType.APPLICATION_OCTET_STREAM) + fun retrieveCert(@PathParam("var") requestId: String): Response { + val data = storage.getApprovedRequest(requestId) + return if (data != null) { + val clientCert = storage.getOrElseCreateCertificate(requestId) { + JcaPKCS10CertificationRequest(data.request).run { + X509Utilities.createServerCert(subject, publicKey, intermediateCACertAndKey, + if (data.ipAddr == data.hostName) listOf() else listOf(data.hostName), listOf(data.ipAddr)) + } + } + // Write certificate chain to a zip stream and extract the bit array output. + ByteArrayOutputStream().use { + ZipOutputStream(it).use { + zipStream -> + // Client certificate must come first and root certificate should come last. + mapOf(CORDA_CLIENT_CA to clientCert, + CORDA_INTERMEDIATE_CA to intermediateCACertAndKey.certificate, + CORDA_ROOT_CA to rootCert).forEach { + zipStream.putNextEntry(ZipEntry("${it.key}.cer")) + zipStream.write(it.value.encoded) + zipStream.setComment(it.key) + zipStream.closeEntry() + } + } + ok(it.toByteArray()) + .type("application/zip") + .header("Content-Disposition", "attachment; filename=\"certificates.zip\"") + } + } else { + noContent() + }.build() + } +} \ No newline at end of file diff --git a/netpermission/src/main/kotlin/com/r3corda/netpermission/internal/persistence/CertificationRequestStorage.kt b/netpermission/src/main/kotlin/com/r3corda/netpermission/internal/persistence/CertificationRequestStorage.kt new file mode 100644 index 0000000000..f9b0034441 --- /dev/null +++ b/netpermission/src/main/kotlin/com/r3corda/netpermission/internal/persistence/CertificationRequestStorage.kt @@ -0,0 +1,27 @@ +package com.r3corda.netpermission.internal.persistence + +import org.bouncycastle.pkcs.PKCS10CertificationRequest +import java.security.cert.Certificate +/** + * Provide certificate signing request storage for the certificate signing server. + */ +interface CertificationRequestStorage { + /** + * Persist [certificationData] in storage for further approval, returns randomly generated request ID. + */ + fun saveRequest(certificationData: CertificationData): String + + /** + * Retrieve approved certificate singing request and Host/IP information using [requestId]. + * Returns [CertificationData] if request has been approved, else returns null. + */ + fun getApprovedRequest(requestId: String): CertificationData? + + /** + * Retrieve client certificate with provided [requestId]. + * Generate new certificate and store in storage using provided [certificateGenerator] if certificate does not exist. + */ + fun getOrElseCreateCertificate(requestId: String, certificateGenerator: () -> Certificate): Certificate +} + +data class CertificationData(val hostName: String, val ipAddr: String, val request: PKCS10CertificationRequest) \ No newline at end of file diff --git a/netpermission/src/main/kotlin/com/r3corda/netpermission/internal/persistence/InMemoryCertificationRequestStorage.kt b/netpermission/src/main/kotlin/com/r3corda/netpermission/internal/persistence/InMemoryCertificationRequestStorage.kt new file mode 100644 index 0000000000..cc49d2c689 --- /dev/null +++ b/netpermission/src/main/kotlin/com/r3corda/netpermission/internal/persistence/InMemoryCertificationRequestStorage.kt @@ -0,0 +1,24 @@ +package com.r3corda.netpermission.internal.persistence + +import com.r3corda.core.crypto.SecureHash +import java.security.cert.Certificate +import java.util.* + +class InMemoryCertificationRequestStorage : CertificationRequestStorage { + val requestStore = HashMap() + val certificateStore = HashMap() + + override fun getOrElseCreateCertificate(requestId: String, certificateGenerator: () -> Certificate): Certificate { + return certificateStore.getOrPut(requestId, certificateGenerator) + } + + override fun getApprovedRequest(requestId: String): CertificationData? { + return requestStore[requestId] + } + + override fun saveRequest(certificationData: CertificationData): String { + val requestId = SecureHash.randomSHA256().toString() + requestStore.put(requestId, certificationData) + return requestId + } +} \ No newline at end of file diff --git a/netpermission/src/test/kotlin/com/r3corda/netpermission/CertificateSigningServiceTest.kt b/netpermission/src/test/kotlin/com/r3corda/netpermission/CertificateSigningServiceTest.kt new file mode 100644 index 0000000000..e8ccb4f3bb --- /dev/null +++ b/netpermission/src/test/kotlin/com/r3corda/netpermission/CertificateSigningServiceTest.kt @@ -0,0 +1,116 @@ +package com.r3corda.netpermission + +import com.google.common.net.HostAndPort +import com.nhaarman.mockito_kotlin.* +import com.r3corda.core.crypto.SecureHash +import com.r3corda.core.crypto.X509Utilities +import com.r3corda.core.seconds +import com.r3corda.netpermission.CertificateSigningServer.Companion.hostAndPort +import com.r3corda.netpermission.internal.CertificateSigningService +import com.r3corda.netpermission.internal.persistence.CertificationData +import com.r3corda.netpermission.internal.persistence.CertificationRequestStorage +import org.junit.Test +import sun.security.x509.X500Name +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.security.cert.Certificate +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.* +import java.util.zip.ZipInputStream +import kotlin.test.assertEquals + +class CertificateSigningServiceTest { + private fun getSigningServer(storage: CertificationRequestStorage): CertificateSigningServer { + val rootCA = X509Utilities.createSelfSignedCACert("Corda Node Root CA") + val intermediateCA = X509Utilities.createSelfSignedCACert("Corda Node Intermediate CA") + return CertificateSigningServer(HostAndPort.fromParts("localhost", 0), CertificateSigningService(intermediateCA, rootCA.certificate, storage)) + } + + @Test + fun testSubmitRequest() { + val id = SecureHash.randomSHA256().toString() + + val storage: CertificationRequestStorage = mock { + on { saveRequest(any()) }.then { id } + } + + getSigningServer(storage).use { + val keyPair = X509Utilities.generateECDSAKeyPairForSSL() + val request = X509Utilities.createCertificateSigningRequest("Test", "London", "admin@test.com", keyPair) + // Post request to signing server via http. + val submitRequest = { + val conn = URL("http://${it.server.hostAndPort()}/api/certificate").openConnection() as HttpURLConnection + conn.doOutput = true + conn.requestMethod = "POST" + conn.setRequestProperty("Content-Type", "application/octet-stream") + conn.outputStream.write(request.encoded) + conn.inputStream.bufferedReader().readLine() + } + + assertEquals(id, submitRequest()) + verify(storage, times(1)).saveRequest(any()) + submitRequest() + verify(storage, times(2)).saveRequest(any()) + } + } + + @Test + fun testRetrieveCertificate() { + val keyPair = X509Utilities.generateECDSAKeyPairForSSL() + val id = SecureHash.randomSHA256().toString() + var count = 0 + val storage: CertificationRequestStorage = mock { + on { getApprovedRequest(eq(id)) }.then { + if (count < 5) null else CertificationData("", "", X509Utilities.createCertificateSigningRequest("LegalName", + "London", "admin@test.com", keyPair)) + } + on { getOrElseCreateCertificate(eq(id), any()) }.thenAnswer { (it.arguments[1] as () -> Certificate)() } + } + + getSigningServer(storage).use { + val poll = { + val url = URL("http://${it.server.hostAndPort()}/api/certificate/$id") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "GET" + + when (conn.responseCode) { + HttpURLConnection.HTTP_OK -> conn.inputStream.use { + ZipInputStream(it).use { + val certificates = ArrayList() + while (it.nextEntry != null) { + certificates.add(CertificateFactory.getInstance("X.509").generateCertificate(it)) + } + certificates + } + } + HttpURLConnection.HTTP_NO_CONTENT -> null + else -> + throw IOException("Cannot connect to Certificate Signing Server, HTTP response code : ${conn.responseCode}") + } + } + + var certificates = poll() + + while (certificates == null) { + Thread.sleep(1.seconds.toMillis()) + count++ + certificates = poll() + } + + verify(storage, times(6)).getApprovedRequest(any()) + assertEquals(3, certificates.size) + + (certificates.first() as X509Certificate).run { + assertEquals("LegalName", (subjectDN as X500Name).commonName) + assertEquals("London", (subjectDN as X500Name).locality) + } + + (certificates.last() as X509Certificate).run { + assertEquals("Corda Node Root CA", (subjectDN as X500Name).commonName) + assertEquals("London", (subjectDN as X500Name).locality) + } + } + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 5eb65bc579..76590d2a9a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -7,4 +7,5 @@ include 'client' include 'experimental' include 'test-utils' include 'network-simulator' +include 'netpermission' include 'explorer'