mirror of
https://github.com/corda/corda.git
synced 2025-06-15 13:48:14 +00:00
ENT-1125 bootstrap root certificate (#2151)
* ENT-1125 make nodes check that the returned signed certificate from Doorman has the expected root
This commit is contained in:
@ -0,0 +1,182 @@
|
||||
package net.corda.node.utilities.registration
|
||||
|
||||
import net.corda.core.crypto.Crypto
|
||||
import net.corda.core.identity.CordaX500Name
|
||||
import net.corda.core.internal.cert
|
||||
import net.corda.core.internal.toX509CertHolder
|
||||
import net.corda.core.utilities.contextLogger
|
||||
import net.corda.core.utilities.minutes
|
||||
import net.corda.nodeapi.internal.crypto.CertificateAndKeyPair
|
||||
import net.corda.nodeapi.internal.crypto.CertificateType
|
||||
import net.corda.nodeapi.internal.crypto.X509CertificateFactory
|
||||
import net.corda.nodeapi.internal.crypto.X509Utilities
|
||||
import net.corda.nodeapi.internal.crypto.X509Utilities.CORDA_CLIENT_CA
|
||||
import net.corda.nodeapi.internal.crypto.X509Utilities.CORDA_INTERMEDIATE_CA
|
||||
import net.corda.nodeapi.internal.crypto.X509Utilities.CORDA_ROOT_CA
|
||||
import net.corda.testing.ALICE_NAME
|
||||
import net.corda.testing.driver.PortAllocation
|
||||
import net.corda.testing.driver.driver
|
||||
import net.corda.testing.node.network.NetworkMapServer
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.bouncycastle.pkcs.PKCS10CertificationRequest
|
||||
import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequest
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.net.URL
|
||||
import java.security.KeyPair
|
||||
import java.security.cert.CertPath
|
||||
import java.security.cert.Certificate
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import javax.ws.rs.*
|
||||
import javax.ws.rs.core.MediaType
|
||||
import javax.ws.rs.core.Response
|
||||
|
||||
private const val REQUEST_ID = "requestId"
|
||||
|
||||
private val x509CertificateFactory = X509CertificateFactory()
|
||||
private val portAllocation = PortAllocation.Incremental(13000)
|
||||
|
||||
/**
|
||||
* Driver based tests for [NetworkRegistrationHelper]
|
||||
*/
|
||||
class NetworkRegistrationHelperDriverTest {
|
||||
val rootCertAndKeyPair = createSelfKeyAndSelfSignedCertificate()
|
||||
val rootCert = rootCertAndKeyPair.certificate
|
||||
val handler = RegistrationHandler(rootCertAndKeyPair)
|
||||
lateinit var server: NetworkMapServer
|
||||
lateinit var host: String
|
||||
var port: Int = 0
|
||||
val compatibilityZoneUrl get() = URL("http", host, port, "")
|
||||
|
||||
@Before
|
||||
fun startServer() {
|
||||
server = NetworkMapServer(1.minutes, portAllocation.nextHostAndPort(), handler)
|
||||
val (host, port) = server.start()
|
||||
this.host = host
|
||||
this.port = port
|
||||
}
|
||||
|
||||
@After
|
||||
fun stopServer() {
|
||||
server.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `node registration correct root cert`() {
|
||||
driver(portAllocation = portAllocation,
|
||||
compatibilityZoneURL = compatibilityZoneUrl,
|
||||
startNodesInProcess = true,
|
||||
rootCertificate = rootCert
|
||||
) {
|
||||
startNode(providedName = ALICE_NAME, initialRegistration = true).get()
|
||||
}
|
||||
|
||||
// We're getting:
|
||||
// a request to sign the certificate then
|
||||
// at least one poll request to see if the request has been approved.
|
||||
// all the network map registration and download.
|
||||
assertThat(handler.requests).startsWith("/certificate", "/certificate/" + REQUEST_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `node registration without root cert`() {
|
||||
driver(portAllocation = portAllocation,
|
||||
compatibilityZoneURL = compatibilityZoneUrl,
|
||||
startNodesInProcess = true
|
||||
) {
|
||||
assertThatThrownBy {
|
||||
startNode(providedName = ALICE_NAME, initialRegistration = true).get()
|
||||
}.isInstanceOf(java.nio.file.NoSuchFileException::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `node registration wrong root cert`() {
|
||||
driver(portAllocation = portAllocation,
|
||||
compatibilityZoneURL = compatibilityZoneUrl,
|
||||
startNodesInProcess = true,
|
||||
rootCertificate = createSelfKeyAndSelfSignedCertificate().certificate
|
||||
) {
|
||||
assertThatThrownBy {
|
||||
startNode(providedName = ALICE_NAME, initialRegistration = true).get()
|
||||
}.isInstanceOf(WrongRootCaCertificateException::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Simple registration handler which can handle a single request, which will be given request id [REQUEST_ID].
|
||||
*/
|
||||
@Path("certificate")
|
||||
class RegistrationHandler(private val certificateAndKeyPair: CertificateAndKeyPair) {
|
||||
val requests = mutableListOf<String>()
|
||||
lateinit var certificationRequest: JcaPKCS10CertificationRequest
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_OCTET_STREAM)
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
fun registration(input: InputStream): Response {
|
||||
requests += "/certificate"
|
||||
certificationRequest = input.use { JcaPKCS10CertificationRequest(it.readBytes()) }
|
||||
return Response.ok(REQUEST_ID).build()
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path(REQUEST_ID)
|
||||
fun reply(): Response {
|
||||
requests += "/certificate/" + REQUEST_ID
|
||||
val certPath = createSignedClientCertificate(certificationRequest,
|
||||
certificateAndKeyPair.keyPair, arrayOf(certificateAndKeyPair.certificate.cert))
|
||||
return buildDoormanReply(certPath.certificates.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
// TODO this logic is shared with doorman itself, refactor this to be somewhere where both doorman and these tests
|
||||
// can depend on
|
||||
private fun createSignedClientCertificate(certificationRequest: PKCS10CertificationRequest,
|
||||
caKeyPair: KeyPair,
|
||||
caCertPath: Array<Certificate>): CertPath {
|
||||
val request = JcaPKCS10CertificationRequest(certificationRequest)
|
||||
val x509CertificateHolder = X509Utilities.createCertificate(CertificateType.CLIENT_CA,
|
||||
caCertPath.first().toX509CertHolder(),
|
||||
caKeyPair,
|
||||
CordaX500Name.parse(request.subject.toString()).copy(commonName = X509Utilities.CORDA_CLIENT_CA_CN),
|
||||
request.publicKey,
|
||||
nameConstraints = null)
|
||||
return x509CertificateFactory.generateCertPath(x509CertificateHolder.cert, *caCertPath)
|
||||
}
|
||||
|
||||
// TODO this logic is shared with doorman itself, refactor this to be somewhere where both doorman and these tests
|
||||
// can depend on
|
||||
private fun buildDoormanReply(certificates: Array<Certificate>): Response {
|
||||
// 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.
|
||||
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.closeEntry()
|
||||
}
|
||||
}
|
||||
return Response.ok(baos.toByteArray())
|
||||
.type("application/zip")
|
||||
.header("Content-Disposition", "attachment; filename=\"certificates.zip\"").build()
|
||||
}
|
||||
|
||||
private fun createSelfKeyAndSelfSignedCertificate(): CertificateAndKeyPair {
|
||||
val rootCAKey = Crypto.generateKeyPair(X509Utilities.DEFAULT_TLS_SIGNATURE_SCHEME)
|
||||
val rootCACert = X509Utilities.createSelfSignedCACertificate(
|
||||
CordaX500Name(commonName = "Integration Test Corda Node Root CA",
|
||||
organisation = "R3 Ltd", locality = "London",
|
||||
country = "GB"), rootCAKey)
|
||||
return CertificateAndKeyPair(rootCACert, rootCAKey)
|
||||
}
|
@ -12,6 +12,7 @@ import net.corda.nodeapi.internal.crypto.X509Utilities.CORDA_ROOT_CA
|
||||
import org.bouncycastle.openssl.jcajce.JcaPEMWriter
|
||||
import org.bouncycastle.util.io.pem.PemObject
|
||||
import java.io.StringWriter
|
||||
import java.nio.file.Path
|
||||
import java.security.KeyPair
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.Certificate
|
||||
@ -75,10 +76,15 @@ class NetworkRegistrationHelper(private val config: NodeConfiguration, private v
|
||||
caKeyStore.addOrReplaceKey(CORDA_CLIENT_CA, keyPair.private, privateKeyPassword.toCharArray(), certificates)
|
||||
caKeyStore.deleteEntry(SELF_SIGNED_PRIVATE_KEY)
|
||||
caKeyStore.save(config.nodeKeystore, keystorePassword)
|
||||
|
||||
// Check the root certificate.
|
||||
val returnedRootCa = certificates.last()
|
||||
checkReturnedRootCaMatchesExpectedCa(returnedRootCa)
|
||||
|
||||
// Save root certificates to trust store.
|
||||
val trustStore = loadOrCreateKeyStore(config.trustStoreFile, config.trustStorePassword)
|
||||
// Assumes certificate chain always starts with client certificate and end with root certificate.
|
||||
trustStore.addOrReplaceCertificate(CORDA_ROOT_CA, certificates.last())
|
||||
trustStore.addOrReplaceCertificate(CORDA_ROOT_CA, returnedRootCa)
|
||||
trustStore.save(config.trustStoreFile, config.trustStorePassword)
|
||||
println("Node private key and certificate stored in ${config.nodeKeystore}.")
|
||||
|
||||
@ -98,6 +104,17 @@ class NetworkRegistrationHelper(private val config: NodeConfiguration, private v
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that the passed Certificate is the expected root CA.
|
||||
* @throws WrongRootCaCertificateException if the certificates don't match.
|
||||
*/
|
||||
private fun checkReturnedRootCaMatchesExpectedCa(returnedRootCa: Certificate) {
|
||||
val expected = X509Utilities.loadCertificateFromPEMFile(config.rootCaCertFile).cert
|
||||
if (expected != returnedRootCa) {
|
||||
throw WrongRootCaCertificateException(expected, returnedRootCa, config.rootCaCertFile)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll Certificate Signing Server for approved certificate,
|
||||
* enter a slow polling loop if server return null.
|
||||
@ -151,3 +168,17 @@ class NetworkRegistrationHelper(private val config: NodeConfiguration, private v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception thrown when the doorman root certificate doesn't match the expected (out-of-band) root certificate.
|
||||
* This usually means the has been a Man-in-the-middle attack when contacting the doorman.
|
||||
*/
|
||||
class WrongRootCaCertificateException(expected: Certificate,
|
||||
actual: Certificate,
|
||||
expectedFilePath: Path):
|
||||
Exception("""
|
||||
The Root CA returned back from the registration process does not match the expected Root CA
|
||||
expected: $expected
|
||||
actual: $actual
|
||||
the expected certificate is stored in: $expectedFilePath
|
||||
""".trimMargin())
|
||||
|
@ -46,6 +46,9 @@ class NetworkRegistrationHelperTest {
|
||||
baseDirectory = tempFolder.root.toPath(),
|
||||
myLegalName = ALICE.name)
|
||||
|
||||
config.rootCaCertFile.parent.createDirectories()
|
||||
X509Utilities.saveCertificateAsPEMFile(certs.last().toX509CertHolder(), config.rootCaCertFile)
|
||||
|
||||
assertFalse(config.nodeKeystore.exists())
|
||||
assertFalse(config.sslKeystore.exists())
|
||||
assertFalse(config.trustStoreFile.exists())
|
||||
|
Reference in New Issue
Block a user