Refactored NotaryChangeProtocol and tests: moved the proposal verification step into the protocol.

Added another proposal verification step in the NotaryChangeProtocol.
Added the cause exception message to the 'notary change refused' error.
This commit is contained in:
Andrius Dagys 2016-06-14 19:06:44 +01:00
parent 0a5b7ace35
commit 30ca340b6e
12 changed files with 182 additions and 107 deletions

View File

@ -164,7 +164,7 @@ class Cash : FungibleAsset<Currency>() {
throw InsufficientBalanceException(amount - gatheredAmount)
val change = if (takeChangeFrom != null && gatheredAmount > amount) {
Amount<Issued<Currency>>(gatheredAmount.quantity - amount.quantity, takeChangeFrom.state.state.issuanceDef)
Amount<Issued<Currency>>(gatheredAmount.quantity - amount.quantity, takeChangeFrom.state.data.issuanceDef)
} else {
null
}

View File

@ -27,7 +27,17 @@ interface ContractState {
/** Contract by which the state belongs */
val contract: Contract
/** List of public keys for each party that can consume this state in a valid transaction. */
/**
* A _participant_ is any party that is able to consume this state in a valid transaction.
*
* The list of participants is required for certain types of transactions. For example, when changing the notary
* for this state ([TransactionType.NotaryChange]), every participants has to be involved and approve the transaction
* so that they receive the updated state, and don't end up in a situation where they can no longer use a state
* they possess, since someone consumed that state during the notary change process.
*
* The participants list should normally be derived from the contents of the state. E.g. for [Cash] the participants
* list should just contain the owner.
*/
val participants: List<PublicKey>
}
@ -172,6 +182,7 @@ data class Command(val value: CommandData, val signers: List<PublicKey>) {
init {
require(signers.isNotEmpty())
}
constructor(data: CommandData, key: PublicKey) : this(data, listOf(key))
private fun commandDataToString() = value.toString().let { if (it.contains("@")) it.replace('$', '.').split("@")[0] else it }

View File

@ -10,9 +10,11 @@ import java.util.*
/**
* A TransactionBuilder is a transaction class that's mutable (unlike the others which are all immutable). It is
* intended to be passed around contracts that may edit it by adding new states/commands or modifying the existing set.
* Then once the states and commands are right, this class can be used as a holding bucket to gather signatures from
* multiple parties.
* intended to be passed around contracts that may edit it by adding new states/commands. Then once the states
* and commands are right, this class can be used as a holding bucket to gather signatures from multiple parties.
*
* The builder can be customised for specific transaction types, e.g. where additional processing is needed
* before adding a state/command.
*/
abstract class TransactionBuilder(protected val type: TransactionType = TransactionType.General(),
protected val notary: Party? = null) {

View File

@ -4,7 +4,7 @@ import com.r3corda.core.crypto.Party
import com.r3corda.core.noneOrSingle
import java.security.PublicKey
/** Defines transaction validation rules for a specific transaction type */
/** Defines transaction build & validation logic for a specific transaction type */
sealed class TransactionType {
override fun equals(other: Any?) = other?.javaClass == javaClass
override fun hashCode() = javaClass.name.hashCode()
@ -48,6 +48,7 @@ sealed class TransactionType {
/** A general transaction type where transaction validity is determined by custom contract code */
class General : TransactionType() {
/** Just uses the default [TransactionBuilder] with no special logic */
class Builder(notary: Party? = null) : TransactionBuilder(General(), notary) {}
/**

View File

@ -30,10 +30,9 @@ object NotaryChangeProtocol {
data class Proposal(val stateRef: StateRef,
val newNotary: Party,
val sessionIdForSend: Long,
val sessionIdForReceive: Long)
val stx: SignedTransaction)
class Handshake(val payload: Proposal,
class Handshake(val sessionIdForSend: Long,
replyTo: SingleMessageRecipient,
replySessionId: Long) : AbstractRequestMessage(replyTo, replySessionId)
@ -102,18 +101,21 @@ object NotaryChangeProtocol {
@Suspendable
private fun getParticipantSignature(node: NodeInfo, stx: SignedTransaction, sessionIdForSend: Long): DigitalSignature.WithKey {
val sessionIdForReceive = random63BitValue()
val proposal = Proposal(originalState.ref, newNotary, sessionIdForSend, sessionIdForReceive)
val proposal = Proposal(originalState.ref, newNotary, stx)
val handshake = Handshake(proposal, serviceHub.networkService.myAddress, sessionIdForReceive)
val protocolInitiated = sendAndReceive<Boolean>(TOPIC_INITIATE, node.address, 0, sessionIdForReceive, handshake).validate { it }
if (!protocolInitiated) throw Refused(node.identity, originalState)
val handshake = Handshake(sessionIdForSend, serviceHub.networkService.myAddress, sessionIdForReceive)
sendAndReceive<Unit>(TOPIC_INITIATE, node.address, 0, sessionIdForReceive, handshake)
val response = sendAndReceive<DigitalSignature.WithKey>(TOPIC_CHANGE, node.address, sessionIdForSend, sessionIdForReceive, stx)
val response = sendAndReceive<Result>(TOPIC_CHANGE, node.address, sessionIdForSend, sessionIdForReceive, proposal)
val participantSignature = response.validate {
check(it.by == node.identity.owningKey) { "Not signed by the required participant" }
it.verifyWithECDSA(stx.txBits)
it
if (it.sig == null) throw NotaryChangeException(it.error!!)
else {
check(it.sig.by == node.identity.owningKey) { "Not signed by the required participant" }
it.sig.verifyWithECDSA(stx.txBits)
it.sig
}
}
return participantSignature
}
@ -132,38 +134,88 @@ object NotaryChangeProtocol {
companion object {
object VERIFYING : ProgressTracker.Step("Verifying Notary change proposal")
object SIGNING : ProgressTracker.Step("Signing Notary change transaction")
object APPROVING : ProgressTracker.Step("Notary change approved")
fun tracker() = ProgressTracker(VERIFYING, SIGNING)
object REJECTING : ProgressTracker.Step("Notary change rejected")
fun tracker() = ProgressTracker(VERIFYING, APPROVING, REJECTING)
}
@Suspendable
override fun call() {
progressTracker.currentStep = VERIFYING
val proposal = receive<Proposal>(TOPIC_CHANGE, sessionIdForReceive).validate { it }
val proposedTx = receive<SignedTransaction>(TOPIC_CHANGE, sessionIdForReceive).validate { validateTx(it) }
try {
verifyProposal(proposal)
verifyTx(proposal.stx)
} catch(e: Exception) {
// TODO: catch only specific exceptions. However, there are numerous validation exceptions
// that might occur (tx validation/resolution, invalid proposal). Need to rethink how
// we manage exceptions and maybe introduce some platform exception hierarchy
val myIdentity = serviceHub.storageService.myLegalIdentity
val state = proposal.stateRef
val reason = NotaryChangeRefused(myIdentity, state, e.message)
progressTracker.currentStep = SIGNING
reject(reason)
return
}
val mySignature = sign(proposedTx)
val swapSignatures = sendAndReceive<List<DigitalSignature.WithKey>>(TOPIC_CHANGE, otherSide, sessionIdForSend, sessionIdForReceive, mySignature)
approve(proposal.stx)
}
@Suspendable
private fun approve(stx: SignedTransaction) {
progressTracker.currentStep = APPROVING
val mySignature = sign(stx)
val response = Result.noError(mySignature)
val swapSignatures = sendAndReceive<List<DigitalSignature.WithKey>>(TOPIC_CHANGE, otherSide, sessionIdForSend, sessionIdForReceive, response)
val allSignatures = swapSignatures.validate { signatures ->
signatures.forEach { it.verifyWithECDSA(proposedTx.txBits) }
signatures.forEach { it.verifyWithECDSA(stx.txBits) }
signatures
}
val finalTx = proposedTx + allSignatures
val finalTx = stx + allSignatures
finalTx.verify()
serviceHub.recordTransactions(listOf(finalTx))
}
@Suspendable
private fun validateTx(stx: SignedTransaction): SignedTransaction {
private fun reject(e: NotaryChangeRefused) {
progressTracker.currentStep = REJECTING
val response = Result.withError(e)
send(TOPIC_CHANGE, otherSide, sessionIdForSend, response)
}
/**
* Check the notary change proposal.
*
* For example, if the proposed new notary has the same behaviour (e.g. both are non-validating)
* and is also in a geographically convenient location we can just automatically approve the change.
* TODO: In more difficult cases this should call for human attention to manually verify and approve the proposal
*/
@Suspendable
private fun verifyProposal(proposal: NotaryChangeProtocol.Proposal) {
val newNotary = proposal.newNotary
val isNotary = serviceHub.networkMapCache.notaryNodes.any { it.identity == newNotary }
require(isNotary) { "The proposed node $newNotary does not run a Notary service " }
val state = proposal.stateRef
val proposedTx = proposal.stx.tx
require(proposedTx.inputs.contains(state)) { "The proposed state $state is not in the proposed transaction inputs" }
// An example requirement
val blacklist = listOf("Evil Notary")
require(!blacklist.contains(newNotary.name)) { "The proposed new notary $newNotary is not trusted by the party" }
}
@Suspendable
private fun verifyTx(stx: SignedTransaction) {
checkMySignatureRequired(stx.tx)
checkDependenciesValid(stx)
checkValid(stx)
return stx
}
private fun checkMySignatureRequired(tx: WireTransaction) {
@ -189,8 +241,20 @@ object NotaryChangeProtocol {
}
}
/** Thrown when a participant refuses to change the notary of the state */
class Refused(val identity: Party, val originalState: StateAndRef<*>) : Exception() {
override fun toString() = "A participant $identity refused to change the notary of state $originalState"
// TODO: similar classes occur in other places (NotaryProtocol), need to consolidate
data class Result private constructor(val sig: DigitalSignature.WithKey?, val error: NotaryChangeRefused?) {
companion object {
fun withError(error: NotaryChangeRefused) = Result(null, error)
fun noError(sig: DigitalSignature.WithKey) = Result(sig, null)
}
}
}
/** Thrown when a participant refuses to change the notary of the state */
class NotaryChangeRefused(val identity: Party, val state: StateRef, val cause: String?) {
override fun toString() = "A participant $identity refused to change the notary of state $state"
}
class NotaryChangeException(val error: NotaryChangeRefused) : Exception() {
override fun toString() = "${super.toString()}: Notary change failed - ${error.toString()}"
}

View File

@ -26,13 +26,13 @@ class TransactionGraphSearchTests {
* @param signer signer for the two transactions and their commands.
*/
fun buildTransactions(command: CommandData, signer: KeyPair): GraphTransactionStorage {
val originTx = TransactionBuilder().apply {
addOutputState(DummyContract.State(random31BitValue(), DUMMY_NOTARY))
val originTx = TransactionType.General.Builder().apply {
addOutputState(DummyContract.State(random31BitValue()), DUMMY_NOTARY)
addCommand(command, signer.public)
signWith(signer)
}.toSignedTransaction(false)
val inputTx = TransactionBuilder().apply {
addInputState(originTx.tx.outRef<DummyContract.State>(0).ref)
val inputTx = TransactionType.General.Builder().apply {
addInputState(originTx.tx.outRef<DummyContract.State>(0))
signWith(signer)
}.toSignedTransaction(false)
return GraphTransactionStorage(originTx, inputTx)

View File

@ -5,6 +5,7 @@ import com.r3corda.core.crypto.SecureHash
import com.r3corda.core.node.services.Wallet
import com.r3corda.core.testing.DUMMY_NOTARY
import org.junit.Test
import java.security.PublicKey
import kotlin.test.assertEquals
@ -12,14 +13,15 @@ class WalletUpdateTests {
object DummyContract : Contract {
override fun verify(tx: TransactionForVerification) {
override fun verify(tx: TransactionForContract) {
}
override val legalContractReference: SecureHash = SecureHash.sha256("")
}
private class DummyState : ContractState {
override val notary = DUMMY_NOTARY
override val participants: List<PublicKey>
get() = emptyList()
override val contract = WalletUpdateTests.DummyContract
}
@ -29,11 +31,11 @@ class WalletUpdateTests {
private val stateRef3 = StateRef(SecureHash.randomSHA256(), 3)
private val stateRef4 = StateRef(SecureHash.randomSHA256(), 4)
private val stateAndRef0 = StateAndRef<DummyState>(DummyState(), stateRef0)
private val stateAndRef1 = StateAndRef<DummyState>(DummyState(), stateRef1)
private val stateAndRef2 = StateAndRef<DummyState>(DummyState(), stateRef2)
private val stateAndRef3 = StateAndRef<DummyState>(DummyState(), stateRef3)
private val stateAndRef4 = StateAndRef<DummyState>(DummyState(), stateRef4)
private val stateAndRef0 = StateAndRef(TransactionState(DummyState(), DUMMY_NOTARY), stateRef0)
private val stateAndRef1 = StateAndRef(TransactionState(DummyState(), DUMMY_NOTARY), stateRef1)
private val stateAndRef2 = StateAndRef(TransactionState(DummyState(), DUMMY_NOTARY), stateRef2)
private val stateAndRef3 = StateAndRef(TransactionState(DummyState(), DUMMY_NOTARY), stateRef3)
private val stateAndRef4 = StateAndRef(TransactionState(DummyState(), DUMMY_NOTARY), stateRef4)
@Test
fun `nothing plus nothing is nothing`() {

View File

@ -3,6 +3,8 @@ package com.r3corda.node.internal.testing
import com.r3corda.core.contracts.StateAndRef
import com.r3corda.core.contracts.DummyContract
import com.r3corda.core.contracts.StateRef
import com.r3corda.core.contracts.TransactionState
import com.r3corda.core.contracts.TransactionType
import com.r3corda.core.crypto.Party
import com.r3corda.core.seconds
import com.r3corda.core.testing.DUMMY_NOTARY
@ -11,8 +13,8 @@ import com.r3corda.node.internal.AbstractNode
import java.time.Instant
import java.util.*
fun issueState(node: AbstractNode, notary: Party = DUMMY_NOTARY): StateAndRef<*> {
val tx = DummyContract().generateInitial(node.info.identity.ref(0), Random().nextInt(), notary)
fun issueState(node: AbstractNode): StateAndRef<*> {
val tx = DummyContract().generateInitial(node.info.identity.ref(0), Random().nextInt(), DUMMY_NOTARY)
tx.signWith(node.storage.myLegalIdentityKey)
tx.signWith(DUMMY_NOTARY_KEY)
val stx = tx.toSignedTransaction()
@ -20,6 +22,20 @@ fun issueState(node: AbstractNode, notary: Party = DUMMY_NOTARY): StateAndRef<*>
return StateAndRef(tx.outputStates().first(), StateRef(stx.id, 0))
}
fun issueMultiPartyState(nodeA: AbstractNode, nodeB: AbstractNode): StateAndRef<DummyContract.MultiOwnerState> {
val state = TransactionState(DummyContract.MultiOwnerState(0,
listOf(nodeA.info.identity.owningKey, nodeB.info.identity.owningKey)), DUMMY_NOTARY)
val tx = TransactionType.NotaryChange.Builder().withItems(state)
tx.signWith(nodeA.storage.myLegalIdentityKey)
tx.signWith(nodeB.storage.myLegalIdentityKey)
tx.signWith(DUMMY_NOTARY_KEY)
val stx = tx.toSignedTransaction()
nodeA.services.recordTransactions(listOf(stx))
nodeB.services.recordTransactions(listOf(stx))
val stateAndRef = StateAndRef(state, StateRef(stx.id, 0))
return stateAndRef
}
fun issueInvalidState(node: AbstractNode, notary: Party = DUMMY_NOTARY): StateAndRef<*> {
val tx = DummyContract().generateInitial(node.info.identity.ref(0), Random().nextInt(), notary)
tx.setTime(Instant.now(), notary, 30.seconds)

View File

@ -3,7 +3,7 @@ package com.r3corda.node.internal.testing
import com.r3corda.contracts.cash.Cash
import com.r3corda.core.contracts.Amount
import com.r3corda.core.contracts.Issued
import com.r3corda.core.contracts.TransactionBuilder
import com.r3corda.core.contracts.TransactionType
import com.r3corda.core.crypto.Party
import com.r3corda.core.node.ServiceHub
import com.r3corda.core.serialization.OpaqueBytes
@ -34,7 +34,7 @@ object WalletFiller {
// this field as there's no other database or source of truth we need to sync with.
val depositRef = myIdentity.ref(ref)
val issuance = TransactionBuilder()
val issuance = TransactionType.General.Builder()
val freshKey = services.keyManagementService.freshKey()
cash.generateIssue(issuance, Amount(pennies, Issued(depositRef, howMuch.token)), freshKey.public, notary)
issuance.signWith(myKey)

View File

@ -17,37 +17,11 @@ class NotaryChangeService(net: MessagingService, val smm: StateMachineManager) :
)
}
private fun handleChangeNotaryRequest(req: NotaryChangeProtocol.Handshake): Boolean {
val proposal = req.payload
val autoAccept = checkProposal(proposal)
if (autoAccept) {
val protocol = NotaryChangeProtocol.Acceptor(
req.replyTo as SingleMessageRecipient,
proposal.sessionIdForReceive,
proposal.sessionIdForSend)
smm.add(NotaryChangeProtocol.TOPIC_CHANGE, protocol)
}
return autoAccept
}
/**
* Check the notary change for a state proposal and decide whether to allow the change and initiate the protocol
* or deny the change.
*
* For example, if the proposed new notary has the same behaviour (e.g. both are non-validating)
* and is also in a geographically convenient location we can just automatically approve the change.
* TODO: In more difficult cases this should call for human attention to manually verify and approve the proposal
*/
private fun checkProposal(proposal: NotaryChangeProtocol.Proposal): Boolean {
val newNotary = proposal.newNotary
val isNotary = smm.serviceHub.networkMapCache.notaryNodes.any { it.identity == newNotary }
require(isNotary) { "The proposed node $newNotary does not run a Notary service " }
// An example requirement
val blacklist = listOf("Evil Notary")
require(!blacklist.contains(newNotary.name))
return true
private fun handleChangeNotaryRequest(req: NotaryChangeProtocol.Handshake) {
val protocol = NotaryChangeProtocol.Acceptor(
req.replyTo as SingleMessageRecipient,
req.sessionID!!,
req.sessionIdForSend)
smm.add(NotaryChangeProtocol.TOPIC_CHANGE, protocol)
}
}

View File

@ -1,20 +1,24 @@
package node.services
import com.r3corda.contracts.DummyContract
import com.r3corda.core.contracts.StateAndRef
import com.r3corda.core.contracts.StateRef
import com.r3corda.core.contracts.TransactionState
import com.r3corda.core.contracts.TransactionType
import com.r3corda.core.crypto.Party
import com.r3corda.core.crypto.generateKeyPair
import com.r3corda.core.testing.DUMMY_NOTARY
import com.r3corda.core.testing.DUMMY_NOTARY_KEY
import com.r3corda.node.internal.testing.MockNetwork
import com.r3corda.node.internal.testing.issueMultiPartyState
import com.r3corda.node.internal.testing.issueState
import com.r3corda.node.services.transactions.NotaryService
import com.r3corda.node.services.network.NetworkMapService
import com.r3corda.node.services.transactions.SimpleNotaryService
import org.junit.Before
import org.junit.Test
import protocols.NotaryChangeException
import protocols.NotaryChangeProtocol
import protocols.NotaryChangeProtocol.Instigator
import protocols.NotaryChangeRefused
import java.util.concurrent.ExecutionException
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class NotaryChangeTests {
lateinit var net: MockNetwork
@ -26,22 +30,22 @@ class NotaryChangeTests {
@Before
fun setup() {
net = MockNetwork()
oldNotaryNode = net.createNotaryNode(DUMMY_NOTARY.name, DUMMY_NOTARY_KEY)
clientNodeA = net.createPartyNode(networkMapAddr = oldNotaryNode.info)
clientNodeB = net.createPartyNode(networkMapAddr = oldNotaryNode.info)
newNotaryNode = net.createNode(networkMapAddress = oldNotaryNode.info, advertisedServices = NotaryService.Type)
oldNotaryNode = net.createNode(
legalName = DUMMY_NOTARY.name,
keyPair = DUMMY_NOTARY_KEY,
advertisedServices = *arrayOf(NetworkMapService.Type, SimpleNotaryService.Type))
clientNodeA = net.createNode(networkMapAddress = oldNotaryNode.info)
clientNodeB = net.createNode(networkMapAddress = oldNotaryNode.info)
newNotaryNode = net.createNode(networkMapAddress = oldNotaryNode.info, advertisedServices = SimpleNotaryService.Type)
net.runNetwork() // Clear network map registration messages
}
@Test
fun `should change notary for a state with single participant`() {
val ref = issueState(clientNodeA, DUMMY_NOTARY).ref
val state = clientNodeA.services.loadState(ref)
val state = issueState(clientNodeA)
val newNotary = newNotaryNode.info.identity
val protocol = Instigator(StateAndRef(state, ref), newNotary)
val protocol = Instigator(state, newNotary)
val future = clientNodeA.smm.add(NotaryChangeProtocol.TOPIC_CHANGE, protocol)
net.runNetwork()
@ -52,21 +56,9 @@ class NotaryChangeTests {
@Test
fun `should change notary for a state with multiple participants`() {
val state = TransactionState(DummyContract.MultiOwnerState(0,
listOf(clientNodeA.info.identity.owningKey, clientNodeB.info.identity.owningKey)), DUMMY_NOTARY)
val tx = TransactionType.NotaryChange.Builder().withItems(state)
tx.signWith(clientNodeA.storage.myLegalIdentityKey)
tx.signWith(clientNodeB.storage.myLegalIdentityKey)
tx.signWith(DUMMY_NOTARY_KEY)
val stx = tx.toSignedTransaction()
clientNodeA.services.recordTransactions(listOf(stx))
clientNodeB.services.recordTransactions(listOf(stx))
val stateAndRef = StateAndRef(state, StateRef(stx.id, 0))
val state = issueMultiPartyState(clientNodeA, clientNodeB)
val newNotary = newNotaryNode.info.identity
val protocol = Instigator(stateAndRef, newNotary)
val protocol = Instigator(state, newNotary)
val future = clientNodeA.smm.add(NotaryChangeProtocol.TOPIC_CHANGE, protocol)
net.runNetwork()
@ -78,8 +70,21 @@ class NotaryChangeTests {
assertEquals(loadedStateA, loadedStateB)
}
@Test
fun `should throw when a participant refuses to change Notary`() {
val state = issueMultiPartyState(clientNodeA, clientNodeB)
val newEvilNotary = Party("Evil Notary", generateKeyPair().public)
val protocol = Instigator(state, newEvilNotary)
val future = clientNodeA.smm.add(NotaryChangeProtocol.TOPIC_CHANGE, protocol)
net.runNetwork()
val ex = assertFailsWith(ExecutionException::class) { future.get() }
val error = (ex.cause as NotaryChangeException).error
assertTrue(error is NotaryChangeRefused)
}
// TODO: Add more test cases once we have a general protocol/service exception handling mechanism:
// - A participant refuses to change Notary
// - A participant is offline/can't be found on the network
// - The requesting party is not a participant
// - The requesting party wants to change additional state fields