CORDA-2375 Ensure node has unique attachment contract classname/version from signed JARs (#4535)

Corda Node ensures a given contract class and version can be sourced from only one signed and trusted Attachment (JAR).
An attempt to import a signed JAR as a trusted uploader (or promote to be trusted) with a class and version already present in the other trusted Attachment will raise DuplicateContractClassException.

Minor fixes to Hibernate Attachment Query parser (original query to select attachment without signers would always return no attachments)
This commit is contained in:
szymonsztuka 2019-01-10 14:13:00 +00:00 committed by GitHub
parent e87a8ed496
commit 9b8fda0d6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 246 additions and 31 deletions

View File

@ -212,7 +212,7 @@ data class Sort(val columns: Collection<SortColumn>) : BaseSort() {
data class AttachmentSort(val columns: Collection<AttachmentSortColumn>) : BaseSort() {
enum class AttachmentSortAttribute(val columnName: String) {
INSERTION_DATE("insertion_date"),
INSERTION_DATE("insertionDate"),
UPLOADER("uploader"),
FILENAME("filename"),
VERSION ("version")

View File

@ -230,6 +230,20 @@ The contract attachment non-downgrade rule is enforced in two locations:
A version number is stored in the manifest information of the enclosing JAR file. This version identifier should be a whole number starting
from 1. This information should be set using the Gradle cordapp plugin, or manually, as described in :doc:`versioning`.
Uniqueness requirement Contract and Version for Signature Constraint
--------------------------------------------------------------------
CorDapps in Corda 4 may be signed (to use new signature constraints functionality) or unsigned, and versioned.
The following controls are enforced for these different types of jars within the attachment store of a node:
- Signed contract JARs must be uniquely versioned per contract class (or group of).
At runtime the node will throw a `DuplicateContractClassException`` exception if this condition is violated.
- Unsigned contract JARs: there may exist multiple instances of the same contract jar.
At run-time the node will warn of duplicates encountered.
The most recent version given by insertionDate into the attachment storage will be used upon transaction building/resolution.
Issues when using the HashAttachmentConstraint
----------------------------------------------

View File

@ -11,6 +11,12 @@ import net.corda.core.serialization.CordaSerializable
*/
class DuplicateAttachmentException(attachmentHash: String) : java.nio.file.FileAlreadyExistsException(attachmentHash), ClientRelevantError
/**
* Thrown to indicate that a contract class name of the same version was already uploaded to a Corda node.
*/
class DuplicateContractClassException(contractClassName: String, version: Int, attachmentHashes: List<String>) :
Exception("Contract $contractClassName version '$version' already present in the attachments $attachmentHashes"), ClientRelevantError
/**
* Thrown to indicate that a flow was not designed for RPC and should be started from an RPC client.
*/

View File

@ -80,18 +80,24 @@ class FlowCheckpointVersionNodeStartupCheckTest {
@Test
fun `restart node with incompatible version of suspended flow due to different jar hash`() {
driver(parametersForRestartingNodes()) {
val uniqueName = "different-jar-hash-test-${UUID.randomUUID()}"
val cordapp = defaultCordapp.copy(name = uniqueName)
val uniqueWorkflowJarName = "different-jar-hash-test-${UUID.randomUUID()}"
val uniqueContractJarName = "contract-$uniqueWorkflowJarName"
val defaultWorkflowJar = cordappWithPackages(SendMessageFlow::class.packageName)
val defaultContractJar = cordappWithPackages(MessageState::class.packageName)
val contractJar = defaultContractJar.copy(name = uniqueContractJarName)
val workflowJar = defaultWorkflowJar.copy(name = uniqueWorkflowJarName)
val bobBaseDir = createSuspendedFlowInBob(setOf(cordapp))
val bobBaseDir = createSuspendedFlowInBob(setOf(workflowJar, contractJar))
val cordappsDir = bobBaseDir / "cordapps"
val cordappJar = cordappsDir.list().single { it.toString().endsWith(".jar") }
val cordappJar = cordappsDir.list().single {
! it.toString().contains(uniqueContractJarName) && it.toString().endsWith(".jar")
}
// Make sure we're dealing with right jar
assertThat(cordappJar.fileName.toString()).contains(uniqueName)
assertThat(cordappJar.fileName.toString()).contains(uniqueWorkflowJarName)
// The name is part of the MANIFEST so changing it is sufficient to change the jar hash
val modifiedCordapp = cordapp.copy(name = "${cordapp.name}-modified")
val modifiedCordapp = workflowJar.copy(name = "${workflowJar.name}-modified")
val modifiedCordappJar = CustomCordapp.getJarFile(modifiedCordapp)
modifiedCordappJar.moveTo(cordappJar, REPLACE_EXISTING)
@ -125,8 +131,8 @@ class FlowCheckpointVersionNodeStartupCheckTest {
)).getOrThrow()
}
val logDir = baseDirectory(BOB_NAME) / NodeStartup.LOGS_DIRECTORY_NAME
val logFile = logDir.list { it.filter { it.fileName.toString().endsWith(".log") }.findAny().get() }
val logDir = baseDirectory(BOB_NAME)
val logFile = logDir.list { it.filter { it.fileName.toString().endsWith("out.log") }.findAny().get() }
val matchingLineCount = logFile.readLines { it.filter { line -> logMessage in line }.count() }
assertEquals(1, matchingLineCount)
}

View File

@ -29,6 +29,7 @@ import net.corda.node.utilities.InfrequentlyMutatedCache
import net.corda.node.utilities.NonInvalidatingCache
import net.corda.node.utilities.NonInvalidatingWeightBasedCache
import net.corda.nodeapi.exceptions.DuplicateAttachmentException
import net.corda.nodeapi.exceptions.DuplicateContractClassException
import net.corda.nodeapi.internal.persistence.CordaPersistence
import net.corda.nodeapi.internal.persistence.NODE_DATABASE_PREFIX
import net.corda.nodeapi.internal.persistence.currentDBSession
@ -305,6 +306,22 @@ class NodeAttachmentService(
currentDBSession().find(NodeAttachmentService.DBAttachment::class.java, attachmentId.toString()) != null
}
private fun verifyVersionUniquenessForSignedAttachments(contractClassNames: List<ContractClassName>, contractVersion: Int, signers: List<PublicKey>?){
if (signers != null && signers.isNotEmpty()) {
contractClassNames.forEach {
val existingContractsImplementations = queryAttachments(AttachmentQueryCriteria.AttachmentsQueryCriteria(
contractClassNamesCondition = Builder.equal(listOf(it)),
versionCondition = Builder.equal(contractVersion),
uploaderCondition = Builder.`in`(TRUSTED_UPLOADERS),
isSignedCondition = Builder.equal(true))
)
if (existingContractsImplementations.isNotEmpty()) {
throw DuplicateContractClassException(it, contractVersion, existingContractsImplementations.map { it.toString() })
}
}
}
}
// TODO: PLT-147: The attachment should be randomised to prevent brute force guessing and thus privacy leaks.
private fun import(jar: InputStream, uploader: String?, filename: String?): AttachmentId {
return database.transaction {
@ -324,6 +341,9 @@ class NodeAttachmentService(
val jarSigners = getSigners(bytes)
val contractVersion = getVersion(bytes)
val session = currentDBSession()
verifyVersionUniquenessForSignedAttachments(contractClassNames, contractVersion, jarSigners)
val attachment = NodeAttachmentService.DBAttachment(
attId = id.toString(),
content = bytes,
@ -344,6 +364,7 @@ class NodeAttachmentService(
val attachment = session.get(NodeAttachmentService.DBAttachment::class.java, id.toString())
// update the `uploader` field (as the existing attachment may have been resolved from a peer)
if (attachment.uploader != uploader) {
verifyVersionUniquenessForSignedAttachments(contractClassNames, attachment.version, attachment.signers)
attachment.uploader = uploader
session.saveOrUpdate(attachment)
log.info("Updated attachment $id with uploader $uploader")
@ -430,7 +451,8 @@ class NodeAttachmentService(
private fun getContractAttachmentVersions(contractClassName: String): NavigableMap<Version, AttachmentIds> = contractsCache.get(contractClassName) { name ->
val attachmentQueryCriteria = AttachmentQueryCriteria.AttachmentsQueryCriteria(contractClassNamesCondition = Builder.equal(listOf(name)),
versionCondition = Builder.greaterThanOrEqual(0), uploaderCondition = Builder.`in`(TRUSTED_UPLOADERS))
val attachmentSort = AttachmentSort(listOf(AttachmentSort.AttachmentSortColumn(AttachmentSort.AttachmentSortAttribute.VERSION, Sort.Direction.DESC)))
val attachmentSort = AttachmentSort(listOf(AttachmentSort.AttachmentSortColumn(AttachmentSort.AttachmentSortAttribute.VERSION, Sort.Direction.DESC),
AttachmentSort.AttachmentSortColumn(AttachmentSort.AttachmentSortAttribute.INSERTION_DATE, Sort.Direction.DESC)))
database.transaction {
val session = currentDBSession()
val criteriaBuilder = session.criteriaBuilder
@ -447,15 +469,17 @@ class NodeAttachmentService(
val query = session.createQuery(criteriaQuery)
// execution
TreeMap(query.resultList.groupBy { it.version }.map { makeAttachmentIds(it) }.toMap())
TreeMap(query.resultList.groupBy { it.version }.map { makeAttachmentIds(it, name) }.toMap())
}
}
private fun makeAttachmentIds(it: Map.Entry<Int, List<DBAttachment>>): Pair<Version, AttachmentIds> {
check(it.value.size <= 2)
val signed = it.value.filter { it.signers?.isNotEmpty() ?: false }.map { AttachmentId.parse(it.attId) }.singleOrNull()
val unsigned = it.value.filter { it.signers?.isEmpty() ?: true }.map { AttachmentId.parse(it.attId) }.singleOrNull()
return it.key to AttachmentIds(signed, unsigned)
private fun makeAttachmentIds(it: Map.Entry<Int, List<DBAttachment>>, contractClassName: String): Pair<Version, AttachmentIds> {
val signed = it.value.filter { it.signers?.isNotEmpty() ?: false }.map { AttachmentId.parse(it.attId) }
check (signed.size <= 1) //sanity check
val unsigned = it.value.filter { it.signers?.isEmpty() ?: true }.map { AttachmentId.parse(it.attId) }
if (unsigned.size > 1) //TODO cater better for whiltelisted JARs - CORDA-2405
log.warn("Selecting attachment ${unsigned.first()} from duplicated, unsigned attachments ${unsigned.map { it.toString() }} for contract $contractClassName version '${it.key}'.")
return it.key to AttachmentIds(signed.singleOrNull(), unsigned.firstOrNull())
}
override fun getContractAttachmentWithHighestContractVersion(contractClassName: String, minContractVersion: Int): AttachmentId? {

View File

@ -231,11 +231,12 @@ class HibernateAttachmentQueryCriteriaParser(override val criteriaBuilder: Crite
}
criteria.isSignedCondition?.let { isSigned ->
if (isSigned == Builder.equal(true)) {
val joinDBAttachmentToSigners = root.joinList<NodeAttachmentService.DBAttachment, PublicKey>("signers")
if (isSigned == Builder.equal(true))
predicateSet.add(criteriaBuilder.and(joinDBAttachmentToSigners.isNotNull))
else
predicateSet.add(criteriaBuilder.and(joinDBAttachmentToSigners.isNull))
} else {
predicateSet.add(criteriaBuilder.equal(criteriaBuilder.size(root.get<List<PublicKey>?>("signers")),0))
}
}
criteria.versionCondition?.let {

View File

@ -18,6 +18,7 @@ import net.corda.core.node.services.vault.Sort
import net.corda.core.utilities.getOrThrow
import net.corda.node.services.transactions.PersistentUniquenessProvider
import net.corda.nodeapi.exceptions.DuplicateAttachmentException
import net.corda.nodeapi.exceptions.DuplicateContractClassException
import net.corda.nodeapi.internal.persistence.CordaPersistence
import net.corda.nodeapi.internal.persistence.DatabaseConfig
import net.corda.testing.core.internal.ContractJarTestUtils.makeTestContractJar
@ -33,6 +34,7 @@ import net.corda.testing.node.internal.InternalMockNetwork
import net.corda.testing.node.internal.startFlow
import org.assertj.core.api.Assertions.*
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
@ -268,10 +270,18 @@ class NodeAttachmentServiceTest {
storage.queryAttachments(AttachmentsQueryCriteria(signersCondition = Builder.equal(listOf(publicKey)))).size
)
assertEquals(
3,
storage.queryAttachments(AttachmentsQueryCriteria(isSignedCondition = Builder.equal(true))).size
)
val allAttachments = storage.queryAttachments(AttachmentsQueryCriteria())
assertEquals(6, allAttachments.size)
val signedAttachments = storage.queryAttachments(AttachmentsQueryCriteria(isSignedCondition = Builder.equal(true)))
assertEquals(3, signedAttachments.size)
val unsignedAttachments = storage.queryAttachments(AttachmentsQueryCriteria(isSignedCondition = Builder.equal(false)))
assertEquals(3, unsignedAttachments.size)
assertNotEquals(signedAttachments.toSet(), unsignedAttachments.toSet())
assertEquals(signedAttachments.toSet() + unsignedAttachments.toSet(), allAttachments.toSet())
assertEquals(
1,
@ -315,6 +325,138 @@ class NodeAttachmentServiceTest {
}
}
@Test
fun `cannot import jar with duplicated contract class, version and signers for trusted uploader`() {
SelfCleaningDir().use { file ->
val (contractJar, _) = makeTestSignedContractJar(file.path, "com.example.MyContract")
val anotherContractJar = makeTestContractJar(file.path, listOf( "com.example.MyContract", "com.example.AnotherContract"), true, generateManifest = false, jarFileName = "another-sample.jar")
contractJar.read { storage.privilegedImportAttachment(it, "app", "sample.jar") }
assertThatExceptionOfType(DuplicateContractClassException::class.java).isThrownBy {
anotherContractJar.read { storage.privilegedImportAttachment(it, "app", "another-sample.jar") }
}
}
}
@Test
fun `can import jar with duplicated contract class, version and signers - when one uploader is trusted and other isnt`() {
SelfCleaningDir().use { file ->
val (contractJar, _) = makeTestSignedContractJar(file.path, "com.example.MyContract")
val anotherContractJar = makeTestContractJar(file.path, listOf( "com.example.MyContract", "com.example.AnotherContract"), true, generateManifest = false, jarFileName = "another-sample.jar")
val attachmentId = contractJar.read { storage.importAttachment(it, "uploaderA", "sample.jar") }
val anotherAttachmentId = anotherContractJar.read { storage.privilegedImportAttachment(it, "app", "another-sample.jar") }
assertNotEquals(attachmentId, anotherAttachmentId)
}
}
@Test
fun `can promote to trusted uploader for the same attachment`() {
SelfCleaningDir().use { file ->
val (contractJar, _) = makeTestSignedContractJar(file.path, "com.example.MyContract")
val attachmentId = contractJar.read { storage.importAttachment(it, "uploaderA", "sample.jar") }
val reimportedAttachmentId = contractJar.read { storage.privilegedImportAttachment(it, "app", "sample.jar") }
assertEquals(attachmentId, reimportedAttachmentId)
}
}
@Test
fun `cannot promote to trusted uploader if other trusted attachment already has duplicated contract class, version and signers`() {
SelfCleaningDir().use { file ->
val (contractJar, _) = makeTestSignedContractJar(file.path, "com.example.MyContract")
contractJar.read { storage.importAttachment(it, "uploaderA", "sample.jar") }
val anotherContractJar = makeTestContractJar(file.path, listOf( "com.example.MyContract", "com.example.AnotherContract"), true, generateManifest = false, jarFileName = "another-sample.jar")
anotherContractJar.read { storage.privilegedImportAttachment(it, "app", "another-sample.jar") }
assertThatExceptionOfType(DuplicateContractClassException::class.java).isThrownBy {
contractJar.read { storage.privilegedImportAttachment(it, "app", "sample.jar") }
}
}
}
@Test
fun `cannot promote to trusted uploder the same jar if other trusted uplodaer `() {
SelfCleaningDir().use { file ->
val (contractJar, _) = makeTestSignedContractJar(file.path, "com.example.MyContract")
val anotherContractJar = makeTestContractJar(file.path, listOf( "com.example.MyContract", "com.example.AnotherContract"), true, generateManifest = false, jarFileName = "another-sample.jar")
contractJar.read { storage.privilegedImportAttachment(it, "app", "sample.jar") }
assertThatExceptionOfType(DuplicateContractClassException::class.java).isThrownBy {
anotherContractJar.read { storage.privilegedImportAttachment(it, "app", "another-sample.jar") }
}
}
}
@Test
fun `can import duplicated contract class and signers if versions differ`() {
SelfCleaningDir().use { file ->
val (contractJar, _) = makeTestSignedContractJar(file.path, "com.example.MyContract", 2)
val anotherContractJar = makeTestContractJar(file.path, listOf( "com.example.MyContract", "com.example.AnotherContract"), true, generateManifest = false, jarFileName = "another-sample.jar")
contractJar.read { storage.importAttachment(it, "uploaderA", "sample.jar") }
anotherContractJar.read { storage.importAttachment(it, "uploaderA", "another-sample.jar") }
val attachments = storage.queryAttachments(AttachmentsQueryCriteria(contractClassNamesCondition = Builder.equal(listOf("com.example.MyContract"))))
assertEquals(2, attachments.size)
attachments.forEach {
assertTrue("com.example.MyContract" in (storage.openAttachment(it) as ContractAttachment).allContracts)
}
}
}
@Test
fun `can import duplicated contract class and version from unsiged attachment if a signed attachment already exists`() {
SelfCleaningDir().use { file ->
val (contractJar, _) = makeTestSignedContractJar(file.path, "com.example.MyContract")
val anotherContractJar = makeTestContractJar(file.path, listOf( "com.example.MyContract", "com.example.AnotherContract"), generateManifest = false, jarFileName = "another-sample.jar")
contractJar.read { storage.importAttachment(it, "uploaderA", "sample.jar") }
anotherContractJar.read { storage.importAttachment(it, "uploaderB", "another-sample.jar") }
val attachments = storage.queryAttachments(AttachmentsQueryCriteria(contractClassNamesCondition = Builder.equal(listOf("com.example.MyContract"))))
assertEquals(2, attachments.size)
attachments.forEach {
val att = storage.openAttachment(it)
assertTrue(att is ContractAttachment)
assertTrue("com.example.MyContract" in (att as ContractAttachment).allContracts)
}
}
}
@Test
fun `can import duplicated contract class and version from siged attachment if an unsigned attachment already exists`() {
SelfCleaningDir().use { file ->
val contractJar = makeTestContractJar(file.path, "com.example.MyContract")
val anotherContractJar = makeTestContractJar(file.path, listOf( "com.example.MyContract", "com.example.AnotherContract"), true, generateManifest = false, jarFileName = "another-sample.jar")
contractJar.read { storage.importAttachment(it, "uploaderA", "sample.jar") }
anotherContractJar.read { storage.importAttachment(it, "uploaderB", "another-sample.jar") }
val attachments = storage.queryAttachments(AttachmentsQueryCriteria(contractClassNamesCondition = Builder.equal(listOf("com.example.MyContract"))))
assertEquals(2, attachments.size)
attachments.forEach {
val att = storage.openAttachment(it)
assertTrue(att is ContractAttachment)
assertTrue("com.example.MyContract" in (att as ContractAttachment).allContracts)
}
}
}
@Test
fun `can import duplicated contract class and version for unsigned attachments`() {
SelfCleaningDir().use { file ->
val contractJar = makeTestContractJar(file.path, "com.example.MyContract")
val anotherContractJar = makeTestContractJar(file.path, listOf( "com.example.MyContract", "com.example.AnotherContract"), generateManifest = false, jarFileName = "another-sample.jar")
contractJar.read { storage.importAttachment(it, "uploaderA", "sample.jar") }
anotherContractJar.read { storage.importAttachment(it, "uploaderB", "another-sample.jar") }
val attachments = storage.queryAttachments(AttachmentsQueryCriteria(contractClassNamesCondition = Builder.equal(listOf("com.example.MyContract"))))
assertEquals(2, attachments.size)
attachments.forEach {
val att = storage.openAttachment(it)
assertTrue(att is ContractAttachment)
assertTrue("com.example.MyContract" in (att as ContractAttachment).allContracts)
}
}
}
@Test
fun `sorting and compound conditions work`() {
val (jarA, hashA) = makeTestJar(listOf(Pair("a", "a")))

View File

@ -44,15 +44,20 @@ object ContractJarTestUtils {
}
}
@JvmOverloads
fun makeTestSignedContractJar(workingDir: Path, contractName: String, version: Int = 1): Pair<Path, PublicKey> {
private fun Path.signWithDummyKey(jarName: Path): PublicKey{
val alias = "testAlias"
val pwd = "testPassword"
workingDir.generateKey(alias, pwd, ALICE_NAME.toString())
this.generateKey(alias, pwd, ALICE_NAME.toString())
val signer = this.signJar(jarName.toAbsolutePath().toString(), alias, pwd)
(this / "_shredder").delete()
(this / "_teststore").delete()
return signer
}
@JvmOverloads
fun makeTestSignedContractJar(workingDir: Path, contractName: String, version: Int = 1): Pair<Path, PublicKey> {
val jarName = makeTestContractJar(workingDir, contractName, true, version)
val signer = workingDir.signJar(jarName.toAbsolutePath().toString(), alias, pwd)
(workingDir / "_shredder").delete()
(workingDir / "_teststore").delete()
val signer = workingDir.signWithDummyKey(jarName)
return workingDir.resolve(jarName) to signer
}
@ -67,6 +72,23 @@ object ContractJarTestUtils {
return workingDir.resolve(jarName)
}
@JvmOverloads
fun makeTestContractJar(workingDir: Path, contractNames: List<String>, signed: Boolean = false, version: Int = 1, generateManifest: Boolean = true, jarFileName : String? = null): Path {
contractNames.forEach {
val packages = it.split(".")
val className = packages.last()
createTestClass(workingDir, className, packages.subList(0, packages.size - 1))
}
val packages = contractNames.first().split(".")
val jarName = jarFileName ?: "attachment-${packages.last()}-$version-${(if (signed) "signed" else "")}.jar"
workingDir.createJar(jarName, *contractNames.map{ "${it.replace(".", "/")}.class" }.toTypedArray() )
if (generateManifest)
workingDir.addManifest(jarName, Pair(Attributes.Name(CORDAPP_CONTRACT_VERSION), version.toString()))
if (signed)
workingDir.signWithDummyKey(workingDir.resolve(jarName))
return workingDir.resolve(jarName)
}
private fun createTestClass(workingDir: Path, className: String, packages: List<String>): Path {
val newClass = """package ${packages.joinToString(".")};
import net.corda.core.contracts.*;