diff --git a/core/src/main/kotlin/net/corda/core/contracts/Attachment.kt b/core/src/main/kotlin/net/corda/core/contracts/Attachment.kt new file mode 100644 index 0000000000..b55055e529 --- /dev/null +++ b/core/src/main/kotlin/net/corda/core/contracts/Attachment.kt @@ -0,0 +1,42 @@ +package net.corda.core.contracts + +import net.corda.core.identity.Party +import net.corda.core.internal.extractFile +import java.io.FileNotFoundException +import java.io.InputStream +import java.io.OutputStream +import java.util.jar.JarInputStream + +/** + * An attachment is a ZIP (or an optionally signed JAR) that contains one or more files. Attachments are meant to + * contain public static data which can be referenced from transactions and utilised from contracts. Good examples + * of how attachments are meant to be used include: + * - Calendar data + * - Fixes (e.g. LIBOR) + * - Smart contract code + * - Legal documents + * - Facts generated by oracles which might be reused a lot + */ +interface Attachment : NamedByHash { + fun open(): InputStream + fun openAsJAR(): JarInputStream { + val stream = open() + try { + return JarInputStream(stream) + } catch (t: Throwable) { + stream.use { throw t } + } + } + + /** + * Finds the named file case insensitively and copies it to the output stream. + * @throws FileNotFoundException if the given path doesn't exist in the attachment. + */ + fun extractFile(path: String, outputTo: OutputStream) = openAsJAR().use { it.extractFile(path, outputTo) } + + /** + * The parties that have correctly signed the whole attachment. + * Can be empty, for example non-contract attachments won't be necessarily be signed. + */ + val signers: List +} diff --git a/core/src/main/kotlin/net/corda/core/contracts/Structures.kt b/core/src/main/kotlin/net/corda/core/contracts/Structures.kt index f3e501bd5a..820d87fe6a 100644 --- a/core/src/main/kotlin/net/corda/core/contracts/Structures.kt +++ b/core/src/main/kotlin/net/corda/core/contracts/Structures.kt @@ -9,18 +9,11 @@ import net.corda.core.flows.FlowLogicRefFactory import net.corda.core.identity.AbstractParty import net.corda.core.identity.Party import net.corda.core.serialization.CordaSerializable -import net.corda.core.serialization.MissingAttachmentsException -import net.corda.core.serialization.SerializeAsTokenContext import net.corda.core.serialization.serialize import net.corda.core.transactions.LedgerTransaction import net.corda.core.utilities.OpaqueBytes -import java.io.FileNotFoundException -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream import java.security.PublicKey import java.time.Instant -import java.util.jar.JarInputStream /** Implemented by anything that can be named by a secure hash value (e.g. transactions, attachments). */ interface NamedByHash { @@ -362,69 +355,6 @@ interface UpgradedContract ByteArray) : Attachment { - companion object { - fun SerializeAsTokenContext.attachmentDataLoader(id: SecureHash): () -> ByteArray { - return { - val a = serviceHub.attachments.openAttachment(id) ?: throw MissingAttachmentsException(listOf(id)) - (a as? AbstractAttachment)?.attachmentData ?: a.open().use { it.readBytes() } - } - } - } - - protected val attachmentData: ByteArray by lazy(dataLoader) - override fun open(): InputStream = attachmentData.inputStream() - override fun equals(other: Any?) = other === this || other is Attachment && other.id == this.id - override fun hashCode() = id.hashCode() - override fun toString() = "${javaClass.simpleName}(id=$id)" -} - -@Throws(IOException::class) -fun JarInputStream.extractFile(path: String, outputTo: OutputStream) { - val p = path.toLowerCase().split('\\', '/') - while (true) { - val e = nextJarEntry ?: break - if (!e.isDirectory && e.name.toLowerCase().split('\\', '/') == p) { - copyTo(outputTo) - return - } - closeEntry() - } - throw FileNotFoundException(path) -} - /** * A privacy salt is required to compute nonces per transaction component in order to ensure that an adversary cannot * use brute force techniques and reveal the content of a Merkle-leaf hashed value. diff --git a/core/src/main/kotlin/net/corda/core/internal/AbstractAttachment.kt b/core/src/main/kotlin/net/corda/core/internal/AbstractAttachment.kt new file mode 100644 index 0000000000..2f402f43e5 --- /dev/null +++ b/core/src/main/kotlin/net/corda/core/internal/AbstractAttachment.kt @@ -0,0 +1,69 @@ +package net.corda.core.internal + +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.util.jar.JarInputStream + +abstract class AbstractAttachment(dataLoader: () -> ByteArray) : Attachment { + companion object { + fun SerializeAsTokenContext.attachmentDataLoader(id: SecureHash): () -> ByteArray { + return { + val a = serviceHub.attachments.openAttachment(id) ?: throw MissingAttachmentsException(listOf(id)) + (a as? AbstractAttachment)?.attachmentData ?: a.open().use { it.readBytes() } + } + } + + /** @see */ + private val unsignableEntryName = "META-INF/(?:.*[.](?:SF|DSA|RSA)|SIG-.*)".toRegex() + private val shredder = ByteArray(1024) + } + + protected val attachmentData: ByteArray by lazy(dataLoader) + 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 -> + 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].toX509CertHolder()) + }.sortedBy { it.name.toString() } // Determinism. + } + + override fun equals(other: Any?) = other === this || other is Attachment && other.id == this.id + override fun hashCode() = id.hashCode() + override fun toString() = "${javaClass.simpleName}(id=$id)" +} + +@Throws(IOException::class) +fun JarInputStream.extractFile(path: String, outputTo: OutputStream) { + fun String.norm() = toLowerCase().split('\\', '/') // XXX: Should this really be locale-sensitive? + val p = path.norm() + while (true) { + val e = nextJarEntry ?: break + if (!e.isDirectory && e.name.norm() == p) { + copyTo(outputTo) + return + } + closeEntry() + } + throw FileNotFoundException(path) +} diff --git a/core/src/main/kotlin/net/corda/core/internal/FetchDataFlow.kt b/core/src/main/kotlin/net/corda/core/internal/FetchDataFlow.kt index 5006843121..ce95ee9838 100644 --- a/core/src/main/kotlin/net/corda/core/internal/FetchDataFlow.kt +++ b/core/src/main/kotlin/net/corda/core/internal/FetchDataFlow.kt @@ -1,7 +1,6 @@ package net.corda.core.internal import co.paralleluniverse.fibers.Suspendable -import net.corda.core.contracts.AbstractAttachment import net.corda.core.contracts.Attachment import net.corda.core.contracts.NamedByHash import net.corda.core.crypto.SecureHash diff --git a/core/src/test/kotlin/net/corda/core/contracts/StructuresTests.kt b/core/src/test/kotlin/net/corda/core/contracts/StructuresTests.kt index b1d915a745..47f69945dd 100644 --- a/core/src/test/kotlin/net/corda/core/contracts/StructuresTests.kt +++ b/core/src/test/kotlin/net/corda/core/contracts/StructuresTests.kt @@ -30,6 +30,7 @@ class AttachmentTest { val attachment = object : Attachment { override val id get() = throw UnsupportedOperationException() override fun open() = inputStream + override val signers get() = throw UnsupportedOperationException() } try { attachment.openAsJAR() diff --git a/core/src/test/kotlin/net/corda/core/internal/AbstractAttachmentTest.kt b/core/src/test/kotlin/net/corda/core/internal/AbstractAttachmentTest.kt new file mode 100644 index 0000000000..338cbfdb19 --- /dev/null +++ b/core/src/test/kotlin/net/corda/core/internal/AbstractAttachmentTest.kt @@ -0,0 +1,111 @@ +package net.corda.core.internal + +import net.corda.testing.ALICE +import net.corda.testing.BOB +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.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" + fun execute(vararg command: String) { + assertEquals(0, ProcessBuilder().inheritIO().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.toString()) + execute("keytool", "-genkey", "-keystore", "_teststore", "-storepass", "storepass", "-keyalg", "RSA", "-alias", "bob", "-keypass", "bobpass", "-dname", BOB.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.toFile().deleteRecursively() + } + } + + @After + fun tearDown() { + dir.toFile().listFiles().forEach { + if (!it.name.startsWith("_")) it.deleteRecursively() + } + assertEquals(4, dir.toFile().listFiles().size) + } + + @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/serialization/AttachmentSerializationTest.kt b/core/src/test/kotlin/net/corda/core/serialization/AttachmentSerializationTest.kt index 64e703dfec..ab38c1a545 100644 --- a/core/src/test/kotlin/net/corda/core/serialization/AttachmentSerializationTest.kt +++ b/core/src/test/kotlin/net/corda/core/serialization/AttachmentSerializationTest.kt @@ -113,6 +113,7 @@ class AttachmentSerializationTest { private class CustomAttachment(override val id: SecureHash, internal val customContent: String) : Attachment { override fun open() = throw UnsupportedOperationException("Not implemented.") + override val signers get() = throw UnsupportedOperationException() } private class CustomAttachmentLogic(server: MockNetwork.MockNode, private val attachmentId: SecureHash, private val customContent: String) : ClientLogic(server) { diff --git a/node/src/main/kotlin/net/corda/node/services/persistence/NodeAttachmentService.kt b/node/src/main/kotlin/net/corda/node/services/persistence/NodeAttachmentService.kt index 28125f40ff..94c00f52ca 100644 --- a/node/src/main/kotlin/net/corda/node/services/persistence/NodeAttachmentService.kt +++ b/node/src/main/kotlin/net/corda/node/services/persistence/NodeAttachmentService.kt @@ -6,7 +6,7 @@ import com.google.common.hash.HashCode import com.google.common.hash.Hashing import com.google.common.hash.HashingInputStream import com.google.common.io.CountingInputStream -import net.corda.core.contracts.AbstractAttachment +import net.corda.core.internal.AbstractAttachment import net.corda.core.contracts.Attachment import net.corda.core.crypto.SecureHash import net.corda.core.node.services.AttachmentStorage diff --git a/test-utils/src/main/kotlin/net/corda/testing/node/MockServices.kt b/test-utils/src/main/kotlin/net/corda/testing/node/MockServices.kt index 3f0bdc17f5..83394c5180 100644 --- a/test-utils/src/main/kotlin/net/corda/testing/node/MockServices.kt +++ b/test-utils/src/main/kotlin/net/corda/testing/node/MockServices.kt @@ -1,5 +1,6 @@ package net.corda.testing.node +import net.corda.core.internal.AbstractAttachment import net.corda.core.contracts.Attachment import net.corda.core.crypto.* import net.corda.core.flows.StateMachineRunId @@ -36,7 +37,6 @@ import net.corda.testing.schemas.DummyLinearStateSchemaV1 import org.bouncycastle.operator.ContentSigner import rx.Observable import rx.subjects.PublishSubject -import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File import java.io.InputStream @@ -222,9 +222,8 @@ class MockAttachmentStorage : AttachmentStorage, SingletonSerializeAsToken() { override fun openAttachment(id: SecureHash): Attachment? { val f = files[id] ?: return null - return object : Attachment { - override fun open(): InputStream = ByteArrayInputStream(f) - override val id: SecureHash = id + return object : AbstractAttachment({ f }) { + override val id = id } } diff --git a/verifier/src/integration-test/kotlin/net/corda/verifier/GeneratedLedger.kt b/verifier/src/integration-test/kotlin/net/corda/verifier/GeneratedLedger.kt index a5890f8a9b..ff4dc2d99f 100644 --- a/verifier/src/integration-test/kotlin/net/corda/verifier/GeneratedLedger.kt +++ b/verifier/src/integration-test/kotlin/net/corda/verifier/GeneratedLedger.kt @@ -8,11 +8,11 @@ import net.corda.core.crypto.sha256 import net.corda.core.identity.AbstractParty import net.corda.core.identity.AnonymousParty import net.corda.core.identity.Party +import net.corda.core.internal.AbstractAttachment import net.corda.core.transactions.LedgerTransaction import net.corda.core.transactions.WireTransaction import net.corda.testing.contracts.DummyContract import net.corda.testing.getTestX509Name -import java.io.ByteArrayInputStream import java.math.BigInteger import java.security.PublicKey import java.util.* @@ -184,11 +184,8 @@ data class GeneratedState( override val contract = DummyContract() } -class GeneratedAttachment( - val bytes: ByteArray -) : Attachment { +class GeneratedAttachment(bytes: ByteArray) : AbstractAttachment({ bytes }) { override val id = bytes.sha256() - override fun open() = ByteArrayInputStream(bytes) } class GeneratedCommandData( diff --git a/webserver/src/main/kotlin/net/corda/webserver/servlets/AttachmentDownloadServlet.kt b/webserver/src/main/kotlin/net/corda/webserver/servlets/AttachmentDownloadServlet.kt index 9171e02e60..9750f595ac 100644 --- a/webserver/src/main/kotlin/net/corda/webserver/servlets/AttachmentDownloadServlet.kt +++ b/webserver/src/main/kotlin/net/corda/webserver/servlets/AttachmentDownloadServlet.kt @@ -1,6 +1,6 @@ package net.corda.webserver.servlets -import net.corda.core.contracts.extractFile +import net.corda.core.internal.extractFile import net.corda.core.crypto.SecureHash import net.corda.core.messaging.CordaRPCOps import net.corda.core.utilities.loggerFor