mirror of
https://github.com/corda/corda.git
synced 2025-02-20 09:26:41 +00:00
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
This commit is contained in:
parent
bc330bd989
commit
f81428eb53
@ -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<AttachmentStorage>().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
|
||||
|
@ -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 })
|
||||
}
|
@ -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 <https://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.html#Signed_JAR_File> */
|
||||
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<CodeSigner>? = 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<CodeSigner>()).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)
|
||||
}
|
||||
|
||||
|
@ -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 <https://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.html#Signed_JAR_File> */
|
||||
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<Party> {
|
||||
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<Pair<String, Set<CodeSigner>>> get() =
|
||||
entries.thatAreSignable.shreddedFrom(this).toFileSignerSet().toList()
|
||||
|
||||
private val Sequence<JarEntry>.thatAreSignable: Sequence<JarEntry> get() =
|
||||
filterNot { entry -> entry.isDirectory || unsignableEntryName.matches(entry.name) }
|
||||
|
||||
private fun Sequence<JarEntry>.shreddedFrom(jar: JarInputStream): Sequence<JarEntry> = 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<JarEntry>.toFileSignerSet(): Sequence<Pair<String, Set<CodeSigner>>> =
|
||||
map { entry -> entry.name to (entry.codeSigners?.toSet() ?: emptySet()) }
|
||||
|
||||
private fun Set<CodeSigner>.toPartiesOrderedByName(): List<Party> = map {
|
||||
Party(it.signerCertPath.certificates[0] as X509Certificate)
|
||||
}.sortedBy { it.name.toString() } // Sorted for determinism.
|
||||
|
||||
private val JarInputStream.entries get(): Sequence<JarEntry> = generateSequence(nextJarEntry) { nextJarEntry }
|
||||
}
|
||||
|
||||
class InvalidJarSignersException(msg: String) : Exception(msg)
|
@ -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<ContractState>.withConstraint(newConstraint: AttachmentConstraint) =
|
||||
if (newConstraint == constraint) this else copy(constraint = newConstraint)
|
||||
|
||||
private fun makeAttachmentConstraint(services: ServicesForResolution, state: TransactionState<ContractState>): 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<Party>) =
|
||||
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<PublicKey>) = addCommand(Command(data, keys))
|
||||
|
||||
/**
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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<Party>.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<InvalidJarSignersException>(
|
||||
"""
|
||||
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<SecurityException> { 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
|
||||
|
||||
}
|
@ -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<ServicesForResolution>()
|
||||
private val contractAttachmentId = SecureHash.randomSHA256()
|
||||
private val attachments = rigorousMock<AttachmentStorage>()
|
||||
|
||||
@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<Party> get() = emptyList()
|
||||
}
|
||||
|
||||
private fun signedAttachment(vararg parties: Party) = object : AbstractAttachment({ byteArrayOf() }) {
|
||||
override val id: SecureHash get() = throw UnsupportedOperationException()
|
||||
|
||||
override val signers: List<Party> get() = parties.toList()
|
||||
}
|
||||
}
|
||||
|
@ -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<AttachmentStorage>().also {
|
||||
doReturn(unsignedAttachment).whenever(it).openAttachment(any())
|
||||
}
|
||||
|
||||
private val serviceHub = rigorousMock<ServicesForResolution>().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
|
||||
|
@ -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()
|
||||
|
Loading…
x
Reference in New Issue
Block a user