diff --git a/doorman/src/main/kotlin/com/r3/corda/doorman/DoormanParameters.kt b/doorman/src/main/kotlin/com/r3/corda/doorman/DoormanParameters.kt index e4c2540020..54d10cab84 100644 --- a/doorman/src/main/kotlin/com/r3/corda/doorman/DoormanParameters.kt +++ b/doorman/src/main/kotlin/com/r3/corda/doorman/DoormanParameters.kt @@ -17,14 +17,12 @@ data class DoormanParameters(val basedir: Path, val host: String, val port: Int, val dataSourceProperties: Properties, + val mode: Mode, val databaseProperties: Properties? = null, - val keygen: Boolean = false, - val rootKeygen: Boolean = false, val jiraConfig: JiraConfig? = null, - val keystorePath: Path = basedir / "certificates" / "caKeystore.jks", - val rootStorePath: Path = basedir / "certificates" / "rootCAKeystore.jks" + val keystorePath: Path? = null, // basedir / "certificates" / "caKeystore.jks", + val rootStorePath: Path? = null // basedir / "certificates" / "rootCAKeystore.jks" ) { - val mode = if (rootKeygen) Mode.ROOT_KEYGEN else if (keygen) Mode.CA_KEYGEN else Mode.DOORMAN enum class Mode { DOORMAN, CA_KEYGEN, ROOT_KEYGEN @@ -43,10 +41,9 @@ fun parseParameters(vararg args: String): DoormanParameters { val argConfig = args.toConfigWithOptions { accepts("basedir", "Overriding configuration filepath, default to current directory.").withRequiredArg().defaultsTo(".").describedAs("filepath") accepts("configFile", "Overriding configuration file, default to <>/node.conf.").withRequiredArg().describedAs("filepath") - accepts("keygen", "Generate CA keypair and certificate using provide Root CA key.").withOptionalArg() - accepts("rootKeygen", "Generate Root CA keypair and certificate.").withOptionalArg() - accepts("keystorePath", "CA keystore filepath, default to [basedir]/certificates/caKeystore.jks.").withRequiredArg().describedAs("filepath") - accepts("rootStorePath", "Root CA keystore filepath, default to [basedir]/certificates/rootCAKeystore.jks.").withRequiredArg().describedAs("filepath") + accepts("mode", "Execution mode. Allowed values: ${DoormanParameters.Mode.values()}").withRequiredArg().defaultsTo(DoormanParameters.Mode.DOORMAN.name) + accepts("keystorePath", "CA keystore filepath").withRequiredArg().describedAs("filepath") + accepts("rootStorePath", "Root CA keystore filepath").withRequiredArg().describedAs("filepath") accepts("keystorePassword", "CA keystore password.").withRequiredArg().describedAs("password") accepts("caPrivateKeyPassword", "CA private key password.").withRequiredArg().describedAs("password") accepts("rootKeystorePassword", "Root CA keystore password.").withRequiredArg().describedAs("password") diff --git a/doorman/src/main/kotlin/com/r3/corda/doorman/DoormanWebService.kt b/doorman/src/main/kotlin/com/r3/corda/doorman/DoormanWebService.kt index f81783e4bf..4c84cdb9e1 100644 --- a/doorman/src/main/kotlin/com/r3/corda/doorman/DoormanWebService.kt +++ b/doorman/src/main/kotlin/com/r3/corda/doorman/DoormanWebService.kt @@ -3,7 +3,7 @@ package com.r3.corda.doorman import com.r3.corda.doorman.persistence.CertificateResponse import com.r3.corda.doorman.persistence.CertificationRequestData import com.r3.corda.doorman.persistence.CertificationRequestStorage -import net.corda.node.utilities.CertificateAndKeyPair +import com.r3.corda.doorman.signer.DefaultCsrHandler import net.corda.node.utilities.X509Utilities.CORDA_CLIENT_CA import net.corda.node.utilities.X509Utilities.CORDA_INTERMEDIATE_CA import net.corda.node.utilities.X509Utilities.CORDA_ROOT_CA @@ -11,7 +11,6 @@ import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequest import org.codehaus.jackson.map.ObjectMapper 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 @@ -26,7 +25,7 @@ import javax.ws.rs.core.Response.Status.UNAUTHORIZED * Provides functionality for asynchronous submission of certificate signing requests and retrieval of the results. */ @Path("") -class DoormanWebService(val intermediateCACertAndKey: CertificateAndKeyPair, val rootCert: Certificate, val storage: CertificationRequestStorage, val serverStatus: DoormanServerStatus) { +class DoormanWebService(val csrHandler: DefaultCsrHandler, val serverStatus: DoormanServerStatus) { @Context lateinit var request: HttpServletRequest /** * Accept stream of [PKCS10CertificationRequest] from user and persists in [CertificationRequestStorage] for approval. @@ -43,7 +42,7 @@ class DoormanWebService(val intermediateCACertAndKey: CertificateAndKeyPair, val // 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(CertificationRequestData(request.remoteHost, request.remoteAddr, certificationRequest)) + val requestId = csrHandler.saveRequest(CertificationRequestData(request.remoteHost, request.remoteAddr, certificationRequest)) return ok(requestId).build() } @@ -55,22 +54,17 @@ class DoormanWebService(val intermediateCACertAndKey: CertificateAndKeyPair, val @Path("certificate/{var}") @Produces(MediaType.APPLICATION_OCTET_STREAM) fun retrieveCert(@PathParam("var") requestId: String): Response { - val response = storage.getResponse(requestId) + val response = csrHandler.getResponse(requestId) return when (response) { is CertificateResponse.Ready -> { // Write certificate chain to a zip stream and extract the bit array output. val baos = ByteArrayOutputStream() ZipOutputStream(baos).use { zip -> // Client certificate must come first and root certificate should come last. - val entries = listOf( - CORDA_CLIENT_CA to response.certificate, - CORDA_INTERMEDIATE_CA to intermediateCACertAndKey.certificate.toX509Certificate(), - CORDA_ROOT_CA to rootCert - ) - entries.forEach { + val certificates = ArrayList(response.certificatePath.certificates) + listOf(CORDA_CLIENT_CA, CORDA_INTERMEDIATE_CA, CORDA_ROOT_CA).zip(certificates).forEach { zip.putNextEntry(ZipEntry("${it.first}.cer")) zip.write(it.second.encoded) - zip.setComment(it.first) zip.closeEntry() } } diff --git a/doorman/src/main/kotlin/com/r3/corda/doorman/Main.kt b/doorman/src/main/kotlin/com/r3/corda/doorman/Main.kt index 2b6e7e6018..2db1035d8e 100644 --- a/doorman/src/main/kotlin/com/r3/corda/doorman/Main.kt +++ b/doorman/src/main/kotlin/com/r3/corda/doorman/Main.kt @@ -3,12 +3,12 @@ package com.r3.corda.doorman import com.atlassian.jira.rest.client.internal.async.AsynchronousJiraRestClientFactory import com.google.common.net.HostAndPort import com.r3.corda.doorman.DoormanServer.Companion.logger +import com.r3.corda.doorman.persistence.ApprovingAllCertificateRequestStorage import com.r3.corda.doorman.persistence.CertificationRequestStorage import com.r3.corda.doorman.persistence.DBCertificateRequestStorage import com.r3.corda.doorman.persistence.DoormanSchemaService -import com.r3.corda.doorman.persistence.JiraCertificateRequestStorage +import com.r3.corda.doorman.signer.* import net.corda.core.crypto.Crypto -import net.corda.core.identity.CordaX500Name import net.corda.core.internal.createDirectories import net.corda.core.utilities.loggerFor import net.corda.core.utilities.seconds @@ -17,10 +17,6 @@ import net.corda.node.utilities.X509Utilities.CORDA_INTERMEDIATE_CA import net.corda.node.utilities.X509Utilities.CORDA_ROOT_CA import net.corda.node.utilities.X509Utilities.createCertificate import org.bouncycastle.asn1.x500.X500Name -import org.bouncycastle.asn1.x509.GeneralName -import org.bouncycastle.asn1.x509.GeneralSubtree -import org.bouncycastle.asn1.x509.NameConstraints -import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequest import org.eclipse.jetty.server.Server import org.eclipse.jetty.server.ServerConnector import org.eclipse.jetty.server.handler.HandlerCollection @@ -32,7 +28,6 @@ import java.io.Closeable import java.lang.Thread.sleep import java.net.InetSocketAddress import java.net.URI -import java.security.cert.Certificate import java.time.Instant import kotlin.concurrent.thread import kotlin.system.exitProcess @@ -43,24 +38,25 @@ import kotlin.system.exitProcess * 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 DoormanServer(webServerAddr: HostAndPort, val caCertAndKey: CertificateAndKeyPair, val rootCACert: Certificate, val storage: CertificationRequestStorage) : Closeable { +class DoormanServer(webServerAddr: HostAndPort, val csrHandler: DefaultCsrHandler) : Closeable { val serverStatus = DoormanServerStatus() companion object { val logger = loggerFor() } - private val server: Server = Server(InetSocketAddress(webServerAddr.hostText, webServerAddr.port)).apply { + private val server: Server = Server(InetSocketAddress(webServerAddr.host, webServerAddr.port)).apply { handler = HandlerCollection().apply { addHandler(buildServletContextHandler()) } } - val hostAndPort: HostAndPort get() = server.connectors - .map { it as? ServerConnector } - .filterNotNull() - .map { HostAndPort.fromParts(it.host, it.localPort) } - .first() + val hostAndPort: HostAndPort + get() = server.connectors + .map { it as? ServerConnector } + .filterNotNull() + .map { HostAndPort.fromParts(it.host, it.localPort) } + .first() override fun close() { logger.info("Shutting down Doorman Web Services...") @@ -81,25 +77,7 @@ class DoormanServer(webServerAddr: HostAndPort, val caCertAndKey: CertificateAnd sleep(10.seconds.toMillis()) // TODO: Handle rejected request? serverStatus.lastRequestCheckTime = Instant.now() - for (id in storage.getApprovedRequestIds()) { - storage.approveRequest(id) { - val request = JcaPKCS10CertificationRequest(request) - // The sub certs issued by the client must satisfy this directory name (or legal name in Corda) constraints, sub certs' directory name must be within client CA's name's subtree, - // please see [sun.security.x509.X500Name.isWithinSubtree()] for more information. - // We assume all attributes in the subject name has been checked prior approval. - // TODO: add validation to subject name. - val nameConstraints = NameConstraints(arrayOf(GeneralSubtree(GeneralName(GeneralName.directoryName, CordaX500Name.build(request.subject).copy(commonName = null).x500Name))), arrayOf()) - createCertificate(CertificateType.CLIENT_CA, - caCertAndKey.certificate, - caCertAndKey.keyPair, - CordaX500Name.build(request.subject).copy(commonName = X509Utilities.CORDA_CLIENT_CA_CN), - request.publicKey, - nameConstraints = nameConstraints).toX509Certificate() - } - logger.info("Approved request $id") - serverStatus.lastApprovalTime = Instant.now() - serverStatus.approvedRequests++ - } + csrHandler.sign() } catch (e: Exception) { // Log the error and carry on. logger.error("Error encountered when approving request.", e) @@ -113,7 +91,7 @@ class DoormanServer(webServerAddr: HostAndPort, val caCertAndKey: CertificateAnd contextPath = "/" val resourceConfig = ResourceConfig().apply { // Add your API provider classes (annotated for JAX-RS) here - register(DoormanWebService(caCertAndKey, rootCACert, storage, serverStatus)) + register(DoormanWebService(csrHandler, serverStatus)) } val jerseyServlet = ServletHolder(ServletContainer(resourceConfig)).apply { initOrder = 0 // Initialise at server start @@ -124,12 +102,10 @@ class DoormanServer(webServerAddr: HostAndPort, val caCertAndKey: CertificateAnd } data class DoormanServerStatus(var serverStartTime: Instant? = null, - var lastRequestCheckTime: Instant? = null, - var lastApprovalTime: Instant? = null, - var approvedRequests: Int = 0) + var lastRequestCheckTime: Instant? = null) /** Read password from console, do a readLine instead if console is null (e.g. when debugging in IDE). */ -private fun readPassword(fmt: String): String { +internal fun readPassword(fmt: String): String { return if (System.console() != null) { String(System.console().readPassword(fmt)) } else { @@ -139,6 +115,9 @@ private fun readPassword(fmt: String): String { } private fun DoormanParameters.generateRootKeyPair() { + if (rootStorePath == null) { + throw IllegalArgumentException("The 'rootStorePath' parameter must be specified when generating keys!") + } println("Generating Root CA keypair and certificate.") // Get password from console if not in config. val rootKeystorePassword = rootKeystorePassword ?: readPassword("Root Keystore Password: ") @@ -164,6 +143,13 @@ private fun DoormanParameters.generateRootKeyPair() { } private fun DoormanParameters.generateCAKeyPair() { + if (keystorePath == null) { + throw IllegalArgumentException("The 'keystorePath' parameter must be specified when generating keys!") + } + + if (rootStorePath == null) { + throw IllegalArgumentException("The 'rootStorePath' parameter must be specified when generating keys!") + } println("Generating Intermediate CA keypair and certificate using root keystore $rootStorePath.") // Get password from console if not in config. val rootKeystorePassword = rootKeystorePassword ?: readPassword("Root Keystore Password: ") @@ -194,40 +180,43 @@ private fun DoormanParameters.generateCAKeyPair() { println(loadKeyStore(keystorePath, keystorePassword).getCertificate(CORDA_INTERMEDIATE_CA).publicKey) } -private fun DoormanParameters.startDoorman() { +private fun DoormanParameters.startDoorman(isLocalSigning: Boolean = false) { logger.info("Starting Doorman server.") - // Get password from console if not in config. - val keystorePassword = keystorePassword ?: readPassword("Keystore Password: ") - val caPrivateKeyPassword = caPrivateKeyPassword ?: readPassword("CA Private Key Password: ") - - val keystore = loadOrCreateKeyStore(keystorePath, keystorePassword) - val rootCACert = keystore.getCertificateChain(CORDA_INTERMEDIATE_CA).last() - val caCertAndKey = keystore.getCertificateAndKeyPair(CORDA_INTERMEDIATE_CA, caPrivateKeyPassword) // Create DB connection. val database = configureDatabase(dataSourceProperties, databaseProperties, { DoormanSchemaService() }, createIdentityService = { // Identity service not needed doorman, corda persistence is not very generic. throw UnsupportedOperationException() }) - - val requestStorage = DBCertificateRequestStorage(database) - - val storage = if (jiraConfig == null) { + val csrHandler = if (jiraConfig == null) { logger.warn("Doorman server is in 'Approve All' mode, this will approve all incoming certificate signing request.") - // Approve all pending request. - object : CertificationRequestStorage by requestStorage { - // The doorman is in approve all mode, returns all pending request id as approved request id. - override fun getApprovedRequestIds() = getPendingRequestIds() - } + val storage = ApprovingAllCertificateRequestStorage(database) + DefaultCsrHandler(storage, buildLocalSigner(storage, this)) } else { + val storage = DBCertificateRequestStorage(database) + val signer = if (isLocalSigning) { + buildLocalSigner(storage, this) + } else { + ExternalSigner() + } val jiraClient = AsynchronousJiraRestClientFactory().createWithBasicHttpAuthentication(URI(jiraConfig.address), jiraConfig.username, jiraConfig.password) - JiraCertificateRequestStorage(requestStorage, jiraClient, jiraConfig.projectCode, jiraConfig.doneTransitionCode) + JiraCsrHandler(jiraClient, jiraConfig.projectCode, jiraConfig.doneTransitionCode, storage, signer) } - - val doorman = DoormanServer(HostAndPort.fromParts(host, port), caCertAndKey, rootCACert, storage) + val doorman = DoormanServer(HostAndPort.fromParts(host, port), csrHandler) doorman.start() Runtime.getRuntime().addShutdownHook(thread(start = false) { doorman.close() }) } +private fun buildLocalSigner(storage: CertificationRequestStorage, parameters: DoormanParameters): Signer { + checkNotNull(parameters.keystorePath) {"The keystorePath parameter must be specified when using local signing!"} + // Get password from console if not in config. + val keystorePassword = parameters.keystorePassword ?: readPassword("Keystore Password: ") + val caPrivateKeyPassword = parameters.caPrivateKeyPassword ?: readPassword("CA Private Key Password: ") + val keystore = loadOrCreateKeyStore(parameters.keystorePath!!, keystorePassword) + val rootCACert = keystore.getCertificateChain(X509Utilities.CORDA_INTERMEDIATE_CA).last() + val caCertAndKey = keystore.getCertificateAndKeyPair(X509Utilities.CORDA_INTERMEDIATE_CA, caPrivateKeyPassword) + return LocalSigner(storage, caCertAndKey, rootCACert) +} + fun main(args: Array) { try { // TODO : Remove config overrides and solely use config file after testnet is finalized. @@ -235,7 +224,7 @@ fun main(args: Array) { when (mode) { DoormanParameters.Mode.ROOT_KEYGEN -> generateRootKeyPair() DoormanParameters.Mode.CA_KEYGEN -> generateCAKeyPair() - DoormanParameters.Mode.DOORMAN -> startDoorman() + DoormanParameters.Mode.DOORMAN -> startDoorman(keystorePath != null) } } } catch (e: ShowHelpException) { diff --git a/doorman/src/main/kotlin/com/r3/corda/doorman/Utilities.kt b/doorman/src/main/kotlin/com/r3/corda/doorman/Utilities.kt index c0174edfab..8b61c1f850 100644 --- a/doorman/src/main/kotlin/com/r3/corda/doorman/Utilities.kt +++ b/doorman/src/main/kotlin/com/r3/corda/doorman/Utilities.kt @@ -6,6 +6,7 @@ import joptsimple.ArgumentAcceptingOptionSpec import joptsimple.OptionParser import org.bouncycastle.cert.X509CertificateHolder import java.io.ByteArrayInputStream +import java.security.cert.CertPath import java.security.cert.Certificate import java.security.cert.CertificateFactory import java.security.cert.X509Certificate @@ -26,7 +27,7 @@ object OptionParserHelper { // Convert all command line options to Config. return ConfigFactory.parseMap(parser.recognizedOptions().mapValues { val optionSpec = it.value - if (optionSpec is ArgumentAcceptingOptionSpec<*> && !optionSpec.requiresArgument() && optionSet.has(optionSpec)) true else optionSpec.value(optionSet) + if (optionSpec is ArgumentAcceptingOptionSpec<*> && !optionSpec.requiresArgument() && optionSet.has(optionSpec)) null else optionSpec.value(optionSet) }.filterValues { it != null }) } } @@ -40,3 +41,7 @@ object CertificateUtilities { } fun X509CertificateHolder.toX509Certificate(): Certificate = CertificateUtilities.toX509Certificate(encoded) + +fun buildCertPath(vararg certificates: Certificate): CertPath { + return CertificateFactory.getInstance("X509").generateCertPath(certificates.asList()) +} \ No newline at end of file diff --git a/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/ApprovingAllCertificateRequestStorage.kt b/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/ApprovingAllCertificateRequestStorage.kt new file mode 100644 index 0000000000..8e8c83050d --- /dev/null +++ b/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/ApprovingAllCertificateRequestStorage.kt @@ -0,0 +1,14 @@ +package com.r3.corda.doorman.persistence + +import net.corda.node.utilities.CordaPersistence + +/** + * This storage automatically approves all created requests. + */ +class ApprovingAllCertificateRequestStorage(private val database: CordaPersistence) : DBCertificateRequestStorage(database) { + override fun saveRequest(certificationData: CertificationRequestData): String { + val requestId = saveRequest(certificationData) + approveRequest(requestId) + return requestId + } +} \ No newline at end of file diff --git a/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/CertificationRequestStorage.kt b/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/CertificationRequestStorage.kt index 3f63756f20..9228bc42c3 100644 --- a/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/CertificationRequestStorage.kt +++ b/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/CertificationRequestStorage.kt @@ -1,14 +1,19 @@ package com.r3.corda.doorman.persistence import org.bouncycastle.pkcs.PKCS10CertificationRequest -import java.security.cert.Certificate +import java.security.cert.CertPath /** * Provide certificate signing request storage for the certificate signing server. */ interface CertificationRequestStorage { + + companion object { + val DOORMAN_SIGNATURE = listOf("Doorman") + } + /** - * Persist [certificationData] in storage for further approval if it's a valid request. If not then it will be automically + * Persist [certificationData] in storage for further approval if it's a valid request. If not then it will be automatically * rejected and not subject to any approval process. In both cases a randomly generated request ID is returned. */ fun saveRequest(certificationData: CertificationRequestData): String @@ -24,30 +29,44 @@ interface CertificationRequestStorage { fun getResponse(requestId: String): CertificateResponse /** - * Approve the given request by generating and storing a new certificate using the provided generator. + * Approve the given request if it has not already been approved. Otherwise do nothing. + * + * @return True if the request has been approved and false otherwise. */ - fun approveRequest(requestId: String, generateCertificate: CertificationRequestData.() -> Certificate) + fun approveRequest(requestId: String, approvedBy: String = DOORMAN_SIGNATURE.first()): Boolean + + /** + * Signs the certificate signing request by assigning the given certificate. + * + * @return True if the request has been signed and false otherwise. + */ + fun signCertificate(requestId: String, signedBy: List = DOORMAN_SIGNATURE, generateCertificate: CertificationRequestData.() -> CertPath): Boolean /** * Reject the given request using the given reason. */ - fun rejectRequest(requestId: String, rejectReason: String) + fun rejectRequest(requestId: String, rejectedBy: String = DOORMAN_SIGNATURE.first(), rejectReason: String) /** * Retrieve list of request IDs waiting for approval. */ - fun getPendingRequestIds(): List + fun getNewRequestIds(): List /** * Retrieve list of approved request IDs. */ fun getApprovedRequestIds(): List + + /** + * Retrieve list of signed request IDs. + */ + fun getSignedRequestIds(): List } data class CertificationRequestData(val hostName: String, val ipAddress: String, val request: PKCS10CertificationRequest) sealed class CertificateResponse { object NotReady : CertificateResponse() - class Ready(val certificate: Certificate) : CertificateResponse() + class Ready(val certificatePath: CertPath) : CertificateResponse() class Unauthorised(val message: String) : CertificateResponse() } \ No newline at end of file diff --git a/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/DBCertificateRequestStorage.kt b/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/DBCertificateRequestStorage.kt index b66ca7dbf0..87cb24c993 100644 --- a/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/DBCertificateRequestStorage.kt +++ b/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/DBCertificateRequestStorage.kt @@ -1,11 +1,12 @@ package com.r3.corda.doorman.persistence -import com.r3.corda.doorman.CertificateUtilities import net.corda.core.crypto.SecureHash import net.corda.core.identity.CordaX500Name import net.corda.node.utilities.CordaPersistence import org.bouncycastle.pkcs.PKCS10CertificationRequest -import java.security.cert.Certificate +import java.io.ByteArrayInputStream +import java.security.cert.CertPath +import java.security.cert.CertificateFactory import java.time.Instant import javax.persistence.* import javax.persistence.criteria.CriteriaBuilder @@ -13,7 +14,7 @@ import javax.persistence.criteria.Path import javax.persistence.criteria.Predicate // TODO Relax the uniqueness requirement to be on the entire X.500 subject rather than just the legal name -class DBCertificateRequestStorage(private val database: CordaPersistence) : CertificationRequestStorage { +open class DBCertificateRequestStorage(private val database: CordaPersistence) : CertificationRequestStorage { @Entity @Table(name = "certificate_signing_request") class CertificateSigningRequest( @@ -34,20 +35,43 @@ class DBCertificateRequestStorage(private val database: CordaPersistence) : Cert @Column var request: ByteArray = ByteArray(0), - @Column(name = "request_timestamp") - var requestTimestamp: Instant = Instant.now(), + @Column(name = "created_at") + var createdAt: Instant = Instant.now(), - @Column(name = "process_timestamp", nullable = true) - var processTimestamp: Instant? = null, + @Column(name = "approved_at") + var approvedAt: Instant = Instant.now(), + + @Column(name = "approved_by", length = 64) + var approvedBy: String? = null, + + @Column + var status: Status = Status.New, + + @Column(name = "signed_by", length = 512) + @ElementCollection(targetClass = String::class, fetch = FetchType.EAGER) + var signedBy: List? = null, + + @Column(name = "signed_at") + var signedAt: Instant? = Instant.now(), + + @Column(name = "rejected_by", length = 64) + var rejectedBy: String? = null, + + @Column(name = "rejected_at") + var rejectedAt: Instant? = Instant.now(), @Lob @Column(nullable = true) - var certificate: ByteArray? = null, + var certificatePath: ByteArray? = null, @Column(name = "reject_reason", length = 256, nullable = true) var rejectReason: String? = null ) + enum class Status { + New, Approved, Rejected, Signed + } + override fun saveRequest(certificationData: CertificationRequestData): String { val requestId = SecureHash.randomSHA256().toString() @@ -60,9 +84,8 @@ class DBCertificateRequestStorage(private val database: CordaPersistence) : Cert val criteriaQuery = createQuery(CertificateSigningRequest::class.java) criteriaQuery.from(CertificateSigningRequest::class.java).run { val nameEq = equal(get(CertificateSigningRequest::legalName.name), legalName.toString()) - val certNotNull = isNotNull(get(CertificateSigningRequest::certificate.name)) - val processTimeIsNull = isNull(get(CertificateSigningRequest::processTimestamp.name)) - criteriaQuery.where(and(nameEq, or(certNotNull, processTimeIsNull))) + val statusNewOrApproved = get(CertificateSigningRequest::status.name).`in`(Status.Approved, Status.New) + criteriaQuery.where(and(nameEq, statusNewOrApproved)) } } val duplicate = session.createQuery(query).resultList.isNotEmpty() @@ -74,16 +97,14 @@ class DBCertificateRequestStorage(private val database: CordaPersistence) : Cert } catch (e: IllegalArgumentException) { Pair(certificationData.request.subject, "Name validation failed with exception : ${e.message}") } - val now = Instant.now() val request = CertificateSigningRequest( - requestId, - certificationData.hostName, - certificationData.ipAddress, - legalName.toString(), - certificationData.request.encoded, - now, + requestId = requestId, + hostName = certificationData.hostName, + ipAddress = certificationData.ipAddress, + legalName = legalName.toString(), + request = certificationData.request.encoded, rejectReason = rejectReason, - processTimestamp = rejectReason?.let { now } + status = if (rejectReason == null) Status.New else Status.Rejected ) session.save(request) } @@ -93,49 +114,68 @@ class DBCertificateRequestStorage(private val database: CordaPersistence) : Cert override fun getResponse(requestId: String): CertificateResponse { return database.transaction { val response = singleRequestWhere { builder, path -> - val eq = builder.equal(path.get(CertificateSigningRequest::requestId.name), requestId) - val timeNotNull = builder.isNotNull(path.get(CertificateSigningRequest::processTimestamp.name)) - builder.and(eq, timeNotNull) + builder.equal(path.get(CertificateSigningRequest::requestId.name), requestId) } - if (response == null) { CertificateResponse.NotReady } else { - val certificate = response.certificate - if (certificate != null) { - CertificateResponse.Ready(CertificateUtilities.toX509Certificate(certificate)) - } else { - CertificateResponse.Unauthorised(response.rejectReason!!) + when (response.status) { + Status.New, Status.Approved -> CertificateResponse.NotReady + Status.Rejected -> CertificateResponse.Unauthorised(response.rejectReason ?: "Unknown reason") + Status.Signed -> CertificateResponse.Ready(buildCertPath(response.certificatePath)) } } } } - override fun approveRequest(requestId: String, generateCertificate: CertificationRequestData.() -> Certificate) { + override fun approveRequest(requestId: String, approvedBy: String): Boolean { + var approved = false database.transaction { val request = singleRequestWhere { builder, path -> - val eq = builder.equal(path.get(CertificateSigningRequest::requestId.name), requestId) - val timeIsNull = builder.isNull(path.get(CertificateSigningRequest::processTimestamp.name)) - builder.and(eq, timeIsNull) + builder.and(builder.equal(path.get(CertificateSigningRequest::requestId.name), requestId), + builder.equal(path.get(CertificateSigningRequest::status.name), Status.New)) } if (request != null) { - request.certificate = request.toRequestData().generateCertificate().encoded - request.processTimestamp = Instant.now() + request.approvedAt = Instant.now() + request.approvedBy = approvedBy + request.status = Status.Approved session.save(request) + approved = true } } + return approved } - override fun rejectRequest(requestId: String, rejectReason: String) { + override fun signCertificate(requestId: String, signedBy: List, generateCertificate: CertificationRequestData.() -> CertPath): Boolean { + var signed = false database.transaction { val request = singleRequestWhere { builder, path -> - val eq = builder.equal(path.get(CertificateSigningRequest::requestId.name), requestId) - val timeIsNull = builder.isNull(path.get(CertificateSigningRequest::processTimestamp.name)) - builder.and(eq, timeIsNull) + builder.and(builder.equal(path.get(CertificateSigningRequest::requestId.name), requestId), + builder.equal(path.get(CertificateSigningRequest::status.name), Status.Approved)) + } + if (request != null) { + val now = Instant.now() + request.certificatePath = request.toRequestData().generateCertificate().encoded + request.status = Status.Signed + request.signedAt = now + request.signedBy = signedBy + session.save(request) + signed = true + } + } + return signed + } + + override fun rejectRequest(requestId: String, rejectedBy: String, rejectReason: String) { + database.transaction { + val request = singleRequestWhere { builder, path -> + builder.equal(path.get(CertificateSigningRequest::requestId.name), requestId) } if (request != null) { request.rejectReason = rejectReason - request.processTimestamp = Instant.now() + request.status = Status.Rejected + request.rejectedBy = rejectedBy + request.rejectedAt = Instant.now() session.save(request) } } @@ -149,21 +189,31 @@ class DBCertificateRequestStorage(private val database: CordaPersistence) : Cert }?.toRequestData() } - override fun getPendingRequestIds(): List { + override fun getApprovedRequestIds(): List { + return getRequestIdsByStatus(Status.Approved) + } + + override fun getNewRequestIds(): List { + return getRequestIdsByStatus(Status.New) + } + + override fun getSignedRequestIds(): List { + return getRequestIdsByStatus(Status.Signed) + } + + private fun getRequestIdsByStatus(status: Status): List { return database.transaction { val builder = session.criteriaBuilder val query = builder.createQuery(String::class.java).run { from(CertificateSigningRequest::class.java).run { select(get(CertificateSigningRequest::requestId.name)) - where(builder.isNull(get(CertificateSigningRequest::processTimestamp.name))) + where(builder.equal(get(CertificateSigningRequest::status.name), status)) } } session.createQuery(query).resultList } } - override fun getApprovedRequestIds(): List = emptyList() - private fun singleRequestWhere(predicate: (CriteriaBuilder, Path) -> Predicate): CertificateSigningRequest? { return database.transaction { val builder = session.criteriaBuilder @@ -176,4 +226,6 @@ class DBCertificateRequestStorage(private val database: CordaPersistence) : Cert } private fun CertificateSigningRequest.toRequestData() = CertificationRequestData(hostName, ipAddress, PKCS10CertificationRequest(request)) + + private fun buildCertPath(certPathBytes: ByteArray?) = CertificateFactory.getInstance("X509").generateCertPath(ByteArrayInputStream(certPathBytes)) } \ No newline at end of file diff --git a/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/JiraCertificateRequestStorage.kt b/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/JiraCertificateRequestStorage.kt deleted file mode 100644 index b8257301c7..0000000000 --- a/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/JiraCertificateRequestStorage.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.r3.corda.doorman.persistence - -import com.atlassian.jira.rest.client.api.JiraRestClient -import com.atlassian.jira.rest.client.api.domain.Field -import com.atlassian.jira.rest.client.api.domain.IssueType -import com.atlassian.jira.rest.client.api.domain.input.IssueInputBuilder -import com.atlassian.jira.rest.client.api.domain.input.TransitionInput -import net.corda.core.utilities.country -import net.corda.core.utilities.locality -import net.corda.core.utilities.loggerFor -import net.corda.core.utilities.organisation -import net.corda.node.utilities.X509Utilities -import org.bouncycastle.asn1.x500.style.BCStyle -import org.bouncycastle.openssl.jcajce.JcaPEMWriter -import org.bouncycastle.util.io.pem.PemObject -import java.io.StringWriter -import java.security.cert.Certificate - -class JiraCertificateRequestStorage(val delegate: CertificationRequestStorage, - val jiraClient: JiraRestClient, - val projectCode: String, - val doneTransitionCode: Int) : CertificationRequestStorage by delegate { - private enum class Status { - Approved, Rejected - } - - companion object { - private val logger = loggerFor() - } - - // The JIRA project must have a Request ID field and the Task issue type. - private val requestIdField: Field = jiraClient.metadataClient.fields.claim().find { it.name == "Request ID" }!! - private val taskIssueType: IssueType = jiraClient.metadataClient.issueTypes.claim().find { it.name == "Task" }!! - - override fun saveRequest(certificationData: CertificationRequestData): String { - val requestId = delegate.saveRequest(certificationData) - // Make sure request has been accepted. - val response = getResponse(requestId) - if (response !is CertificateResponse.Unauthorised) { - val request = StringWriter() - JcaPEMWriter(request).use { - it.writeObject(PemObject("CERTIFICATE REQUEST", certificationData.request.encoded)) - } - val organisation = certificationData.request.subject.organisation - val nearestCity = certificationData.request.subject.locality - val country = certificationData.request.subject.country - - val email = certificationData.request.getAttributes(BCStyle.E).firstOrNull()?.attrValues?.firstOrNull()?.toString() - - val issue = IssueInputBuilder().setIssueTypeId(taskIssueType.id) - .setProjectKey(projectCode) - .setDescription("Organisation: $organisation\nNearest City: $nearestCity\nCountry: $country\nEmail: $email\n\n{code}$request{code}") - .setSummary(organisation) - .setFieldValue(requestIdField.id, requestId) - // This will block until the issue is created. - jiraClient.issueClient.createIssue(issue.build()).fail { logger.error("Exception when creating JIRA issue.", it) }.claim() - } - return requestId - } - - override fun approveRequest(requestId: String, generateCertificate: CertificationRequestData.() -> Certificate) { - delegate.approveRequest(requestId, generateCertificate) - // Certificate should be created, retrieving it to attach to the jira task. - val certificate = (getResponse(requestId) as? CertificateResponse.Ready)?.certificate - // Jira only support ~ (contains) search for custom textfield. - val issue = jiraClient.searchClient.searchJql("'Request ID' ~ $requestId").claim().issues.firstOrNull() - if (issue != null) { - jiraClient.issueClient.transition(issue, TransitionInput(doneTransitionCode)).fail { logger.error("Exception when transiting JIRA status.", it) }.claim() - jiraClient.issueClient.addAttachment(issue.attachmentsUri, certificate?.encoded?.inputStream(), "${X509Utilities.CORDA_CLIENT_CA}.cer") - .fail { logger.error("Exception when uploading attachment to JIRA.", it) }.claim() - } - } - - override fun getApprovedRequestIds(): List = getRequestByStatus(Status.Approved) - - private fun getRequestByStatus(status: Status): List { - val issues = jiraClient.searchClient.searchJql("project = $projectCode AND status = $status").claim().issues - return issues.map { it.getField(requestIdField.id)?.value?.toString() }.filterNotNull() - } -} diff --git a/doorman/src/main/kotlin/com/r3/corda/doorman/signer/CsrHandler.kt b/doorman/src/main/kotlin/com/r3/corda/doorman/signer/CsrHandler.kt new file mode 100644 index 0000000000..d083c3bc5e --- /dev/null +++ b/doorman/src/main/kotlin/com/r3/corda/doorman/signer/CsrHandler.kt @@ -0,0 +1,100 @@ +package com.r3.corda.doorman.signer + +import com.atlassian.jira.rest.client.api.JiraRestClient +import com.atlassian.jira.rest.client.api.domain.Field +import com.atlassian.jira.rest.client.api.domain.IssueType +import com.atlassian.jira.rest.client.api.domain.input.IssueInputBuilder +import com.atlassian.jira.rest.client.api.domain.input.TransitionInput +import com.r3.corda.doorman.persistence.CertificateResponse +import com.r3.corda.doorman.persistence.CertificationRequestData +import com.r3.corda.doorman.persistence.CertificationRequestStorage +import net.corda.core.utilities.country +import net.corda.core.utilities.locality +import net.corda.core.utilities.loggerFor +import net.corda.core.utilities.organisation +import net.corda.node.utilities.X509Utilities +import org.bouncycastle.asn1.x500.style.BCStyle +import org.bouncycastle.openssl.jcajce.JcaPEMWriter +import org.bouncycastle.util.io.pem.PemObject +import java.io.StringWriter + +open class DefaultCsrHandler(protected val storage: CertificationRequestStorage, protected val signer: Signer) { + open fun saveRequest(certificationData: CertificationRequestData): String { + return storage.saveRequest(certificationData) + } + + open fun sign() { + for (id in storage.getApprovedRequestIds()) { + signer.sign(id) + } + } + + fun getResponse(requestId: String) = storage.getResponse(requestId) +} + +class JiraCsrHandler(private val jiraClient: JiraRestClient, + private val projectCode: String, + private val doneTransitionCode: Int, + storage: CertificationRequestStorage, signer: Signer) : DefaultCsrHandler(storage, signer) { + + companion object { + private val logger = loggerFor() + } + + // The JIRA project must have a Request ID field and the Task issue type. + private val requestIdField: Field = jiraClient.metadataClient.fields.claim().find { it.name == "Request ID" }!! + private val taskIssueType: IssueType = jiraClient.metadataClient.issueTypes.claim().find { it.name == "Task" }!! + + override fun saveRequest(certificationData: CertificationRequestData): String { + val requestId = super.saveRequest(certificationData) + // Make sure request has been accepted. + val response = storage.getResponse(requestId) + if (response !is CertificateResponse.Unauthorised) { + val request = StringWriter() + JcaPEMWriter(request).use { + it.writeObject(PemObject("CERTIFICATE REQUEST", certificationData.request.encoded)) + } + val organisation = certificationData.request.subject.organisation + val nearestCity = certificationData.request.subject.locality + val country = certificationData.request.subject.country + + val email = certificationData.request.getAttributes(BCStyle.E).firstOrNull()?.attrValues?.firstOrNull()?.toString() + + val issue = IssueInputBuilder().setIssueTypeId(taskIssueType.id) + .setProjectKey(projectCode) + .setDescription("Organisation: $organisation\nNearest City: $nearestCity\nCountry: $country\nEmail: $email\n\n{code}$request{code}") + .setSummary(organisation) + .setFieldValue(requestIdField.id, requestId) + // This will block until the issue is created. + jiraClient.issueClient.createIssue(issue.build()).fail { logger.error("Exception when creating JIRA issue.", it) }.claim() + } + return requestId + } + + override fun sign() { + val issues = jiraClient.searchClient.searchJql("project = $projectCode AND status = Approved").claim().issues + issues.map { + val requestId = it.getField(requestIdField.id)?.value?.toString() + if (requestId != null) { + var approvedBy = it.assignee?.displayName + if (approvedBy == null) { + approvedBy = "Unknown" + } + storage.approveRequest(requestId, approvedBy) + } + + } + super.sign() + // Retrieving certificates for signed CSRs to attach to the jira tasks. + storage.getSignedRequestIds().forEach { + val certificate = (storage.getResponse(it) as? CertificateResponse.Ready)?.certificatePath!!.certificates.first() + // Jira only support ~ (contains) search for custom textfield. + val issue = jiraClient.searchClient.searchJql("'Request ID' ~ $it").claim().issues.firstOrNull() + if (issue != null) { + jiraClient.issueClient.transition(issue, TransitionInput(doneTransitionCode)).fail { logger.error("Exception when transiting JIRA status.", it) }.claim() + jiraClient.issueClient.addAttachment(issue.attachmentsUri, certificate?.encoded?.inputStream(), "${X509Utilities.CORDA_CLIENT_CA}.cer") + .fail { logger.error("Exception when uploading attachment to JIRA.", it) }.claim() + } + } + } +} \ No newline at end of file diff --git a/doorman/src/main/kotlin/com/r3/corda/doorman/signer/Signer.kt b/doorman/src/main/kotlin/com/r3/corda/doorman/signer/Signer.kt new file mode 100644 index 0000000000..c303f2f572 --- /dev/null +++ b/doorman/src/main/kotlin/com/r3/corda/doorman/signer/Signer.kt @@ -0,0 +1,48 @@ +package com.r3.corda.doorman.signer + +import com.r3.corda.doorman.buildCertPath +import com.r3.corda.doorman.persistence.CertificationRequestStorage +import com.r3.corda.doorman.toX509Certificate +import net.corda.core.identity.CordaX500Name +import net.corda.node.utilities.CertificateAndKeyPair +import net.corda.node.utilities.CertificateType +import net.corda.node.utilities.X509Utilities +import org.bouncycastle.asn1.x509.GeneralName +import org.bouncycastle.asn1.x509.GeneralSubtree +import org.bouncycastle.asn1.x509.NameConstraints +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequest +import java.security.cert.Certificate + +interface Signer { + fun sign(requestId: String) +} + +class LocalSigner(private val storage: CertificationRequestStorage, + private val caCertAndKey: CertificateAndKeyPair, + private val rootCACert: Certificate) : Signer { + + override fun sign(requestId: String) { + storage.signCertificate(requestId) { + val request = JcaPKCS10CertificationRequest(request) + // The sub certs issued by the client must satisfy this directory name (or legal name in Corda) constraints, sub certs' directory name must be within client CA's name's subtree, + // please see [sun.security.x509.X500Name.isWithinSubtree()] for more information. + // We assume all attributes in the subject name has been checked prior approval. + // TODO: add validation to subject name. + val nameConstraints = NameConstraints(arrayOf(GeneralSubtree(GeneralName(GeneralName.directoryName, CordaX500Name.build(request.subject).copy(commonName = null).x500Name))), arrayOf()) + val ourCertificate = caCertAndKey.certificate + val clientCertificate = X509Utilities.createCertificate(CertificateType.CLIENT_CA, + caCertAndKey.certificate, + caCertAndKey.keyPair, + CordaX500Name.build(request.subject).copy(commonName = X509Utilities.CORDA_CLIENT_CA_CN), + request.publicKey, + nameConstraints = nameConstraints).toX509Certificate() + buildCertPath(clientCertificate, ourCertificate.toX509Certificate(), rootCACert) + } + } +} + +class ExternalSigner : Signer { + override fun sign(requestId: String) { + // Do nothing + } +} \ No newline at end of file diff --git a/doorman/src/test/kotlin/com/r3/corda/doorman/DoormanParametersTest.kt b/doorman/src/test/kotlin/com/r3/corda/doorman/DoormanParametersTest.kt index 662b19d97e..b63c014dea 100644 --- a/doorman/src/test/kotlin/com/r3/corda/doorman/DoormanParametersTest.kt +++ b/doorman/src/test/kotlin/com/r3/corda/doorman/DoormanParametersTest.kt @@ -14,9 +14,9 @@ class DoormanParametersTest { @Test fun `parse mode flag arg correctly`() { - assertEquals(DoormanParameters.Mode.CA_KEYGEN, parseParameters("--keygen", "--configFile", validConfigPath).mode) - assertEquals(DoormanParameters.Mode.ROOT_KEYGEN, parseParameters("--rootKeygen", "--configFile", validConfigPath).mode) - assertEquals(DoormanParameters.Mode.DOORMAN, parseParameters("--configFile", validConfigPath).mode) + assertEquals(DoormanParameters.Mode.CA_KEYGEN, parseParameters("--mode", "CA_KEYGEN", "--configFile", validConfigPath).mode) + assertEquals(DoormanParameters.Mode.ROOT_KEYGEN, parseParameters("--mode", "ROOT_KEYGEN", "--configFile", validConfigPath).mode) + assertEquals(DoormanParameters.Mode.DOORMAN, parseParameters("--mode", "DOORMAN", "--configFile", validConfigPath).mode) } @Test @@ -34,7 +34,7 @@ class DoormanParametersTest { fun `should fail when config missing`() { // dataSourceProperties is missing from node_fail.conf and it should fail during parsing, and shouldn't use default from reference.conf. assertFailsWith { - parseParameters("--keygen", "--keystorePath", testDummyPath, "--configFile", invalidConfigPath) + parseParameters("--configFile", invalidConfigPath) } } diff --git a/doorman/src/test/kotlin/com/r3/corda/doorman/DoormanServiceTest.kt b/doorman/src/test/kotlin/com/r3/corda/doorman/DoormanServiceTest.kt index b6cc2a4959..7ce497ceb4 100644 --- a/doorman/src/test/kotlin/com/r3/corda/doorman/DoormanServiceTest.kt +++ b/doorman/src/test/kotlin/com/r3/corda/doorman/DoormanServiceTest.kt @@ -5,6 +5,8 @@ import com.nhaarman.mockito_kotlin.* import com.r3.corda.doorman.persistence.CertificateResponse import com.r3.corda.doorman.persistence.CertificationRequestData import com.r3.corda.doorman.persistence.CertificationRequestStorage +import com.r3.corda.doorman.signer.DefaultCsrHandler +import com.r3.corda.doorman.signer.LocalSigner import net.corda.core.crypto.Crypto import net.corda.core.crypto.SecureHash import net.corda.node.utilities.CertificateAndKeyPair @@ -27,7 +29,7 @@ import java.io.IOException import java.net.HttpURLConnection import java.net.HttpURLConnection.* import java.net.URL -import java.security.cert.Certificate +import java.security.cert.CertPath import java.security.cert.X509Certificate import java.util.* import java.util.zip.ZipInputStream @@ -42,7 +44,9 @@ class DoormanServiceTest { private lateinit var doormanServer: DoormanServer private fun startSigningServer(storage: CertificationRequestStorage) { - doormanServer = DoormanServer(HostAndPort.fromParts("localhost", 0), CertificateAndKeyPair(intermediateCACert, intermediateCAKey), rootCACert.toX509Certificate(), storage) + val caCertAndKey = CertificateAndKeyPair(intermediateCACert, intermediateCAKey) + val csrManager = DefaultCsrHandler(storage, LocalSigner(storage, caCertAndKey, rootCACert.toX509Certificate())) + doormanServer = DoormanServer(HostAndPort.fromParts("localhost", 0), csrManager) doormanServer.start() } @@ -77,28 +81,32 @@ class DoormanServiceTest { val id = SecureHash.randomSHA256().toString() // Mock Storage behaviour. - val certificateStore = mutableMapOf() + val certificateStore = mutableMapOf() val storage = mock { on { getResponse(eq(id)) }.then { - certificateStore[id]?.let { CertificateResponse.Ready(it) } ?: CertificateResponse.NotReady + certificateStore[id]?.let { + CertificateResponse.Ready(it) + } ?: CertificateResponse.NotReady } - on { approveRequest(eq(id), any()) }.then { + on { signCertificate(eq(id), any(), any()) }.then { @Suppress("UNCHECKED_CAST") - val certGen = it.arguments[1] as ((CertificationRequestData) -> Certificate) + val certGen = it.arguments[2] as ((CertificationRequestData) -> CertPath) val request = CertificationRequestData("", "", X509Utilities.createCertificateSigningRequest(X500Name("CN=LegalName,L=London"), "my@mail.com", keyPair)) certificateStore[id] = certGen(request) - Unit + true } - on { getPendingRequestIds() }.then { listOf(id) } + on { getNewRequestIds() }.then { listOf(id) } } startSigningServer(storage) assertThat(pollForResponse(id)).isEqualTo(PollResponse.NotReady) - storage.approveRequest(id) { + storage.approveRequest(id) + storage.signCertificate(id) { JcaPKCS10CertificationRequest(request).run { - X509Utilities.createCertificate(CertificateType.TLS, intermediateCACert, intermediateCAKey, subject, publicKey).toX509Certificate() + val tlsCert = X509Utilities.createCertificate(CertificateType.TLS, intermediateCACert, intermediateCAKey, subject, publicKey).toX509Certificate() + buildCertPath(tlsCert, intermediateCACert.toX509Certificate(), rootCACert.toX509Certificate()) } } @@ -123,29 +131,33 @@ class DoormanServiceTest { val id = SecureHash.randomSHA256().toString() // Mock Storage behaviour. - val certificateStore = mutableMapOf() + val certificateStore = mutableMapOf() val storage = mock { on { getResponse(eq(id)) }.then { - certificateStore[id]?.let { CertificateResponse.Ready(it) } ?: CertificateResponse.NotReady + certificateStore[id]?.let { + CertificateResponse.Ready(it) + } ?: CertificateResponse.NotReady } - on { approveRequest(eq(id), any()) }.then { + on { signCertificate(eq(id), any(), any()) }.then { @Suppress("UNCHECKED_CAST") - val certGen = it.arguments[1] as ((CertificationRequestData) -> Certificate) + val certGen = it.arguments[2] as ((CertificationRequestData) -> CertPath) val request = CertificationRequestData("", "", X509Utilities.createCertificateSigningRequest(X500Name("CN=LegalName,L=London"), "my@mail.com", keyPair)) certificateStore[id] = certGen(request) - Unit + true } - on { getPendingRequestIds() }.then { listOf(id) } + on { getNewRequestIds() }.then { listOf(id) } } startSigningServer(storage) assertThat(pollForResponse(id)).isEqualTo(PollResponse.NotReady) - storage.approveRequest(id) { + storage.approveRequest(id) + storage.signCertificate(id) { JcaPKCS10CertificationRequest(request).run { val nameConstraints = NameConstraints(arrayOf(GeneralSubtree(GeneralName(GeneralName.directoryName, X500Name("CN=LegalName, L=London")))), arrayOf()) - X509Utilities.createCertificate(CertificateType.CLIENT_CA, intermediateCACert, intermediateCAKey, subject, publicKey, nameConstraints = nameConstraints).toX509Certificate() + val clientCert = X509Utilities.createCertificate(CertificateType.CLIENT_CA, intermediateCACert, intermediateCAKey, subject, publicKey, nameConstraints = nameConstraints).toX509Certificate() + buildCertPath(clientCert, intermediateCACert.toX509Certificate(), rootCACert.toX509Certificate()) } } @@ -166,7 +178,7 @@ class DoormanServiceTest { val storage = mock { on { getResponse(eq(id)) }.then { CertificateResponse.Unauthorised("Not Allowed") } - on { getPendingRequestIds() }.then { listOf(id) } + on { getNewRequestIds() }.then { listOf(id) } } startSigningServer(storage) diff --git a/doorman/src/test/kotlin/com/r3/corda/doorman/internal/persistence/DBCertificateRequestStorageTest.kt b/doorman/src/test/kotlin/com/r3/corda/doorman/internal/persistence/DBCertificateRequestStorageTest.kt index fc0e1df0eb..beb7944ef2 100644 --- a/doorman/src/test/kotlin/com/r3/corda/doorman/internal/persistence/DBCertificateRequestStorageTest.kt +++ b/doorman/src/test/kotlin/com/r3/corda/doorman/internal/persistence/DBCertificateRequestStorageTest.kt @@ -1,5 +1,6 @@ package com.r3.corda.doorman.internal.persistence +import com.r3.corda.doorman.buildCertPath import com.r3.corda.doorman.persistence.CertificateResponse import com.r3.corda.doorman.persistence.CertificationRequestData import com.r3.corda.doorman.persistence.DBCertificateRequestStorage @@ -13,22 +14,20 @@ import net.corda.node.utilities.CordaPersistence import net.corda.node.utilities.X509Utilities import net.corda.node.utilities.configureDatabase import org.assertj.core.api.Assertions.assertThat -import org.bouncycastle.asn1.x509.GeneralName -import org.bouncycastle.asn1.x509.GeneralSubtree -import org.bouncycastle.asn1.x509.NameConstraints +import org.bouncycastle.asn1.x500.X500Name import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequest import org.junit.After import org.junit.Before import org.junit.Test import java.security.KeyPair +import java.security.cert.CertPath import java.util.* import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue class DBCertificateRequestStorageTest { - private val intermediateCAKey = Crypto.generateKeyPair(X509Utilities.DEFAULT_TLS_SIGNATURE_SCHEME) - private val intermediateCACert = X509Utilities.createSelfSignedCACertificate(CordaX500Name(commonName = "Corda Node Intermediate CA", organisation = "R3 Ltd", locality = "London", country = "GB").x500Name, intermediateCAKey) private lateinit var storage: DBCertificateRequestStorage private lateinit var persistence: CordaPersistence @@ -52,32 +51,102 @@ class DBCertificateRequestStorageTest { assertEquals(request.ipAddress, ipAddress) assertEquals(request.request, this.request) } - assertThat(storage.getPendingRequestIds()).containsOnly(requestId) + assertThat(storage.getNewRequestIds()).containsOnly(requestId) } @Test fun `approve request`() { - val (request, keyPair) = createRequest("LegalName") + val (request, _) = createRequest("LegalName") // Add request to DB. val requestId = storage.saveRequest(request) // Pending request should equals to 1. - assertEquals(1, storage.getPendingRequestIds().size) + assertEquals(1, storage.getNewRequestIds().size) // Certificate should be empty. assertEquals(CertificateResponse.NotReady, storage.getResponse(requestId)) // Store certificate to DB. - approveRequest(requestId) - // Check certificate is stored in DB correctly. - val response = storage.getResponse(requestId) as CertificateResponse.Ready - assertThat(response.certificate.publicKey).isEqualTo(keyPair.public) - // Pending request should be empty. - assertTrue(storage.getPendingRequestIds().isEmpty()) + val result = storage.approveRequest(requestId) + // Check request request has been approved + assertTrue(result) + // Check request is not ready yet. + assertTrue(storage.getResponse(requestId) is CertificateResponse.NotReady) + // New request should be empty. + assertTrue(storage.getNewRequestIds().isEmpty()) + } + + @Test + fun `approve request ignores subsequent approvals`() { + // Given + val (request, _) = createRequest("LegalName") + // Add request to DB. + val requestId = storage.saveRequest(request) + storage.approveRequest(requestId) + + // When subsequent approval is performed + val result = storage.approveRequest(requestId) + // Then check request has not been approved + assertFalse(result) + } + + @Test + fun `sign request`() { + val (csr, _) = createRequest("LegalName") + // Add request to DB. + val requestId = storage.saveRequest(csr) + // New request should equals to 1. + assertEquals(1, storage.getNewRequestIds().size) + // Certificate should be empty. + assertEquals(CertificateResponse.NotReady, storage.getResponse(requestId)) + // Store certificate to DB. + storage.approveRequest(requestId) + // Check request is not ready yet. + assertTrue(storage.getResponse(requestId) is CertificateResponse.NotReady) + // New request should be empty. + assertTrue(storage.getNewRequestIds().isEmpty()) + // Sign certificate + storage.signCertificate(requestId) { + JcaPKCS10CertificationRequest(csr.request).run { + val rootCAKey = Crypto.generateKeyPair(X509Utilities.DEFAULT_TLS_SIGNATURE_SCHEME) + val rootCACert = X509Utilities.createSelfSignedCACertificate(X500Name("CN=Corda Node Root CA,L=London"), rootCAKey) + val intermediateCAKey = Crypto.generateKeyPair(X509Utilities.DEFAULT_TLS_SIGNATURE_SCHEME) + val intermediateCACert = X509Utilities.createCertificate(CertificateType.INTERMEDIATE_CA, rootCACert, rootCAKey, X500Name("CN=Corda Node Intermediate CA,L=London"), intermediateCAKey.public) + val ourCertificate = X509Utilities.createCertificate(CertificateType.TLS, intermediateCACert, intermediateCAKey, subject, publicKey).toX509Certificate() + buildCertPath(ourCertificate, intermediateCACert.toX509Certificate(), rootCACert.toX509Certificate()) + } + } + // Check request is ready + assertTrue(storage.getResponse(requestId) is CertificateResponse.Ready) + } + + @Test + fun `sign request ignores subsequent sign requests`() { + val (csr, _) = createRequest("LegalName") + // Add request to DB. + val requestId = storage.saveRequest(csr) + // Store certificate to DB. + storage.approveRequest(requestId) + val generateCert: CertificationRequestData.() -> CertPath = { + JcaPKCS10CertificationRequest(csr.request).run { + val rootCAKey = Crypto.generateKeyPair(X509Utilities.DEFAULT_TLS_SIGNATURE_SCHEME) + val rootCACert = X509Utilities.createSelfSignedCACertificate(X500Name("CN=Corda Node Root CA,L=London"), rootCAKey) + val intermediateCAKey = Crypto.generateKeyPair(X509Utilities.DEFAULT_TLS_SIGNATURE_SCHEME) + val intermediateCACert = X509Utilities.createCertificate(CertificateType.INTERMEDIATE_CA, rootCACert, rootCAKey, X500Name("CN=Corda Node Intermediate CA,L=London"), intermediateCAKey.public) + val ourCertificate = X509Utilities.createCertificate(CertificateType.TLS, intermediateCACert, intermediateCAKey, subject, publicKey).toX509Certificate() + buildCertPath(ourCertificate, intermediateCACert.toX509Certificate(), rootCACert.toX509Certificate()) + } + } + // Sign certificate + storage.signCertificate(requestId, generateCertificate = generateCert) + // When subsequent signature requested + val result = storage.signCertificate(requestId, generateCertificate = generateCert) + // Then check request has not been signed + assertFalse(result) } @Test fun `reject request`() { val requestId = storage.saveRequest(createRequest("BankA").first) - storage.rejectRequest(requestId, "Because I said so!") - assertThat(storage.getPendingRequestIds()).isEmpty() + storage.rejectRequest(requestId, rejectReason = "Because I said so!") + assertThat(storage.getNewRequestIds()).isEmpty() val response = storage.getResponse(requestId) as CertificateResponse.Unauthorised assertThat(response.message).isEqualTo("Because I said so!") } @@ -85,20 +154,20 @@ class DBCertificateRequestStorageTest { @Test fun `request with the same legal name as a pending request`() { val requestId1 = storage.saveRequest(createRequest("BankA").first) - assertThat(storage.getPendingRequestIds()).containsOnly(requestId1) + assertThat(storage.getNewRequestIds()).containsOnly(requestId1) val requestId2 = storage.saveRequest(createRequest("BankA").first) - assertThat(storage.getPendingRequestIds()).containsOnly(requestId1) + assertThat(storage.getNewRequestIds()).containsOnly(requestId1) val response2 = storage.getResponse(requestId2) as CertificateResponse.Unauthorised assertThat(response2.message).containsIgnoringCase("duplicate") // Make sure the first request is processed properly - approveRequest(requestId1) - assertThat(storage.getResponse(requestId1)).isInstanceOf(CertificateResponse.Ready::class.java) + storage.approveRequest(requestId1) + assertThat(storage.getResponse(requestId1)).isInstanceOf(CertificateResponse.NotReady::class.java) } @Test fun `request with the same legal name as a previously approved request`() { val requestId1 = storage.saveRequest(createRequest("BankA").first) - approveRequest(requestId1) + storage.approveRequest(requestId1) val requestId2 = storage.saveRequest(createRequest("BankA").first) val response2 = storage.getResponse(requestId2) as CertificateResponse.Unauthorised assertThat(response2.message).containsIgnoringCase("duplicate") @@ -107,11 +176,11 @@ class DBCertificateRequestStorageTest { @Test fun `request with the same legal name as a previously rejected request`() { val requestId1 = storage.saveRequest(createRequest("BankA").first) - storage.rejectRequest(requestId1, "Because I said so!") + storage.rejectRequest(requestId1, rejectReason = "Because I said so!") val requestId2 = storage.saveRequest(createRequest("BankA").first) - assertThat(storage.getPendingRequestIds()).containsOnly(requestId2) - approveRequest(requestId2) - assertThat(storage.getResponse(requestId2)).isInstanceOf(CertificateResponse.Ready::class.java) + assertThat(storage.getNewRequestIds()).containsOnly(requestId2) + storage.approveRequest(requestId2) + assertThat(storage.getResponse(requestId2)).isInstanceOf(CertificateResponse.NotReady::class.java) } private fun createRequest(legalName: String): Pair { @@ -123,15 +192,6 @@ class DBCertificateRequestStorageTest { return Pair(request, keyPair) } - private fun approveRequest(requestId: String) { - storage.approveRequest(requestId) { - JcaPKCS10CertificationRequest(request).run { - val nameConstraints = NameConstraints(arrayOf(GeneralSubtree(GeneralName(GeneralName.directoryName, subject))), arrayOf()) - X509Utilities.createCertificate(CertificateType.CLIENT_CA, intermediateCACert, intermediateCAKey, subject, publicKey, nameConstraints = nameConstraints).toX509Certificate() - } - } - } - private fun makeTestDataSourceProperties(nodeName: String = SecureHash.randomSHA256().toString()): Properties { val props = Properties() props.setProperty("dataSourceClassName", "org.h2.jdbcx.JdbcDataSource")