From f81428eb53dcbc89462c052a94e893bb61e24749 Mon Sep 17 00:00:00 2001 From: Dominic Fox <40790090+distributedleetravis@users.noreply.github.com> Date: Fri, 24 Aug 2018 17:21:54 +0100 Subject: [PATCH] Corda 1916: signature attachment constraints (#3839) * Create constraint, extract Jar signature collection * Extract JarSignatureCollector into its own file * Jar signature collection throws exception if signatures are inconsistent * Focus testing in Jar signature collection * Extract some helper functions in test * Patch tests with mock attachment storage * Assert that generated constraint is satisfied by signed attachment * Clarify constraint selection logic * Explicit return types on extension methods * Link to docsite Signature Contrainsts documentation * Fix issue with shared JAR reading buffer --- .../client/jackson/JacksonSupportTest.kt | 11 ++ .../core/contracts/AttachmentConstraint.kt | 17 ++ .../corda/core/internal/AbstractAttachment.kt | 26 +-- .../core/internal/JarSignatureCollector.kt | 66 +++++++ .../core/transactions/TransactionBuilder.kt | 54 ++++-- .../core/internal/AbstractAttachmentTest.kt | 120 ------------- .../internal/JarSignatureCollectorTest.kt | 161 ++++++++++++++++++ .../transactions/TransactionBuilderTest.kt | 53 +++++- ...tachmentsClassLoaderStaticContractTests.kt | 13 ++ .../node/internal/InternalMockNetwork.kt | 2 +- 10 files changed, 356 insertions(+), 167 deletions(-) create mode 100644 core/src/main/kotlin/net/corda/core/internal/JarSignatureCollector.kt delete mode 100644 core/src/test/kotlin/net/corda/core/internal/AbstractAttachmentTest.kt create mode 100644 core/src/test/kotlin/net/corda/core/internal/JarSignatureCollectorTest.kt diff --git a/client/jackson/src/test/kotlin/net/corda/client/jackson/JacksonSupportTest.kt b/client/jackson/src/test/kotlin/net/corda/client/jackson/JacksonSupportTest.kt index 62f69be446..ae64ab4004 100644 --- a/client/jackson/src/test/kotlin/net/corda/client/jackson/JacksonSupportTest.kt +++ b/client/jackson/src/test/kotlin/net/corda/client/jackson/JacksonSupportTest.kt @@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode import com.fasterxml.jackson.databind.node.TextNode import com.fasterxml.jackson.dataformat.yaml.YAMLFactory import com.fasterxml.jackson.module.kotlin.convertValue +import com.nhaarman.mockito_kotlin.any import com.nhaarman.mockito_kotlin.doReturn import com.nhaarman.mockito_kotlin.whenever import net.corda.client.jackson.internal.childrenAs @@ -18,9 +19,11 @@ import net.corda.core.crypto.* import net.corda.core.crypto.CompositeKey import net.corda.core.crypto.PartialMerkleTree.PartialTree import net.corda.core.identity.* +import net.corda.core.internal.AbstractAttachment import net.corda.core.internal.DigitalSignatureWithCert import net.corda.core.node.NodeInfo import net.corda.core.node.ServiceHub +import net.corda.core.node.services.AttachmentStorage import net.corda.core.serialization.CordaSerializable import net.corda.core.serialization.SerializedBytes import net.corda.core.serialization.serialize @@ -80,10 +83,18 @@ class JacksonSupportTest(@Suppress("unused") private val name: String, factory: @Before fun setup() { + val unsignedAttachment = object : AbstractAttachment({ byteArrayOf() }) { + override val id: SecureHash get() = throw UnsupportedOperationException() + } + + val attachments = rigorousMock().also { + doReturn(unsignedAttachment).whenever(it).openAttachment(any()) + } services = rigorousMock() cordappProvider = rigorousMock() doReturn(cordappProvider).whenever(services).cordappProvider doReturn(testNetworkParameters()).whenever(services).networkParameters + doReturn(attachments).whenever(services).attachments } @Test diff --git a/core/src/main/kotlin/net/corda/core/contracts/AttachmentConstraint.kt b/core/src/main/kotlin/net/corda/core/contracts/AttachmentConstraint.kt index 4a07afa1be..b4fbe24ef4 100644 --- a/core/src/main/kotlin/net/corda/core/contracts/AttachmentConstraint.kt +++ b/core/src/main/kotlin/net/corda/core/contracts/AttachmentConstraint.kt @@ -3,10 +3,13 @@ package net.corda.core.contracts import net.corda.core.DoNotImplement import net.corda.core.KeepForDJVM import net.corda.core.contracts.AlwaysAcceptAttachmentConstraint.isSatisfiedBy +import net.corda.core.crypto.CompositeKey import net.corda.core.crypto.SecureHash +import net.corda.core.crypto.isFulfilledBy import net.corda.core.internal.AttachmentWithContext import net.corda.core.internal.isUploaderTrusted import net.corda.core.serialization.CordaSerializable +import java.security.PublicKey /** Constrain which contract-code-containing attachment can be used with a [ContractState]. */ @CordaSerializable @@ -66,4 +69,18 @@ object AutomaticHashConstraint : AttachmentConstraint { override fun isSatisfiedBy(attachment: Attachment): Boolean { throw UnsupportedOperationException("Contracts cannot be satisfied by an AutomaticHashConstraint placeholder") } +} + +/** + * An [AttachmentConstraint] that verifies that the attachment has signers that fulfil the provided [PublicKey]. + * See: [Signature Constraints](https://docs.corda.net/design/data-model-upgrades/signature-constraints.html) + * + * @param key A [PublicKey] that must be fulfilled by the owning keys of the attachment's signing parties. + */ +@KeepForDJVM +data class SignatureAttachmentConstraint( + val key: PublicKey +) : AttachmentConstraint { + override fun isSatisfiedBy(attachment: Attachment): Boolean = + key.isFulfilledBy(attachment.signers.map { it.owningKey }) } \ No newline at end of file diff --git a/core/src/main/kotlin/net/corda/core/internal/AbstractAttachment.kt b/core/src/main/kotlin/net/corda/core/internal/AbstractAttachment.kt index 94a1789ba5..3c72e5d0ef 100644 --- a/core/src/main/kotlin/net/corda/core/internal/AbstractAttachment.kt +++ b/core/src/main/kotlin/net/corda/core/internal/AbstractAttachment.kt @@ -5,15 +5,12 @@ import net.corda.core.DeleteForDJVM import net.corda.core.KeepForDJVM import net.corda.core.contracts.Attachment import net.corda.core.crypto.SecureHash -import net.corda.core.identity.Party import net.corda.core.serialization.MissingAttachmentsException import net.corda.core.serialization.SerializeAsTokenContext import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream import java.io.OutputStream -import java.security.CodeSigner -import java.security.cert.X509Certificate import java.util.jar.JarInputStream const val DEPLOYED_CORDAPP_UPLOADER = "app" @@ -35,9 +32,6 @@ abstract class AbstractAttachment(dataLoader: () -> ByteArray) : Attachment { (a as? AbstractAttachment)?.attachmentData ?: a.open().readFully() } } - - /** @see */ - private val unsignableEntryName = "META-INF/(?:.*[.](?:SF|DSA|RSA)|SIG-.*)".toRegex() } protected val attachmentData: ByteArray by lazy(dataLoader) @@ -47,24 +41,7 @@ abstract class AbstractAttachment(dataLoader: () -> ByteArray) : Attachment { override fun open(): InputStream = attachmentData.inputStream() override val signers by lazy { - // Can't start with empty set if we're doing intersections. Logically the null means "all possible signers": - var attachmentSigners: MutableSet? = null - openAsJAR().use { jar -> - val shredder = ByteArray(1024) - while (true) { - val entry = jar.nextJarEntry ?: break - if (entry.isDirectory || unsignableEntryName.matches(entry.name)) continue - while (jar.read(shredder) != -1) { // Must read entry fully for codeSigners to be valid. - // Do nothing. - } - val entrySigners = entry.codeSigners ?: emptyArray() - attachmentSigners?.retainAll(entrySigners) ?: run { attachmentSigners = entrySigners.toMutableSet() } - if (attachmentSigners!!.isEmpty()) break // Performance short-circuit. - } - } - (attachmentSigners ?: emptySet()).map { - Party(it.signerCertPath.certificates[0] as X509Certificate) - }.sortedBy { it.name.toString() } // Determinism. + openAsJAR().use(JarSignatureCollector::collectSigningParties) } override fun equals(other: Any?) = other === this || other is Attachment && other.id == this.id @@ -86,3 +63,4 @@ fun JarInputStream.extractFile(path: String, outputTo: OutputStream) { } throw FileNotFoundException(path) } + diff --git a/core/src/main/kotlin/net/corda/core/internal/JarSignatureCollector.kt b/core/src/main/kotlin/net/corda/core/internal/JarSignatureCollector.kt new file mode 100644 index 0000000000..70d8c84873 --- /dev/null +++ b/core/src/main/kotlin/net/corda/core/internal/JarSignatureCollector.kt @@ -0,0 +1,66 @@ +package net.corda.core.internal + +import net.corda.core.identity.Party +import java.security.CodeSigner +import java.security.cert.X509Certificate +import java.util.jar.JarEntry +import java.util.jar.JarInputStream + +/** + * Utility class which provides the ability to extract a list of signing parties from a [JarInputStream]. + */ +object JarSignatureCollector { + + /** @see */ + private val unsignableEntryName = "META-INF/(?:.*[.](?:SF|DSA|RSA)|SIG-.*)".toRegex() + + /** + * Returns an ordered list of every [Party] which has signed every signable item in the given [JarInputStream]. + * + * @param jar The open [JarInputStream] to collect signing parties from. + * @throws InvalidJarSignersException If the signer sets for any two signable items are different from each other. + */ + fun collectSigningParties(jar: JarInputStream): List { + val signerSets = jar.fileSignerSets + if (signerSets.isEmpty()) return emptyList() + + val (firstFile, firstSignerSet) = signerSets.first() + for ((otherFile, otherSignerSet) in signerSets.subList(1, signerSets.size)) { + if (otherSignerSet != firstSignerSet) throw InvalidJarSignersException( + """ + Mismatch between signers ${firstSignerSet.toPartiesOrderedByName()} for file $firstFile + and signers ${otherSignerSet.toPartiesOrderedByName()} for file ${otherFile}. + See https://docs.corda.net/design/data-model-upgrades/signature-constraints.html for details of the + constraints applied to attachment signatures. + """.trimIndent().replace('\n', ' ')) + } + + return firstSignerSet.toPartiesOrderedByName() + } + + private val JarInputStream.fileSignerSets: List>> get() = + entries.thatAreSignable.shreddedFrom(this).toFileSignerSet().toList() + + private val Sequence.thatAreSignable: Sequence get() = + filterNot { entry -> entry.isDirectory || unsignableEntryName.matches(entry.name) } + + private fun Sequence.shreddedFrom(jar: JarInputStream): Sequence = map { entry -> + val shredder = ByteArray(1024) // can't share or re-use this, as it's used to compute CRCs during shredding + entry.apply { + while (jar.read(shredder) != -1) { // Must read entry fully for codeSigners to be valid. + // Do nothing. + } + } + } + + private fun Sequence.toFileSignerSet(): Sequence>> = + map { entry -> entry.name to (entry.codeSigners?.toSet() ?: emptySet()) } + + private fun Set.toPartiesOrderedByName(): List = map { + Party(it.signerCertPath.certificates[0] as X509Certificate) + }.sortedBy { it.name.toString() } // Sorted for determinism. + + private val JarInputStream.entries get(): Sequence = generateSequence(nextJarEntry) { nextJarEntry } +} + +class InvalidJarSignersException(msg: String) : Exception(msg) \ No newline at end of file diff --git a/core/src/main/kotlin/net/corda/core/transactions/TransactionBuilder.kt b/core/src/main/kotlin/net/corda/core/transactions/TransactionBuilder.kt index 831a6e098e..9b54509064 100644 --- a/core/src/main/kotlin/net/corda/core/transactions/TransactionBuilder.kt +++ b/core/src/main/kotlin/net/corda/core/transactions/TransactionBuilder.kt @@ -5,9 +5,7 @@ import net.corda.core.CordaInternal import net.corda.core.DeleteForDJVM import net.corda.core.contracts.* import net.corda.core.cordapp.CordappProvider -import net.corda.core.crypto.SecureHash -import net.corda.core.crypto.SignableData -import net.corda.core.crypto.SignatureMetadata +import net.corda.core.crypto.* import net.corda.core.identity.Party import net.corda.core.internal.FlowStateMachine import net.corda.core.internal.ensureMinimumPlatformVersion @@ -111,20 +109,21 @@ open class TransactionBuilder @JvmOverloads constructor( services.ensureMinimumPlatformVersion(4, "Reference states") } - // Resolves the AutomaticHashConstraints to HashAttachmentConstraints or WhitelistedByZoneAttachmentConstraint based on a global parameter. - // The AutomaticHashConstraint allows for less boiler plate when constructing transactions since for the typical case the named contract - // will be available when building the transaction. In exceptional cases the TransactionStates must be created - // with an explicit [AttachmentConstraint] + /** + * Resolves the [AutomaticHashConstraint]s to [HashAttachmentConstraint]s, + * [WhitelistedByZoneAttachmentConstraint]s or [SignatureAttachmentConstraint]s based on a global parameter. + * + * The [AutomaticHashConstraint] allows for less boiler plate when constructing transactions since for the + * typical case the named contract will be available when building the transaction. In exceptional cases the + * [TransactionStates] must be created with an explicit [AttachmentConstraint] + */ val resolvedOutputs = outputs.map { state -> - when { - state.constraint !== AutomaticHashConstraint -> state - useWhitelistedByZoneAttachmentConstraint(state.contract, services.networkParameters) -> state.copy(constraint = WhitelistedByZoneAttachmentConstraint) - else -> { - services.cordappProvider.getContractAttachmentID(state.contract)?.let { - state.copy(constraint = HashAttachmentConstraint(it)) - } ?: throw MissingContractAttachments(listOf(state)) - } - } + state.withConstraint(when { + state.constraint !== AutomaticHashConstraint -> state.constraint + useWhitelistedByZoneAttachmentConstraint(state.contract, services.networkParameters) -> + WhitelistedByZoneAttachmentConstraint + else -> makeAttachmentConstraint(services, state) + }) } return SerializationFactory.defaultFactory.withCurrentContext(serializationContext) { @@ -143,10 +142,28 @@ open class TransactionBuilder @JvmOverloads constructor( } } - private fun useWhitelistedByZoneAttachmentConstraint(contractClassName: ContractClassName, networkParameters: NetworkParameters): Boolean { - return contractClassName in networkParameters.whitelistedContractImplementations.keys + private fun TransactionState.withConstraint(newConstraint: AttachmentConstraint) = + if (newConstraint == constraint) this else copy(constraint = newConstraint) + + private fun makeAttachmentConstraint(services: ServicesForResolution, state: TransactionState): AttachmentConstraint { + val attachmentId = services.cordappProvider.getContractAttachmentID(state.contract) + ?: throw MissingContractAttachments(listOf(state)) + + val attachmentSigners = services.attachments.openAttachment(attachmentId)?.signers + ?: throw MissingContractAttachments(listOf(state)) + + return when { + attachmentSigners.isEmpty() -> HashAttachmentConstraint(attachmentId) + else -> makeSignatureAttachmentConstraint(attachmentSigners) + } } + private fun makeSignatureAttachmentConstraint(attachmentSigners: List) = + SignatureAttachmentConstraint(CompositeKey.Builder().addKeys(attachmentSigners.map { it.owningKey }).build()) + + private fun useWhitelistedByZoneAttachmentConstraint(contractClassName: ContractClassName, networkParameters: NetworkParameters) = + contractClassName in networkParameters.whitelistedContractImplementations.keys + /** * The attachments added to the current transaction contain only the hashes of the current cordapps. * NOT the hashes of the cordapps that were used when the input states were created ( in case they changed in the meantime) @@ -276,6 +293,7 @@ open class TransactionBuilder @JvmOverloads constructor( * signing [PublicKey]s. */ fun addCommand(data: CommandData, vararg keys: PublicKey) = addCommand(Command(data, listOf(*keys))) + fun addCommand(data: CommandData, keys: List) = addCommand(Command(data, keys)) /** diff --git a/core/src/test/kotlin/net/corda/core/internal/AbstractAttachmentTest.kt b/core/src/test/kotlin/net/corda/core/internal/AbstractAttachmentTest.kt deleted file mode 100644 index bb759bc8d8..0000000000 --- a/core/src/test/kotlin/net/corda/core/internal/AbstractAttachmentTest.kt +++ /dev/null @@ -1,120 +0,0 @@ -package net.corda.core.internal - -import net.corda.testing.core.ALICE_NAME -import net.corda.testing.core.BOB_NAME -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatThrownBy -import org.junit.After -import org.junit.AfterClass -import org.junit.BeforeClass -import org.junit.Test -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths -import kotlin.test.assertEquals - -class AbstractAttachmentTest { - companion object { - private val dir = Files.createTempDirectory(AbstractAttachmentTest::class.simpleName) - private val bin = Paths.get(System.getProperty("java.home")).let { if (it.endsWith("jre")) it.parent else it } / "bin" - private val shredder = (dir / "_shredder").toFile() // No need to delete after each test. - fun execute(vararg command: String) { - assertEquals(0, ProcessBuilder() - .inheritIO() - .redirectOutput(shredder) - .directory(dir.toFile()) - .command((bin / command[0]).toString(), *command.sliceArray(1 until command.size)) - .start() - .waitFor()) - } - - @BeforeClass - @JvmStatic - fun beforeClass() { - execute("keytool", "-genkey", "-keystore", "_teststore", "-storepass", "storepass", "-keyalg", "RSA", "-alias", "alice", "-keypass", "alicepass", "-dname", ALICE_NAME.toString()) - execute("keytool", "-genkey", "-keystore", "_teststore", "-storepass", "storepass", "-keyalg", "RSA", "-alias", "bob", "-keypass", "bobpass", "-dname", BOB_NAME.toString()) - (dir / "_signable1").writeLines(listOf("signable1")) - (dir / "_signable2").writeLines(listOf("signable2")) - (dir / "_signable3").writeLines(listOf("signable3")) - } - - private fun load(name: String) = object : AbstractAttachment((dir / name)::readAll) { - override val id get() = throw UnsupportedOperationException() - } - - @AfterClass - @JvmStatic - fun afterClass() { - dir.deleteRecursively() - } - } - - @After - fun tearDown() { - dir.list { - it.filter { !it.fileName.toString().startsWith("_") }.forEach(Path::deleteRecursively) - } - assertThat(dir.list()).hasSize(5) - } - - @Test - fun `empty jar has no signers`() { - (dir / "META-INF").createDirectory() // At least one arg is required, and jar cvf conveniently ignores this. - execute("jar", "cvf", "attachment.jar", "META-INF") - assertEquals(emptyList(), load("attachment.jar").signers) - execute("jarsigner", "-keystore", "_teststore", "-storepass", "storepass", "-keypass", "alicepass", "attachment.jar", "alice") - assertEquals(emptyList(), load("attachment.jar").signers) // There needs to have been a file for ALICE to sign. - } - - @Test - fun `unsigned jar has no signers`() { - execute("jar", "cvf", "attachment.jar", "_signable1") - assertEquals(emptyList(), load("attachment.jar").signers) - execute("jar", "uvf", "attachment.jar", "_signable2") - assertEquals(emptyList(), load("attachment.jar").signers) - } - - @Test - fun `one signer`() { - execute("jar", "cvf", "attachment.jar", "_signable1", "_signable2") - execute("jarsigner", "-keystore", "_teststore", "-storepass", "storepass", "-keypass", "alicepass", "attachment.jar", "alice") - assertEquals(listOf(ALICE_NAME), load("attachment.jar").signers.map { it.name }) // We only reused ALICE's distinguished name, so the keys will be different. - (dir / "my-dir").createDirectory() - execute("jar", "uvf", "attachment.jar", "my-dir") - assertEquals(listOf(ALICE_NAME), load("attachment.jar").signers.map { it.name }) // Unsigned directory is irrelevant. - } - - @Test - fun `two signers`() { - execute("jar", "cvf", "attachment.jar", "_signable1", "_signable2") - execute("jarsigner", "-keystore", "_teststore", "-storepass", "storepass", "-keypass", "alicepass", "attachment.jar", "alice") - execute("jarsigner", "-keystore", "_teststore", "-storepass", "storepass", "-keypass", "bobpass", "attachment.jar", "bob") - assertEquals(listOf(ALICE_NAME, BOB_NAME), load("attachment.jar").signers.map { it.name }) - } - - @Test - fun `a party must sign all the files in the attachment to be a signer`() { - execute("jar", "cvf", "attachment.jar", "_signable1") - execute("jarsigner", "-keystore", "_teststore", "-storepass", "storepass", "-keypass", "alicepass", "attachment.jar", "alice") - assertEquals(listOf(ALICE_NAME), load("attachment.jar").signers.map { it.name }) - execute("jar", "uvf", "attachment.jar", "_signable2") - execute("jarsigner", "-keystore", "_teststore", "-storepass", "storepass", "-keypass", "bobpass", "attachment.jar", "bob") - assertEquals(listOf(BOB_NAME), load("attachment.jar").signers.map { it.name }) // ALICE hasn't signed the new file. - execute("jar", "uvf", "attachment.jar", "_signable3") - assertEquals(emptyList(), load("attachment.jar").signers) // Neither party has signed the new file. - } - - @Test - fun `bad signature is caught even if the party would not qualify as a signer`() { - (dir / "volatile").writeLines(listOf("volatile")) - execute("jar", "cvf", "attachment.jar", "volatile") - execute("jarsigner", "-keystore", "_teststore", "-storepass", "storepass", "-keypass", "alicepass", "attachment.jar", "alice") - assertEquals(listOf(ALICE_NAME), load("attachment.jar").signers.map { it.name }) - (dir / "volatile").writeLines(listOf("garbage")) - execute("jar", "uvf", "attachment.jar", "volatile", "_signable1") // ALICE's signature on volatile is now bad. - execute("jarsigner", "-keystore", "_teststore", "-storepass", "storepass", "-keypass", "bobpass", "attachment.jar", "bob") - val a = load("attachment.jar") - // The JDK doesn't care that BOB has correctly signed the whole thing, it won't let us process the entry with ALICE's bad signature: - assertThatThrownBy { a.signers }.isInstanceOf(SecurityException::class.java) - } -} diff --git a/core/src/test/kotlin/net/corda/core/internal/JarSignatureCollectorTest.kt b/core/src/test/kotlin/net/corda/core/internal/JarSignatureCollectorTest.kt new file mode 100644 index 0000000000..b8620082d8 --- /dev/null +++ b/core/src/test/kotlin/net/corda/core/internal/JarSignatureCollectorTest.kt @@ -0,0 +1,161 @@ +package net.corda.core.internal + +import net.corda.core.identity.CordaX500Name +import net.corda.core.identity.Party +import net.corda.testing.core.ALICE_NAME +import net.corda.testing.core.BOB_NAME +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.AfterClass +import org.junit.BeforeClass +import org.junit.Test +import java.io.FileInputStream +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.jar.JarInputStream +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class JarSignatureCollectorTest { + companion object { + private val dir = Files.createTempDirectory(JarSignatureCollectorTest::class.simpleName) + private val bin = Paths.get(System.getProperty("java.home")).let { if (it.endsWith("jre")) it.parent else it } / "bin" + private val shredder = (dir / "_shredder").toFile() // No need to delete after each test. + + fun execute(vararg command: String) { + assertEquals(0, ProcessBuilder() + .inheritIO() + .redirectOutput(shredder) + .directory(dir.toFile()) + .command((bin / command[0]).toString(), *command.sliceArray(1 until command.size)) + .start() + .waitFor()) + } + + private const val FILENAME = "attachment.jar" + private const val ALICE = "alice" + private const val ALICE_PASS = "alicepass" + private const val BOB = "bob" + private const val BOB_PASS = "bobpass" + + private fun generateKey(alias: String, password: String, name: CordaX500Name) = + execute("keytool", "-genkey", "-keystore", "_teststore", "-storepass", "storepass", "-keyalg", "RSA", "-alias", alias, "-keypass", password, "-dname", name.toString()) + + @BeforeClass + @JvmStatic + fun beforeClass() { + generateKey(ALICE, ALICE_PASS, ALICE_NAME) + generateKey(BOB, BOB_PASS, BOB_NAME) + + (dir / "_signable1").writeLines(listOf("signable1")) + (dir / "_signable2").writeLines(listOf("signable2")) + (dir / "_signable3").writeLines(listOf("signable3")) + } + + @AfterClass + @JvmStatic + fun afterClass() { + dir.deleteRecursively() + } + } + + private val List.names get() = map { it.name } + + @After + fun tearDown() { + dir.list { + it.filter { !it.fileName.toString().startsWith("_") }.forEach(Path::deleteRecursively) + } + assertThat(dir.list()).hasSize(5) + } + + @Test + fun `empty jar has no signers`() { + (dir / "META-INF").createDirectory() // At least one arg is required, and jar cvf conveniently ignores this. + createJar("META-INF") + assertEquals(emptyList(), getJarSigners()) + + signAsAlice() + assertEquals(emptyList(), getJarSigners()) // There needs to have been a file for ALICE to sign. + } + + @Test + fun `unsigned jar has no signers`() { + createJar("_signable1") + assertEquals(emptyList(), getJarSigners()) + + updateJar("_signable2") + assertEquals(emptyList(), getJarSigners()) + } + + @Test + fun `one signer`() { + createJar("_signable1", "_signable2") + signAsAlice() + assertEquals(listOf(ALICE_NAME), getJarSigners().names) // We only reused ALICE's distinguished name, so the keys will be different. + + (dir / "my-dir").createDirectory() + updateJar("my-dir") + assertEquals(listOf(ALICE_NAME), getJarSigners().names) // Unsigned directory is irrelevant. + } + + @Test + fun `two signers`() { + createJar("_signable1", "_signable2") + signAsAlice() + signAsBob() + + assertEquals(listOf(ALICE_NAME, BOB_NAME), getJarSigners().names) + } + + @Test + fun `all files must be signed by the same set of signers`() { + createJar("_signable1") + signAsAlice() + assertEquals(listOf(ALICE_NAME), getJarSigners().names) + + updateJar("_signable2") + signAsBob() + assertFailsWith( + """ + Mismatch between signers [O=Alice Corp, L=Madrid, C=ES, O=Bob Plc, L=Rome, C=IT] for file _signable1 + and signers [O=Bob Plc, L=Rome, C=IT] for file _signable2. + See https://docs.corda.net/design/data-model-upgrades/signature-constraints.html for details of the + constraints applied to attachment signatures. + """.trimIndent().replace('\n', ' ') + ) { getJarSigners() } + } + + @Test + fun `bad signature is caught even if the party would not qualify as a signer`() { + (dir / "volatile").writeLines(listOf("volatile")) + createJar("volatile") + signAsAlice() + assertEquals(listOf(ALICE_NAME), getJarSigners().names) + + (dir / "volatile").writeLines(listOf("garbage")) + updateJar("volatile", "_signable1") // ALICE's signature on volatile is now bad. + signAsBob() + // The JDK doesn't care that BOB has correctly signed the whole thing, it won't let us process the entry with ALICE's bad signature: + assertFailsWith { getJarSigners() } + } + + //region Helper functions + private fun createJar(vararg contents: String) = + execute(*(arrayOf("jar", "cvf", FILENAME) + contents)) + + private fun updateJar(vararg contents: String) = + execute(*(arrayOf("jar", "uvf", FILENAME) + contents)) + + private fun signJar(alias: String, password: String) = + execute("jarsigner", "-keystore", "_teststore", "-storepass", "storepass", "-keypass", password, FILENAME, alias) + + private fun signAsAlice() = signJar(ALICE, ALICE_PASS) + private fun signAsBob() = signJar(BOB, BOB_PASS) + + private fun getJarSigners() = + JarInputStream(FileInputStream((dir / FILENAME).toFile())).use(JarSignatureCollector::collectSigningParties) + //endregion + +} diff --git a/core/src/test/kotlin/net/corda/core/transactions/TransactionBuilderTest.kt b/core/src/test/kotlin/net/corda/core/transactions/TransactionBuilderTest.kt index c24a485776..f779c85678 100644 --- a/core/src/test/kotlin/net/corda/core/transactions/TransactionBuilderTest.kt +++ b/core/src/test/kotlin/net/corda/core/transactions/TransactionBuilderTest.kt @@ -4,19 +4,22 @@ import com.nhaarman.mockito_kotlin.doReturn import com.nhaarman.mockito_kotlin.whenever import net.corda.core.contracts.* import net.corda.core.cordapp.CordappProvider +import net.corda.core.crypto.CompositeKey import net.corda.core.crypto.SecureHash +import net.corda.core.identity.Party +import net.corda.core.internal.AbstractAttachment import net.corda.core.node.ServicesForResolution import net.corda.core.node.ZoneVersionTooLowException +import net.corda.core.node.services.AttachmentStorage import net.corda.testing.common.internal.testNetworkParameters import net.corda.testing.contracts.DummyContract import net.corda.testing.contracts.DummyState -import net.corda.testing.core.DUMMY_NOTARY_NAME -import net.corda.testing.core.DummyCommandData -import net.corda.testing.core.SerializationEnvironmentRule -import net.corda.testing.core.TestIdentity +import net.corda.testing.core.* import net.corda.testing.internal.rigorousMock import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -29,6 +32,7 @@ class TransactionBuilderTest { private val notary = TestIdentity(DUMMY_NOTARY_NAME).party private val services = rigorousMock() private val contractAttachmentId = SecureHash.randomSHA256() + private val attachments = rigorousMock() @Before fun setup() { @@ -36,6 +40,7 @@ class TransactionBuilderTest { doReturn(cordappProvider).whenever(services).cordappProvider doReturn(contractAttachmentId).whenever(cordappProvider).getContractAttachmentID(DummyContract.PROGRAM_ID) doReturn(testNetworkParameters()).whenever(services).networkParameters + doReturn(attachments).whenever(services).attachments } @Test @@ -56,6 +61,8 @@ class TransactionBuilderTest { @Test fun `automatic hash constraint`() { + doReturn(unsignedAttachment).whenever(attachments).openAttachment(contractAttachmentId) + val outputState = TransactionState(data = DummyState(), contract = DummyContract.PROGRAM_ID, notary = notary) val builder = TransactionBuilder() .addOutputState(outputState) @@ -66,6 +73,8 @@ class TransactionBuilderTest { @Test fun `reference states`() { + doReturn(unsignedAttachment).whenever(attachments).openAttachment(contractAttachmentId) + val referenceState = TransactionState(DummyState(), DummyContract.PROGRAM_ID, notary) val referenceStateRef = StateRef(SecureHash.randomSHA256(), 1) val builder = TransactionBuilder(notary) @@ -82,4 +91,40 @@ class TransactionBuilderTest { val wtx = builder.toWireTransaction(services) assertThat(wtx.references).containsOnly(referenceStateRef) } + + @Test + fun `automatic signature constraint`() { + val aliceParty = TestIdentity(ALICE_NAME).party + val bobParty = TestIdentity(BOB_NAME).party + val compositeKey = CompositeKey.Builder().addKeys(aliceParty.owningKey, bobParty.owningKey).build() + val expectedConstraint = SignatureAttachmentConstraint(compositeKey) + val signedAttachment = signedAttachment(aliceParty, bobParty) + + assertTrue(expectedConstraint.isSatisfiedBy(signedAttachment)) + assertFalse(expectedConstraint.isSatisfiedBy(unsignedAttachment)) + + doReturn(signedAttachment).whenever(attachments).openAttachment(contractAttachmentId) + + val outputState = TransactionState(data = DummyState(), contract = DummyContract.PROGRAM_ID, notary = notary) + val builder = TransactionBuilder() + .addOutputState(outputState) + .addCommand(DummyCommandData, notary.owningKey) + val wtx = builder.toWireTransaction(services) + + assertThat(wtx.outputs).containsOnly(outputState.copy(constraint = expectedConstraint)) + + } + + + private val unsignedAttachment = object : AbstractAttachment({ byteArrayOf() }) { + override val id: SecureHash get() = throw UnsupportedOperationException() + + override val signers: List get() = emptyList() + } + + private fun signedAttachment(vararg parties: Party) = object : AbstractAttachment({ byteArrayOf() }) { + override val id: SecureHash get() = throw UnsupportedOperationException() + + override val signers: List get() = parties.toList() + } } diff --git a/node-api/src/test/kotlin/net/corda/nodeapi/internal/AttachmentsClassLoaderStaticContractTests.kt b/node-api/src/test/kotlin/net/corda/nodeapi/internal/AttachmentsClassLoaderStaticContractTests.kt index 5da0217f23..0909707ac4 100644 --- a/node-api/src/test/kotlin/net/corda/nodeapi/internal/AttachmentsClassLoaderStaticContractTests.kt +++ b/node-api/src/test/kotlin/net/corda/nodeapi/internal/AttachmentsClassLoaderStaticContractTests.kt @@ -1,12 +1,16 @@ package net.corda.nodeapi.internal +import com.nhaarman.mockito_kotlin.any import com.nhaarman.mockito_kotlin.doReturn import com.nhaarman.mockito_kotlin.whenever import net.corda.core.contracts.* +import net.corda.core.crypto.SecureHash import net.corda.core.identity.AbstractParty import net.corda.core.identity.CordaX500Name import net.corda.core.identity.Party +import net.corda.core.internal.AbstractAttachment import net.corda.core.node.ServicesForResolution +import net.corda.core.node.services.AttachmentStorage import net.corda.core.serialization.deserialize import net.corda.core.serialization.serialize import net.corda.core.transactions.LedgerTransaction @@ -66,11 +70,20 @@ class AttachmentsClassLoaderStaticContractTests { } } + private val unsignedAttachment = object : AbstractAttachment({ byteArrayOf() }) { + override val id: SecureHash get() = throw UnsupportedOperationException() + } + + private val attachments = rigorousMock().also { + doReturn(unsignedAttachment).whenever(it).openAttachment(any()) + } + private val serviceHub = rigorousMock().also { val cordappProviderImpl = CordappProviderImpl(cordappLoaderForPackages(listOf("net.corda.nodeapi.internal")), MockCordappConfigProvider(), MockAttachmentStorage()) cordappProviderImpl.start(testNetworkParameters().whitelistedContractImplementations) doReturn(cordappProviderImpl).whenever(it).cordappProvider doReturn(testNetworkParameters()).whenever(it).networkParameters + doReturn(attachments).whenever(it).attachments } @Test diff --git a/testing/node-driver/src/main/kotlin/net/corda/testing/node/internal/InternalMockNetwork.kt b/testing/node-driver/src/main/kotlin/net/corda/testing/node/internal/InternalMockNetwork.kt index cffe37a027..96050e777f 100644 --- a/testing/node-driver/src/main/kotlin/net/corda/testing/node/internal/InternalMockNetwork.kt +++ b/testing/node-driver/src/main/kotlin/net/corda/testing/node/internal/InternalMockNetwork.kt @@ -553,7 +553,7 @@ open class InternalMockNetwork(defaultParameters: MockNetworkParameters = MockNe /** Block until all scheduled activity, active flows and network activity has ceased. */ fun waitQuiescent() { - busyLatch.await() + busyLatch.await(30000) // don't hang forever if for some reason things don't complete } override fun close() = stopNodes()