From f0c73cc95fe7f5b095c1a6f79e77db48c57cc7bd Mon Sep 17 00:00:00 2001 From: Adel El-Beik Date: Mon, 4 Nov 2024 19:44:25 +0000 Subject: [PATCH] ENT-12373: Can now cope with diff input states from diff rotated CorDapps. --- .../net/corda/core/contracts/RotatedKeys.kt | 2 + .../core/transactions/TransactionBuilder.kt | 8 +- .../corda/node/ContractWithRotatedKeyTest.kt | 100 ++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/core/src/main/kotlin/net/corda/core/contracts/RotatedKeys.kt b/core/src/main/kotlin/net/corda/core/contracts/RotatedKeys.kt index 85b26c6ee7..390babe2a2 100644 --- a/core/src/main/kotlin/net/corda/core/contracts/RotatedKeys.kt +++ b/core/src/main/kotlin/net/corda/core/contracts/RotatedKeys.kt @@ -78,6 +78,8 @@ data class RotatedKeys(val rotatedSigningKeys: List> = emptyLis } } + fun rotateToHash(key: PublicKey) = rotate(key.hash.sha256()) + private fun rotate(key: SecureHash): SecureHash { return rotateMap[key] ?: key } 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 221a31cc84..2874590104 100644 --- a/core/src/main/kotlin/net/corda/core/transactions/TransactionBuilder.kt +++ b/core/src/main/kotlin/net/corda/core/transactions/TransactionBuilder.kt @@ -659,7 +659,9 @@ open class TransactionBuilder( constraints.any { it is HashAttachmentConstraint } -> constraints.find { it is HashAttachmentConstraint }!! // TODO, we don't currently support mixing signature constraints with different signers. This will change once we introduce third party signers. - constraints.count { it is SignatureAttachmentConstraint } > 1 -> + (constraints.count { it is SignatureAttachmentConstraint } > 1) && + (constraints.filterIsInstance().map { serviceHub?.toVerifyingServiceHub()?.rotatedKeys?.rotateToHash(it.key) ?: it.key}.toSet().size > 1) + -> throw IllegalArgumentException("Cannot mix SignatureAttachmentConstraints signed by different parties in the same transaction.") // This ensures a smooth migration from a Whitelist Constraint to a Signature Constraint @@ -675,6 +677,10 @@ open class TransactionBuilder( // When all input states have the same constraint. constraints.size == 1 -> constraints.single() + // if we are here then the multiple SignatureAttachmentConstraint keys must be rotations of each other due to above check + (constraints.count { it is SignatureAttachmentConstraint } > 1) + -> constraints.filterIsInstance().first { it == makeSignatureAttachmentConstraint(attachmentToUse.signerKeys) } + else -> throw IllegalArgumentException("Unexpected constraints $constraints.") } diff --git a/node/src/integration-test/kotlin/net/corda/node/ContractWithRotatedKeyTest.kt b/node/src/integration-test/kotlin/net/corda/node/ContractWithRotatedKeyTest.kt index 299e71c52f..0effd9a37d 100644 --- a/node/src/integration-test/kotlin/net/corda/node/ContractWithRotatedKeyTest.kt +++ b/node/src/integration-test/kotlin/net/corda/node/ContractWithRotatedKeyTest.kt @@ -2,14 +2,18 @@ package net.corda.node import net.corda.core.crypto.sha256 import net.corda.core.internal.hash +import net.corda.core.transactions.TransactionBuilder import net.corda.core.utilities.OpaqueBytes import net.corda.core.utilities.getOrThrow import net.corda.finance.DOLLARS import net.corda.finance.GBP import net.corda.finance.POUNDS import net.corda.finance.USD +import net.corda.finance.contracts.asset.Cash import net.corda.finance.flows.CashIssueAndPaymentFlow +import net.corda.finance.flows.CashIssueFlow import net.corda.finance.flows.CashPaymentFlow +import net.corda.finance.`issued by` import net.corda.finance.workflows.getCashBalance import net.corda.node.services.config.NodeConfiguration import net.corda.node.services.config.RotatedCorDappSignerKeyConfiguration @@ -34,6 +38,7 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.whenever import kotlin.io.path.div import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class ContractWithRotatedKeyTest { private val ref = OpaqueBytes.of(0x01) @@ -132,4 +137,99 @@ class ContractWithRotatedKeyTest { keyStoreDir1.close() keyStoreDir2.close() } + + @Test(timeout = 300_000) + fun `transaction can be created with multiple contract input states from rotated CorDapps`() { + val keyStoreDir1 = SelfCleaningDir() + val keyStoreDir2 = SelfCleaningDir() + + val packageOwnerKey1 = keyStoreDir1.path.generateKey(alias="1-testcordapp-rsa") + val packageOwnerKey2 = keyStoreDir2.path.generateKey(alias="1-testcordapp-rsa") + + val unsignedFinanceCorDapp1 = cordappWithPackages("net.corda.finance", "migration", "META-INF.services") + val unsignedFinanceCorDapp2 = cordappWithPackages("net.corda.finance", "migration", "META-INF.services").copy(versionId = 2) + + val signedFinanceCorDapp1 = unsignedFinanceCorDapp1.signed( keyStoreDir1.path ) + val signedFinanceCorDapp2 = unsignedFinanceCorDapp2.signed( keyStoreDir2.path ) + + val configOverrides = { conf: NodeConfiguration -> + val rotatedKeys = listOf(RotatedCorDappSignerKeyConfiguration(listOf(packageOwnerKey1.hash.sha256().toString(), packageOwnerKey2.hash.sha256().toString()))) + doReturn(rotatedKeys).whenever(conf).rotatedCordappSignerKeys + } + + val alice = mockNet.createNode(InternalMockNodeParameters(legalName = ALICE_NAME, additionalCordapps = listOf(signedFinanceCorDapp1), configOverrides = configOverrides)) + + val flow1 = alice.services.startFlow(CashIssueAndPaymentFlow(300.DOLLARS, ref, alice.party, false, mockNet.defaultNotaryIdentity)) + val flow2 = alice.services.startFlow(CashIssueAndPaymentFlow(1000.POUNDS, ref, alice.party, false, mockNet.defaultNotaryIdentity)) + mockNet.runNetwork() + flow1.resultFuture.getOrThrow() + flow2.resultFuture.getOrThrow() + + val alice2 = restartNodeAndDeleteOldCorDapps(mockNet, alice, parameters = InternalMockNodeParameters(additionalCordapps = listOf(signedFinanceCorDapp2), configOverrides = configOverrides)) + + val flow3 = alice2.services.startFlow(CashIssueFlow(700.DOLLARS, ref, mockNet.defaultNotaryIdentity)) + mockNet.runNetwork() + flow3.resultFuture.getOrThrow() + + val vaultStates = alice2.services.vaultService.queryBy(Cash.State::class.java).states + assertEquals(3, vaultStates.size) + + val outputState = Cash.State(1000.POUNDS `issued by` alice2.party.ref(1), alice2.party) + val tx = TransactionBuilder(notary = mockNet.defaultNotaryIdentity, serviceHub = alice2.services).apply { + addInputState(vaultStates[0]) + addInputState(vaultStates[1]) + addInputState(vaultStates[2]) + addOutputState(outputState) + addCommand(Cash.Commands.Move(), listOf(alice2.party.owningKey)) + } + tx.toWireTransaction(alice2.services) + + keyStoreDir1.close() + keyStoreDir2.close() + } + + @Test(timeout = 300_000) + fun `transaction creation fails with multiple contract input states from different CorDapps`() { + val keyStoreDir1 = SelfCleaningDir() + val keyStoreDir2 = SelfCleaningDir() + + val unsignedFinanceCorDapp1 = cordappWithPackages("net.corda.finance", "migration", "META-INF.services") + val unsignedFinanceCorDapp2 = cordappWithPackages("net.corda.finance", "migration", "META-INF.services").copy(versionId = 2) + + val signedFinanceCorDapp1 = unsignedFinanceCorDapp1.signed( keyStoreDir1.path ) + val signedFinanceCorDapp2 = unsignedFinanceCorDapp2.signed( keyStoreDir2.path ) + + val alice = mockNet.createNode(InternalMockNodeParameters(legalName = ALICE_NAME, additionalCordapps = listOf(signedFinanceCorDapp1))) + + val flow1 = alice.services.startFlow(CashIssueAndPaymentFlow(300.DOLLARS, ref, alice.party, false, mockNet.defaultNotaryIdentity)) + val flow2 = alice.services.startFlow(CashIssueAndPaymentFlow(1000.POUNDS, ref, alice.party, false, mockNet.defaultNotaryIdentity)) + mockNet.runNetwork() + flow1.resultFuture.getOrThrow() + flow2.resultFuture.getOrThrow() + + val alice2 = restartNodeAndDeleteOldCorDapps(mockNet, alice, parameters = InternalMockNodeParameters(additionalCordapps = listOf(signedFinanceCorDapp2))) + + val flow3 = alice2.services.startFlow(CashIssueFlow(700.DOLLARS, ref, mockNet.defaultNotaryIdentity)) + mockNet.runNetwork() + flow3.resultFuture.getOrThrow() + + val vaultStates = alice2.services.vaultService.queryBy(Cash.State::class.java).states + assertEquals(3, vaultStates.size) + + val outputState = Cash.State(1000.POUNDS `issued by` alice2.party.ref(1), alice2.party) + val tx = TransactionBuilder(notary = mockNet.defaultNotaryIdentity, serviceHub = alice2.services).apply { + addInputState(vaultStates[0]) + addInputState(vaultStates[1]) + addInputState(vaultStates[2]) + addOutputState(outputState) + addCommand(Cash.Commands.Move(), listOf(alice2.party.owningKey)) + } + assertFailsWith(IllegalArgumentException::class) { + tx.toWireTransaction(alice2.services) + }.also { + assertEquals("Cannot mix SignatureAttachmentConstraints signed by different parties in the same transaction.", it.message) + } + keyStoreDir1.close() + keyStoreDir2.close() + } } \ No newline at end of file