Add support for signed attachments (#1369)

This commit is contained in:
Andrzej Cichocki 2017-09-04 16:19:12 +01:00 committed by GitHub
parent 3ceee23901
commit 0e250e9279
11 changed files with 231 additions and 82 deletions

View 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>
}

View File

@ -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.

View File

@ -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)
}

View File

@ -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

View File

@ -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()

View File

@ -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)
}
}

View File

@ -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) {

View File

@ -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

View File

@ -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
}
}

View File

@ -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(

View File

@ -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