From d6f177a40760d29337c7745d8e5b464c1694003b Mon Sep 17 00:00:00 2001 From: Peter Sorotokin Date: Fri, 4 Oct 2024 19:11:38 -0700 Subject: [PATCH] Minor fix for Flow RPC and design documentation for it. Signed-off-by: Peter Sorotokin --- identity-flow/design.md | 332 ++++++++++++++++++ .../android/identity/flow/client/FlowBase.kt | 3 + .../identity/flow/FlowProcessorTest.kt | 27 ++ .../identity/processor/FlowSymbolProcessor.kt | 4 +- .../issuance/simple/SimpleIssuingAuthority.kt | 6 + .../SimpleIssuingAuthorityProofingFlow.kt | 6 + .../SimpleIssuingAuthorityRegistrationFlow.kt | 7 + ...eIssuingAuthorityRequestCredentialsFlow.kt | 6 + 8 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 identity-flow/design.md diff --git a/identity-flow/design.md b/identity-flow/design.md new file mode 100644 index 000000000..f22268678 --- /dev/null +++ b/identity-flow/design.md @@ -0,0 +1,332 @@ +# Flow RPC design + +This module (together with the Flow RPC annotation processor) implements "Flow RPC" system. +This file describes how Flow RPC works. + +## About Flow RPC + +Flow RPC was created to simplify server-client communication. It was designed to have these +properties: + * parameters and results are Cbor-serialized objects, + * primary programming language target is Kotlin on both server and client side, + * all RPC marshalling can be generated automatically using Kotlin annotation processor + * server-side state is round-tripped to the client, no server storage is inherently required, + * server-side code can also be easily run in the client environment if desired. + +The name "flow" was used because many of the interfaces exposed by this mechanism represent some +kind of a workflow, e.g. a number of methods that the client must call in certain sequence. This +pattern is captured by the Flow RPC design, as client-side RPC stubs store the server-side state +as opaque data, and that state is automatically updated on every method call, thus every call +always has the state from the previous call. + +The word "flow" in "Flow RPC" is not related to Kotlin flows. + +While Kotlin is the primary target, the protocol itself is language-agnostic. + +## Flows and data + +An object such as an interface, parameter, or result can either be a _flow_ or _data_. + +Data objects are just marshalled (serialized and deserialized) between the client and the server. +They can be primitive (e.g. `String`) or of a Cbor-serializable type. Their representation is +exactly the same on the server and on the client. Exceptions and notifications are always data +objects. + +Flows are interfaces that the server exposes for the client to call. On the client, they +are a represented by interfaces derived from `FlowBase` interface and marked with `FlowInterface` +annotation. The client stubs implementing these interfaces are automatically generated by the +Flow RPC annotation processor. On the server flows are represented by a Cbor-serializable state +objects marked with `FlowState` annotation. Server state is internally marshalled between +the server and the client on each RPC call, but it is opaque for the client as server encrypts +and decrypts it as it marshals/unmarshals it. Therefore the server and client representation of +a flow is very different. On the server, flow is a Cbor-serializable Kotlin class with some +methods marked with `FlowMethod`. On the client, flow is an interface and an automatically +generated stub class implementing that interface. + +On the server the signature of methods that are marked with `FlowMethod` annotation +must match corresponding methods in the flow interface, except that there is always an additional +(first) parameter of type `FlowEnvironment`. That parameter helps server-side code to access +server-side APIs, such as `Configuration` or `Storage`. + +Multiple implementations can be exposed for a given flow interface, so in the protocol a flow +is always represented by the (opaque, serialized and encrypted) state and the "path" that +identifies a particular implementation on the server (by default the path is just the name of the +state class). + +## Dispatcher + +The lowest level of the Flow RPC stack is a dispatcher. It is represented by this interface: + +```kotlin +interface FlowDispatcher { + val exceptionMap: FlowExceptionMap + suspend fun dispatch(flow: String, method: String, args: List): List +} +``` + +Here `flow` is the path of the flow, `method` is method name to be called and `args` +represents flow state and method arguments. The first element is always opaque server-side +state: Cbor-serialized state object encrypted with the secret server key and wrapped into +Cbor `Bstr`. Other elements of the array encode method arguments one by one. For data arguments +their Cbor representation is used. Flow arguments are encoded as two-element Cbor array, where +the first element is flow path (as `Tstr`) and the second is opaque flow state (as `Bstr`). + +Result array is encoded as three- or four- element array: the first element is updated opaque +flow state, the second element is an integer indicating result type: `0` for normal result +and `1` for an exception. For the normal result the third element represents the returned value. +For an exception, the third element is an exception id, and the fourth is Cbor representation +of the exception object. + +Kotlin `Unit` return type is represented as an empty `Bstr`. Data return types and exceptions +are represented by their Cbor representations. Returned flows are represented as three-element +arrays. The first element of the array is a `Tstr` that holds result flow's path, the second is +the name of the flow _join_ method as `Tstr` (see the next section) and the third is the opaque +state of the result flow. + +## Flow creating and joining + +A from which is marked with `creatable = true` in its `FlowState` annotation, can be created +directly by the client. By default the stub implementation class generated by the annotation +processor from the interface has the same name as the interface with `Impl` suffix added at the +end. Stub implementation class constructor will be defined like this: + +```kotlin +class ExampleImpl(flowPath: String, flowState: DataItem, + flowDispatcher: FlowDispatcher, flowNotifier: FlowNotifier, + onComplete: suspend (DataItem) -> Unit = {}) +``` + +By convention, empty `Bstr` passed to `flowState` parameter will be translated into creating +a new state object that corresponds to the given `flowPath` with no parameters. `FlowNotifier` +will be covered in a later section. + +A common pattern for an API on a flow interface `A` to create another flow interface `B`, with the +expectation that the client will call some additional APIs on the flow `B`, and then switch +back interacting with the original flow `A`. Flow creation is simple: a method of the flow `A` +just needs to return an object corresponding to the flow `B`. To represent the hand-over back to the +original flow, `FlowBase` exposes `complete` method. This method should be called when the +interaction with the flow (`B` in our example) is done. The server-side implementation for flow `A` +can expose a method marked with `FlowJoin` annotation that takes the state for the flow `B` as +a parameter. This method will be called when `complete` method is called for `B` on the client +side. I a `FlowJoin` method is not defined on `A`, calling `complete` on `B` is a no-op. + +## Example + +Suppose we have interfaces defined like this: +```kotlin +@CborSerializable +data class MyData(val text: String, val num: Int) + +@FlowInterface +interface ExampleFlow: FlowBase { + @FlowMethod + suspend fun exampleMethod(data: MyData): ByteString +} + +@FlowInterface +interface ExampleFactory: FlowBase { + @FlowMethod + suspend fun create(name: String): ExampleFlow +} +``` + +Server-side implementation could look like this: + +```kotlin +@CborSerializable +@FlowState(flowInterface = ExampleFlow::class) +data class ExampleState(var someData: Int = 0) { + companion object + + @FlowMethod + fun exampleMethod(env: FlowEnvironment, data: MyData): ByteString { + // possibly modify someData... + return /* some value */ + } +} + +@CborSerializable +@FlowState(flowInterface = ExampleFactory::class, creatable = true) +data class ExampleFactoryState(var someText: String = "") { + companion object + + @FlowMethod + fun create(env: FlowEnvironment, name: String): ExampleState { + return ExampleState(/* params */) + } + + @FlowJoin + fun join(env: FlowEnvironment, obj: ExampleState): Unit { + // some code + } +} +``` + +Now, suppose we have a `FlowDispatcher` implementation in a `flowDispatcher` variable. Running +this code + +```kotlin +val factory = ExampleFactoryImpl( + "ExampleFactoryState", Bstr(), flowDispatcher, FlowNotifier.SILENT) +val example = factory.create("Test") +``` + +will result in the call to `flowDispatcher.dispatch` with the following parameters: + +```kotlin +flowDispatcher.dispatch("ExampleFactoryState", "create", [Bstr(), Tstr("Test")]) +``` + +which after dispatching it to the server-side code will return something like: + +```kotlin +[Bstr(), 0, ["ExampleState", "join", Bstr()]] +``` + +Making this call + +```kotlin +example.exampleMethod(MyData("foobar", 57)) +``` + +will produce the following dispatch + +```kotlin +flowDispatcher.dispatch("ExampleState", "exampleMethod", + [Bstr(), {"text": Tstr("foobar"), "num": 57}]) +``` + +which should produce the following response: + +```kotlin +[Bstr(), 0, Bstr()] +``` + +Finally, calling + +```kotlin +example.complete() +``` + +will produce this dispatch + +```kotlin +flowDispatcher.dispatch("ExampleFactoryState", "join", + [Bstr(), [Tstr("ExampleState"), Bstr()]]) +``` + +and response + +```kotlin +[Bstr(), 0, Bstr()] +``` + +For more fleshed out example, study unit test `FlowProcessorTest`. + +## Running server code locally and transporting over HTTP + +Once we routed Kotlin calls to `FlowDispatch` interface, the next steps are + * to (possibly) transport this call and its response across the network, + * to dispatch `FlowDispatch` call to actual server-side code. + +Let's consider the last task first. Flow RPC provides a `FlowDispatch` interface implementation +called `FlowDispatcherLocal`. Every server-side flow class and every exception must be registered +for dispatch to work. + +Using example code above, this code is needed (register methods are generated by the +annotation processor): + +```kotlin + val builder = FlowDispatcherLocal.Builder() + ExampleFactoryState.register(builder) + ExampleState.register(builder) + val flowDispatch = builder.build( + flowEnvironment, + AesGcmCipher(Random.Default.nextBytes(16)), + FlowExceptionMap.Builder().build() + ) +``` + +If it is desired to run server-side code locally, `flowDispatch` built in this way can be directly +passed to the constructor of the client-side stub implementation. + +A more interesting application is to run RPC across the network. On the client this can be achieved +by using a different implementation of the dispatcher `FlowDispatcherHttp` that translates calls +to `FlowDispatcher.dispatch` to HTTP POST requests, `flow` and `method` parameters of +`FlowDispatcher.dispatch` call are appended as path items to the base url, `args` and result +are serialized as Cbor arrays. On the server side `HttpHandler` can be used to do the reverse: +route HTTP POST requests to `FlowDispatcher.dispatch` calls. + +## Notifications + +A unique feature of Flow RPC system is support for notifications. While traditional RPC is +used to route the call from the client to the server, notifications allow server to request an +action on the client. + +**Note**: notifications are delivered on the "best effort" basis. There are inherent race +conditions in supporting RPC calls and issuing notifications on the same object, as the +object identity becomes difficult to define. Moreover, notification delivery channels that +are likely to be used for large-scale applications (e.g. Android notifications) are also +"best effort" mechanisms. + +Notifications are issued by flows. A flow interface that supports notifications of type `T` must +implement interface `FlowNotifiable` (which extends `FlowBase`). Type `T` must be +Cbor-serializable. This interface exposes a field `FlowNotifiable.notifications: SharedFlow` +which then can be collected to receive notifications (in this case `SharedFlow` is a Kotlin +flow, unrelated to "flow" in Flow RPC name). + +For server-side code, when the implemented interface is notifiable, annotation processor will +generate `emit` function that takes `FlowEnvironment` and the notification object. + +Low-level notification interface on the server side is + +```kotlin +interface FlowNotifications { + suspend fun emit(flowName: String, state: DataItem, notification: DataItem) +} +``` + +On the server, this interface is expected to be provided through the `FlowEnvironment` object (i.e. +by calling `env.getInterface(FlowNotifications::class)`). + +And on the client notifications are supported by this interface + +```kotlin +interface FlowNotifier { + suspend fun register( + flowName: String, + opaqueState: DataItem, + notifications: MutableSharedFlow, + deserializer: (DataItem) -> NotificationT + ) + suspend fun unregister(flowName: String, opaqueState: DataItem) +} +``` + +An instance of a class implementing this must be passed to the stub implementation constructor. + +The important part here is that the client registers for notifications emitted to a particular +flow instance identified by its flow name and opaque state (encrypted and serialized flow state). +On the server, however when notification is sent it is identified by the flow name and Cbor +representation of the state (i.e. not serialized to the sequence of bytes and not encrypted). + +`FlowNotificationsLocal` is an implementation for both of these interfaces which is suitable for +the case when server-side code is being run locally. + +When Flow RPC operates over HTTP, a simple implementation of `FlowNotifier` can be +be created like this `FlowNotifierPoll(FlowPollHttp(httpTransport))`. It uses long poll over a +regular HTTP connection. On the server `FlowNotificationsLocalPoll(cipher)` creates an object +that implements both `FlowNotifications` interface needed by `FlowEnvironment` and `FlowPoll` +interface that needs to be passed as a parameter to `HttpHandler` constructor. + +Note that in high-volume environments these interfaces will need to be implemented through +something like Android notifications. + +## Authentication and security issues + +Flow RPC does not by itself has any user authentication mechanism. However it is fairly easy +to integrate it with an existing authentication, by adding authenticated user id as part of the +flow state. Since flow state is encrypted and opaque to the client, it automatically serves +as an authentication token. Similarly, Flow RPC does not include built-in replay protection or +state expiration: flow state can be cloned and reused. If these features are desired, similarly, +appropriate fields can be included in the flow state. \ No newline at end of file diff --git a/identity-flow/src/main/java/com/android/identity/flow/client/FlowBase.kt b/identity-flow/src/main/java/com/android/identity/flow/client/FlowBase.kt index 7be54e883..2fa213869 100644 --- a/identity-flow/src/main/java/com/android/identity/flow/client/FlowBase.kt +++ b/identity-flow/src/main/java/com/android/identity/flow/client/FlowBase.kt @@ -8,6 +8,9 @@ import com.android.identity.cbor.DataItem * [com.android.identity.flow.annotation.FlowInterface] annotation */ interface FlowBase { + /** Flow path. Only needed in generated code, do not use. */ + val flowPath: String + /** Opaque flow data accessor. Only needed in generated code, do not use. */ val flowState: DataItem diff --git a/identity-flow/src/test/java/com/android/identity/flow/FlowProcessorTest.kt b/identity-flow/src/test/java/com/android/identity/flow/FlowProcessorTest.kt index 2bd60a695..85f13403f 100644 --- a/identity-flow/src/test/java/com/android/identity/flow/FlowProcessorTest.kt +++ b/identity-flow/src/test/java/com/android/identity/flow/FlowProcessorTest.kt @@ -66,6 +66,10 @@ interface SolverFactoryFlow: FlowBase { // Just to test nullable parameters and return values @FlowMethod suspend fun nullableIdentity(value: String?): String? + + // Test passing flows as parameters + @FlowMethod + suspend fun examineSolver(solver: QuadraticSolverFlow): String } // The following definitions are server-side (although we can patch them to run on the client @@ -129,6 +133,8 @@ data class DirectQuadraticSolverState( } override fun finalCount(): Int = count + + override fun toString(): String = "DirectQuadraticSolverState[$count]" } @CborSerializable @@ -155,6 +161,8 @@ class MockQuadraticSolverState : AbstractSolverState() { } override fun finalCount(): Int = 2 + + override fun toString(): String = "MockQuadraticSolverState" } @CborSerializable @@ -200,6 +208,11 @@ data class SolverFactoryState( @FlowMethod fun nullableIdentity(env: FlowEnvironment, value: String?): String? = value + + @FlowMethod + fun examineSolver(env: FlowEnvironment, solver: AbstractSolverState): String { + return solver.toString() + } } // FlowHandlerLocal handles flow calls that executed locally. It is for use on the server, @@ -267,6 +280,20 @@ class FlowProcessorTest { } } + @Test + fun flowParameter() { + runBlocking { + val factory = localFactory() + Assert.assertEquals( + "MockQuadraticSolverState", + factory.examineSolver(factory.createQuadraticSolver("Mock")) + ) + Assert.assertEquals( + "DirectQuadraticSolverState[0]", + factory.examineSolver(factory.createQuadraticSolver("Direct")) + ) + } + } @Test fun remoteUnexpectedNameException() { diff --git a/processor/src/main/kotlin/com/android/identity/processor/FlowSymbolProcessor.kt b/processor/src/main/kotlin/com/android/identity/processor/FlowSymbolProcessor.kt index 933d376f3..0396f8082 100644 --- a/processor/src/main/kotlin/com/android/identity/processor/FlowSymbolProcessor.kt +++ b/processor/src/main/kotlin/com/android/identity/processor/FlowSymbolProcessor.kt @@ -396,7 +396,7 @@ class FlowSymbolProcessor( emptyLine() line("class $baseName(") withIndent { - line("private val flowPath: String,") + line("override val flowPath: String,") line("override var flowState: DataItem,") line("private val flowDispatcher: FlowDispatcher,") line("private val flowNotifier: FlowNotifier,") @@ -483,7 +483,7 @@ class FlowSymbolProcessor( serialization } } else { - "${parameter.name}.flowState" + "CborArray(mutableListOf(Tstr(${parameter.name}.flowPath), ${parameter.name}.flowState))" } } line("val flowParameters = listOf(") diff --git a/wallet/src/main/java/com/android/identity/issuance/simple/SimpleIssuingAuthority.kt b/wallet/src/main/java/com/android/identity/issuance/simple/SimpleIssuingAuthority.kt index 7d48e460a..cb7f2b8f8 100644 --- a/wallet/src/main/java/com/android/identity/issuance/simple/SimpleIssuingAuthority.kt +++ b/wallet/src/main/java/com/android/identity/issuance/simple/SimpleIssuingAuthority.kt @@ -437,6 +437,12 @@ abstract class SimpleIssuingAuthority( // noop } + // Unused in client implementations + override val flowPath: String + get() { + throw UnsupportedOperationException("Unexpected call") + } + // Unused in client implementations override val flowState: DataItem get() { diff --git a/wallet/src/main/java/com/android/identity/issuance/simple/SimpleIssuingAuthorityProofingFlow.kt b/wallet/src/main/java/com/android/identity/issuance/simple/SimpleIssuingAuthorityProofingFlow.kt index e453b1bc0..f34733a15 100644 --- a/wallet/src/main/java/com/android/identity/issuance/simple/SimpleIssuingAuthorityProofingFlow.kt +++ b/wallet/src/main/java/com/android/identity/issuance/simple/SimpleIssuingAuthorityProofingFlow.kt @@ -67,6 +67,12 @@ class SimpleIssuingAuthorityProofingFlow( issuingAuthority.setProofingProcessing(documentId) } + // Unused in client implementations + override val flowPath: String + get() { + throw UnsupportedOperationException("Unexpected call") + } + // Unused in client implementations override val flowState: DataItem get() { diff --git a/wallet/src/main/java/com/android/identity/issuance/simple/SimpleIssuingAuthorityRegistrationFlow.kt b/wallet/src/main/java/com/android/identity/issuance/simple/SimpleIssuingAuthorityRegistrationFlow.kt index 418fca633..53fa2f2a9 100644 --- a/wallet/src/main/java/com/android/identity/issuance/simple/SimpleIssuingAuthorityRegistrationFlow.kt +++ b/wallet/src/main/java/com/android/identity/issuance/simple/SimpleIssuingAuthorityRegistrationFlow.kt @@ -9,6 +9,7 @@ class SimpleIssuingAuthorityRegistrationFlow( private val issuingAuthority: SimpleIssuingAuthority, private val documentId: String ) : RegistrationFlow { + override suspend fun getDocumentRegistrationConfiguration(): RegistrationConfiguration { return RegistrationConfiguration(documentId) } @@ -21,6 +22,12 @@ class SimpleIssuingAuthorityRegistrationFlow( // noop } + // Unused in client implementations + override val flowPath: String + get() { + throw UnsupportedOperationException("Unexpected call") + } + // Unused in client implementations override val flowState: DataItem get() { diff --git a/wallet/src/main/java/com/android/identity/issuance/simple/SimpleIssuingAuthorityRequestCredentialsFlow.kt b/wallet/src/main/java/com/android/identity/issuance/simple/SimpleIssuingAuthorityRequestCredentialsFlow.kt index 2207af41c..f83d78e0c 100644 --- a/wallet/src/main/java/com/android/identity/issuance/simple/SimpleIssuingAuthorityRequestCredentialsFlow.kt +++ b/wallet/src/main/java/com/android/identity/issuance/simple/SimpleIssuingAuthorityRequestCredentialsFlow.kt @@ -34,6 +34,12 @@ class SimpleIssuingAuthorityRequestCredentialsFlow( // noop } + // Unused in client implementations + override val flowPath: String + get() { + throw UnsupportedOperationException("Unexpected call") + } + // Unused in client implementations override val flowState: DataItem get() {