All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.r3.conclave.client.EnclaveClient.kt Maven / Gradle / Ivy

There is a newer version: 1.4-beta1
Show newest version
package com.r3.conclave.client

import com.r3.conclave.client.EnclaveClient.State.*
import com.r3.conclave.common.EnclaveConstraint
import com.r3.conclave.common.EnclaveException
import com.r3.conclave.common.EnclaveInstanceInfo
import com.r3.conclave.common.InvalidEnclaveException
import com.r3.conclave.common.internal.StateManager
import com.r3.conclave.mail.*
import com.r3.conclave.mail.internal.*
import com.r3.conclave.mail.internal.postoffice.AbstractPostOffice
import com.r3.conclave.utilities.internal.*
import java.io.Closeable
import java.io.IOException
import java.security.PrivateKey
import java.security.PublicKey
import java.util.concurrent.ScheduledExecutorService

/**
 * Represents a client of an enclave. [EnclaveClient] manages the encryption of requests that
 * need to be sent to an enclave ([sendMail]), and using an [EnclaveTransport], transports encrypted mail to the
 * host for delivery to the enclave. It also provides a polling mechanism with [pollMail] for retrieving any
 * asychronous responses from the enclave.
 *
 * Creating a new client instance requires providing an [EnclaveConstraint] object. This acts as an identity of the
 * enclave and is used to check that the client is communicating with the intended one. A private encryption key is also
 * required. There is an constructor overload which creates a random new one for you, or you can use
 * [Curve25519PrivateKey.random](https://docs.conclave.net/api/-conclave%20-core/com.r3.conclave.mail/-curve25519-private-key/random.html)
 * to create a new random one yourself.
 *
 * After creating a new client, users will need to call [start] and provide an [EnclaveTransport] for communicating with
 * the host. Which implemenation of [EnclaveTransport] is used will depend on the network transport between the client
 * and host. If the host is using the `conclave-web-host` server then users can use
 * [com.r3.conclave.client.web.WebEnclaveTransport] for the transport.
 *
 * ### Sending and receiving Mail
 *
 * After starting the client is able to send mail to the enclave using [sendMail]. [EnclaveClient] will encrypt the body
 * and optional envelope with the private encryption key and then pass the encrypted bytes onto the provided
 * [EnclaveTransport] to deliver it to the host. [sendMail] can optionally return back a decrypted [EnclaveMail] which
 * is the synchronous response from the enclave, if it produced one.
 *
 * For receiving asychronous responses, i.e. those which the enclave produces for the client but which are produced
 * due to other clients, [pollMail] will return the next asynchronous response if one is available.
 *
 * ### Thread safety
 *
 * [EnclaveClient] is not thread-safe. [sendMail] and [pollMail] must be called from the same thread. If the client
 * wants to poll for asychronous mail in a background thread then that same thread must also be used for sending mail.
 * A simple way to achieve this is to use a single-threaded [ScheduledExecutorService]. The following (incomplete) code
 * sample shows how this might be done.
 *
 * ```java
 *     ScheduledExecutorService singleThreadWorker = Executors.newSingleThreadScheduledExecutor();
 *     BlockingQueue receivedMail = new LinkedBlockingQueue<>();
 *
 *     private ScheduledFuture pollingTask;
 *
 *     void start() throws InvalidEnclaveException, IOException {
 *          enclaveClient.start(enclaveTransport);
 *          pollingTask = singleThreadWorker.scheduleAtFixedRate(() -> {
 *              try {
 *                  EnclaveMail mail = enclaveClient.pollMail();
 *                  if (mail != null) {
 *                      receivedMail.add(mail);
 *                  }
 *              } catch (IOException e) {
 *                  throw new UncheckedIOException(e);
 *              }
 *          }, 0, 5, TimeUnit.SECONDS);  // Poll every 5 seconds
 *     }
 *
 *     Future sendMail(byte[] body) {
 *          return singleThreadWorker.submit(() -> enclaveClient.sendMail(body));
 *     }
 *
 *     void close() throws IOException {
 *          if (pollTask != null)
 *              pollingTask.cancel(true);
 *          enclaveClient.close();
 *     }
 * ```
 *
 * ### Enclave rollback
 *
 * One key security feature [EnclaveClient] provides is detection of rollback attacks by the host on the enclave. This
 * type of attack is where the host restarts the enclave but in an older state. The enclave itself is unable to detect
 * this but it can leverage its clients so that they may detect it. However not all clients may detect the rollback, but
 * at least some will.
 *
 * Detection is enabled by default in [EnclaveClient]. However it's only detected when the client next receives mail
 * which is why [sendMail] and [pollMail] can throw [EnclaveRollbackException]. What action needs to be taken depends on
 * the client's security policy but it may include contacting other clients (if any are known) or maybe even the enclave
 * host if there is genuine foul play and they can be brought to account. It's also important to note a rollback can
 * arise from a genuine mistake if the host accidently restarted the enclave from an old state.
 *
 * The client can continue to be used to send and receive mail after an [EnclaveRollbackException] is thrown. However
 * the state of the client and the state of the enclave are probably now out of sync and further communication with the
 * enclave may be unsafe. If rollback attacks are not a concern at all then detection for it can be turned off
 * completely by overriding [ignoreEnclaveRollback] to return true. The same security caveats apply.
 *
 * ### Restarts
 *
 * [EnclaveClient] is designed to handle both enclave and client restarts. If the enclave restarts then its encryption
 * key changes but [EnclaveClient] will automatically switch to the new encryption key when it detects this.
 * Notifications for restarts can be enabled by overriding [onEnclaveRestarted]. This might be needed if the enclave is
 * non-persistent and the client needs to re-submit their data. It could also be used as an indication of a rollback
 * attack.
 *
 * To support client restarts the state of [EnclaveClient] must be persisted and then restored. Failing to do this will
 * prevent the client from sending or receive mail due to spurious errors. To this end, [save] can be used to serialize
 * the state of the client which can be restored using the [EnclaveClient] constructor that takes in a byte array.
 * This is provided solely as a convenience and the state of the client can be persisted any other way. See the [save]
 * documentation for more details.
 *
 * ### PostOffice
 *
 * [EnclaveClient] is an extension to [PostOffice] and provides features it cannot support. If these features are not
 * needed and you simply need to send a one-off mail, for example, then use [EnclaveInstanceInfo.createPostOffice] to
 * create a post office for encrypting requests and decrypting any responses.
 *
 * @see EnclaveTransport
 */
open class EnclaveClient private constructor(
    clientPrivateKey: PrivateKey?,
    enclaveConstraint: EnclaveConstraint?,
    savedClient: ByteArray?
) : Closeable {
    /** Creates a new client with a new random private key. */
    constructor(enclaveConstraint: EnclaveConstraint) : this(Curve25519PrivateKey.random(), enclaveConstraint, null)

    /** Creates a new client with an existing private key. */
    constructor(clientPrivateKey: PrivateKey, enclaveConstraint: EnclaveConstraint) : this(
        clientPrivateKey,
        enclaveConstraint,
        null
    )

    /**
     * Creates a new client from the serialized state of a previous one that used [save].
     * @throws IllegalArgumentException If the bytes do not represent an enclave client or is corrupted.
     */
    constructor(savedClient: ByteArray) : this(null, null, savedClient)

    /**
     * Returns the private key that is used to encrypt mail to send to the enclave.
     */
    val clientPrivateKey: PrivateKey

    /**
     * The constraints the enclave must satisfy before this client will communicate with it.
     */
    var enclaveConstraint: EnclaveConstraint

    private val stateManager: StateManager
    private val _postOffices = HashMap()
    private var _lastSeenStateId: EnclaveStateId? = null

    init {
        if (savedClient == null) {
            this.clientPrivateKey = clientPrivateKey!!
            this.enclaveConstraint = enclaveConstraint!!
            stateManager = StateManager(New(null, emptyMap()))
        } else {
            val dis = savedClient.dataStream()
            try {
                require(dis.readExactlyNBytes(MAGIC.size).contentEquals(MAGIC)) { "Not a serialized enclave client" }
                val version = dis.read()
                require(version == 1)
                this.clientPrivateKey = Curve25519PrivateKey(dis.readExactlyNBytes(32))
                this.enclaveConstraint = EnclaveConstraint.parse(dis.readUTF())
                val previousEnclaveKey = dis.nullableRead { Curve25519PublicKey(readExactlyNBytes(32)) }
                _lastSeenStateId = dis.nullableRead { readEnclaveStateId() }
                val previousSequenceNumbers = HashMap()
                repeat(dis.readInt()) {
                    val topic = dis.readUTF()
                    val sequenceNumber = dis.readLong()
                    previousSequenceNumbers[topic] = sequenceNumber
                }
                stateManager = StateManager(New(previousEnclaveKey, previousSequenceNumbers))
            } catch (e: IOException) {
                throw IllegalArgumentException("Corrupted serialized enclave client", e)
            }
        }
    }

    /**
     * Returns the corresponding public key of [clientPrivateKey]. This will be what the enclave sees as the
     * authenticated sender of mail it receives from this client.
     *
     * @see EnclaveMail.authenticatedSender
     */
    val clientPublicKey: PublicKey get() = privateCurve25519KeyToPublic(clientPrivateKey)

    /**
     * Returns the [EnclaveTransport] used to communicate with the host.
     * @throws IllegalStateException If the client has not been started.
     */
    val transport: EnclaveTransport get() = currentOrPreviousRunningState.transport

    /**
     * Returns the most recent [EnclaveInstanceInfo] downloaded from the host via the transport.
     * @throws IllegalStateException If the client has not been started.
     */
    val enclaveInstanceInfo: EnclaveInstanceInfo get() = currentOrPreviousRunningState.enclaveInstanceInfo

    /**
     * Returns this client's [EnclaveTransport.ClientConnection] instance with the enclave transport.
     * @throws IllegalStateException If the client has not been started.
     */
    val clientConnection: EnclaveTransport.ClientConnection get() = currentOrPreviousRunningState.clientConnection

    /**
     * Starts the client with the given [EnclaveTransport].
     *
     * @throws IOException If a connection could not be made to the host via the transport.
     * @throws InvalidEnclaveException If the enclave's [EnclaveInstanceInfo] does not satisfy the client's constraints.
     * @throws IllegalStateException If the client has already been started or has been closed.
     */
    @Throws(IOException::class, InvalidEnclaveException::class)
    fun start(transport: EnclaveTransport) {
        val newState = stateManager.checkStateIs { "The client has not been started or has been closed." }
        val enclaveInstanceInfo = transport.enclaveInstanceInfo()
        enclaveConstraint.check(enclaveInstanceInfo)
        if (newState.previousEnclaveKey == enclaveInstanceInfo.encryptionKey) {
            for ((topic, sequenceNumber) in newState.previousSequenceNumbers) {
                val postOffice = enclaveInstanceInfo.createPostOffice(clientPrivateKey, topic)
                postOffice.setNextSequenceNumber(sequenceNumber)
                _postOffices[topic] = postOffice
            }
        } else {
            // If the enclave's key has changed (i.e. it has restarted) since the last time the client was running then
            // it will be expecting sequence number zero. However there's no point creating such a PostOffice now. It
            // can be created if and when the user next uses those topics.
        }

        val clientConnection = transport.connect(this)
        stateManager.state = Running(transport, clientConnection, enclaveInstanceInfo)
    }

    /**
     * Encrypt and send a mail message with the given body to the host using the transport for delivery to the enclave.
     * The mail will have a topic of "default" and an empty envelope.
     *
     * This method will block until the enclave has processed the mail successfully. If the enclave synchronously
     * responds back with mail then they will be decrypted and returned back here. Any responses the enclave produces
     * asychronously after the mail has been produced will be returned by [pollMail].
     *
     * If the enclave throws an exception during the processing of the request mail then this method will throw an
     * [EnclaveException]. The message from the original enclave exception may or may not be present. In particular, if
     * the enclave is running in release mode then the message will never be present. This is to prevent any potential
     * secrets from being leaked.
     *
     * @param body The body of the mail that is to be encrypted with the client's private key.
     *
     * @throws IOException If the mail could not be sent to the host or the response could not be processed.
     * @throws EnclaveException If the enclave threw an exception.
     * @throws EnclaveRollbackException If the client has detected that the enclave's state has been rolled back.
     * @throws IllegalStateException If the client is not running.
     *
     * @see EnclaveMail
     */
    @Throws(IOException::class)
    fun sendMail(body: ByteArray): EnclaveMail? = sendMail("default", body, null)

    /**
     * Encrypt and send a mail message with the given body to the host using the transport for delivery to the enclave.
     * It will have the given topic and envelope.
     *
     * This method will block until the enclave has processed the mail successfully. If the enclave synchronously
     * responds back with mail then it will be decrypted and returned back here. Any responses the enclave produces
     * asychronously after the mail has been produced need to be polled for using [pollMail].
     *
     * If the enclave threw an exception during the processing of the request mail then this method will throw an
     * [EnclaveException]. The message from the original enclave exception may or may not be present. In particular, if
     * the enclave is running in release mode then the message will never be present. This is to prevent any potential
     * secrets from being leaked.
     *
     * @param topic The topic to use in the mail. See [EnclaveMail.topic].
     * @param body The body of the mail that is to be encrypted with the client's private key.
     * @param envelope Optional visible, but authenticated, portion of the mail. See [EnclaveMail.envelope].
     *
     * @throws IOException If the mail could not be sent to the host or the response could not be processed.
     * @throws EnclaveException If the enclave threw an exception.
     * @throws EnclaveRollbackException If the client has detected that the enclave's state has been rolled back.
     * @throws IllegalStateException If the client is not running.
     *
     * @see EnclaveMail
     */
    @Throws(IOException::class)
    fun sendMail(topic: String, body: ByteArray, envelope: ByteArray?): EnclaveMail? {
        val runningState = stateManager.checkStateIs { "The client is not running." }
        val (transport, clientHandle) = runningState

        for (i in 0 until MAX_RETRY_ATTEMPTS) {
            val encryptedMailBytes = postOffice(topic).encryptMail(body, envelope)

            val response = try {
                clientHandle.sendMail(encryptedMailBytes)
            } catch (e: MailDecryptionException) {
                // The enclave was unable to decrypt our mail. Hopefully it's because the enclave was restarted and thus
                // has a new encryption key. Let's re-download the EII and try again with the new key.
                val newEnclaveInstanceInfo = transport.enclaveInstanceInfo()
                if (newEnclaveInstanceInfo.encryptionKey == runningState.enclaveInstanceInfo.encryptionKey) {
                    // Turns out the enclave's key hasn't changed, which means something else has happened, probably a
                    // bug in the transport layer not picking up the new EII. Either way the exception needs to be
                    // propagated to the caller.
                    throw IOException(e)
                }
                try {
                    enclaveConstraint.check(newEnclaveInstanceInfo)
                } catch (e: InvalidEnclaveException) {
                    throw IOException("The enclave has a new EnclaveInstanceInfo which no longer satisfies the " +
                            "client's constraints", e)
                }
                // All existing post office instances are now invalid as they're using the old encryption key.
                resetPostOffices(newEnclaveInstanceInfo)
                runningState.enclaveInstanceInfo = newEnclaveInstanceInfo
                onEnclaveRestarted()
                continue
            }

            return response?.let { processMail(it, runningState.enclaveInstanceInfo) }
        }

        // If we get here it's then reasonable to assume something is wrong with the host/transport and we need to abort.
        throw IOException("Aborted attempt to send mail as the enclave has been restarted several times whilst " +
                "trying to send it mail.")
    }

    /**
     * Polls the host for the next asynchronous mail response from the enclave, if there is one, and returns it
     * decrypted. Otherwise returns `null`.
     *
     * @return The next asynchronous mail response decrypted, or `null` if there isn't one.
     * @throws IOException If the client is unable to poll the host or retrieve the mail.
     * @throws EnclaveRollbackException If the client has detected that the enclave's state has been rolled back.
     * @throws IllegalStateException If the client is not running.
     */
    @Throws(IOException::class)
    fun pollMail(): EnclaveMail? {
        val (_, clientHandle, enclaveInstanceInfo) = stateManager.checkStateIs { "The client is not running." }
        val responseBytes = clientHandle.pollMail()
        return responseBytes?.let { processMail(it, enclaveInstanceInfo) }
    }

    /**
     * Determines whether the client should continue processing mail it's getting from host if it detects the enclave's
     * state has been rolled back.
     *
     * A rollback is a type of attack the host can do on an enclave to make it think it is dealing with up-to-date data
     * when in fact it's been given old out-of-date data. Due to the nature of enclaves they have no way of detecting
     * this themeselves and so must rely on their clients to detect for them.
     *
     * The default implementation returns false which means the client will not process the mail and [sendMail] and
     * [pollMail] will throw an [EnclaveRollbackException]. Override this method if you wish to change this. You can
     * connect this method to a UI, for example, if the decision needs to be made by the user.
     *
     * @return `true` to ignore the detected rollback and continue processing the mail. `false` to stop processing mail
     * and have [EnclaveRollbackException] be thrown by [sendMail] and [pollMail].
     *
     * @see EnclaveRollbackException
     */
    protected open fun ignoreEnclaveRollback(): Boolean = false

    /**
     * Called when the client detects the enclave has been restarted. Override this method to get notification of any
     * restarts. The default implementation does nothing.
     *
     * Note, it's not necessary that all restarts will be detected. Plus, [sendMail] is required before a restart can be
     * detected.
     */
    protected open fun onEnclaveRestarted() {

    }

    /**
     * Returns the last seen state ID from the enclave. The state ID is tracked to detect attempts to roll back the
     * state of the enclave by the host.
     *
     * Access to this value is typically only needed to persist it if a custom serialisation scheme is used (i.e. not
     * [save]). Make sure to restore this value on the new client object to ensure it can continue communicating with
     * the enclave.
     */
    var lastSeenStateId: ByteArray?
        set(value) {
            _lastSeenStateId = value?.let { EnclaveStateId(it.clone()) }
        }
        get() = _lastSeenStateId?.bytes?.clone()

    /**
     * Returns the [PostOffice] instance for the given topic, creating a new one if one doesn't already exist.
     * @throws IllegalStateException If the client has not been started.
     */
    fun postOffice(topic: String): PostOffice {
        return _postOffices.computeIfAbsent(topic) {
            enclaveInstanceInfo.createPostOffice(clientPrivateKey, topic)
        }
    }

    /**
     * Returns the set of [PostOffice]s that have been used to send mail.
     *
     * The returned [Set] is a copy. Adding or removing from it does not affect the client.
     */
    val postOffices: Set get() = _postOffices.values.toSet()

    /**
     * Serializes the state of the client to a byte array so that it can be safely persisted and restored if the client is
     * restarted. Use [EnclaveClient.restoreState] to materialise the state again. As the private key is also serialized
     * it's vitally important the bytes are persisted securely or are encrypted.
     *
     * It is recommended that state be saved after every [sendMail] and [pollMail] call.
     *
     * This method is just a convenience and you may use your own serialization scheme to persist the enclave's state.
     * To ensure the client can continue communicating with the enclave if the client is restarted it's important the
     * following is preserved:
     *
     * * The client's private key ([clientPrivateKey])
     * * The enclave's state ID ([lastSeenStateId])
     * * The next sequence number for every topic used. Use [postOffices] for this.
     *
     * @see EnclaveClient.restoreState
     */
    fun save(): ByteArray {
        val state = stateManager.state
        val enclaveKey = when (state) {
            is New -> state.previousEnclaveKey
            is Running -> state.enclaveInstanceInfo.encryptionKey
            is Closed -> state.running?.enclaveInstanceInfo?.encryptionKey
        }
        val sequenceNumbers = when (state) {
            is New -> state.previousSequenceNumbers.map { it.toPair() }
            else -> _postOffices.map { Pair(it.key, it.value.nextSequenceNumber) }
        }
        return writeData {
            write(MAGIC)
            write(1)  // Version
            write(clientPrivateKey.encoded)
            writeUTF(enclaveConstraint.toString())
            nullableWrite(enclaveKey) { write(it.encoded) }
            nullableWrite(_lastSeenStateId) { write(it.bytes) }
            writeList(sequenceNumbers) { (topic, sequenceNumber) ->
                writeUTF(topic)
                writeLong(sequenceNumber)
            }
        }
    }

    /**
     * Closes the client and disconnects it from the enclave transport. This is a no-op if the client is already closed.
     */
    @Throws(IOException::class)
    override fun close() {
        val runningState = when (val state = stateManager.state) {
            is New -> null
            is Running -> state
            is Closed -> return
        }
        stateManager.state = Closed(runningState)
        runningState?.clientConnection?.disconnect()
    }

    private fun processMail(
        encryptedMail: ByteArray,
        enclaveInstanceInfo: EnclaveInstanceInfo,
    ): DecryptedEnclaveMail {
        val mail = try {
            AbstractPostOffice.decryptMail(
                encryptedMail,
                clientPrivateKey,
                enclaveInstanceInfo.encryptionKey
            )
        } catch (e: MailDecryptionException) {
            throw IOException("Unable to decrypt received mail", e)
        }

        // See if the mail has a private header. If it does then the enclave has been configured for rollback detection
        // and has sent us the necessary information to detect if the host has rolled back its state.
        mail.privateHeader?.deserialise {
            val version = read()
            check(version == 1)
            val receivedStateId = readEnclaveStateId()
            val expectedPreviousStateId = nullableRead { readEnclaveStateId() }

            val previousStateId = _lastSeenStateId
            if (receivedStateId != previousStateId) {
                // We update the last seen state first so that the client can continue to receive mail if they wish
                // after the exception is thrown.
                _lastSeenStateId = receivedStateId
                if (previousStateId != expectedPreviousStateId && !ignoreEnclaveRollback()) {
                    throw EnclaveRollbackException("Possible dropped mail or enclave state rollback by the host " +
                            "detected. Expected $_lastSeenStateId but got $expectedPreviousStateId.", mail)
                }
            } else {
                // If the state ID hasn't changed then it probably means the enclave has sent multiple mail from the
                // same receiveMail/receiveFromUntrustedHost invocation. Or it could mean the host is replaying the same
                // mail to us. We may want to add replay, re-order and dropped mail detection support. But this is
                // something slightly different to roll back detection and would need to be handled separately, probably
                // by checking the sequence numbers. https://r3-cev.atlassian.net/browse/CON-625
            }
        }

        return mail
    }

    private val currentOrPreviousRunningState: Running get() {
        return when (val state = stateManager.state) {
            is New -> throw IllegalStateException("Client has not been started.")
            is Running -> state
            is Closed -> checkNotNull(state.running) { "Client was never started." }
        }
    }

    private fun resetPostOffices(enclaveInstanceInfo: EnclaveInstanceInfo) {
        // Replace any existing post offices with new ones from the current EnclaveInstanceInfo whilst preserving the
        // min size policies. The sequence numbers are reset to zero.
        _postOffices.replaceAll { topic, old ->
            enclaveInstanceInfo.createPostOffice(clientPrivateKey, topic).apply {
                minSizePolicy = old.minSizePolicy
            }
        }
    }


    private sealed class State {
        data class New(
            val previousEnclaveKey: Curve25519PublicKey?,
            val previousSequenceNumbers: Map
        ) : State()

        data class Running(
            val transport: EnclaveTransport,
            val clientConnection: EnclaveTransport.ClientConnection,
            var enclaveInstanceInfo: EnclaveInstanceInfo
        ) : State()

        data class Closed(val running: Running?) : State()
    }

    companion object {
        private const val MAX_RETRY_ATTEMPTS = 10
        private val MAGIC = "EnclaveClient".toByteArray()
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy