mirror of
https://github.com/corda/corda.git
synced 2025-02-21 09:51:57 +00:00
Add support for signed attachments (#1369)
This commit is contained in:
parent
3ceee23901
commit
0e250e9279
42
core/src/main/kotlin/net/corda/core/contracts/Attachment.kt
Normal file
42
core/src/main/kotlin/net/corda/core/contracts/Attachment.kt
Normal file
@ -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<Party>
|
||||
}
|
@ -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<in OldState : ContractState, out NewState : ContractS
|
||||
fun upgrade(state: OldState): NewState
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) }
|
||||
}
|
||||
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
|
@ -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 <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()
|
||||
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<CodeSigner>? = 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<CodeSigner>()).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)
|
||||
}
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user